eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// `lchat relay` — self-hosting with zero routing (docs/relay-mode.md,
4// issue #738 Phase 1). Like `lchat try`, but permanent: the client holds an
5// Ed25519 identity, the hostname it claims belongs to that identity, there
6// is no 24-hour age-out, and the connection heals itself — a dropped line
7// reconnects with backoff, and the gateway keeps the hostname parked (503)
8// for a grace window meanwhile.
9//
10// Dormant until a gateway offers a relay domain (`lchat tunnel serve
11// --relay-domain …`); the fleet gateway doesn't yet.
12
13import (
14 "context"
15 "crypto/ed25519"
16 "crypto/rand"
17 "encoding/base64"
18 "flag"
19 "fmt"
20 "math/big"
21 "net"
22 "net/http"
23 "os"
24 "os/exec"
25 "os/signal"
26 "strings"
27 "syscall"
28 "time"
29
30 "lchat/internal/tunnel"
31)
32
33func runRelayCmd(args []string) {
34 fs := flag.NewFlagSet("relay", flag.ExitOnError)
35 gateway := fs.String("gateway", envOr("LCHAT_TUNNEL_GATEWAY", defaultTunnelGateway), "tunnel gateway origin")
36 dbPath := fs.String("db", "lchat-relay.db", "where the space's data lives")
37 local := fs.String("local", "", "relay an ALREADY-RUNNING local instance at this addr instead of starting one")
38 idPath := fs.String("identity", "", "relay identity file (default: <db>.relay-id, or lchat-relay.id with --local)")
39 _ = fs.Parse(reorderFlagsFirst(fs, args))
40
41 keyFile := *idPath
42 if keyFile == "" {
43 if *local != "" {
44 keyFile = "lchat-relay.id"
45 } else {
46 keyFile = *dbPath + ".relay-id"
47 }
48 }
49 identity, freshIdentity, err := loadOrCreateRelayIdentity(keyFile)
50 if err != nil {
51 die("relay: %v", err)
52 }
53 if freshIdentity {
54 fmt.Printf(" New relay identity saved to %s — it IS your hostname; back it up.\n", keyFile)
55 }
56
57 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
58 defer stop()
59
60 // The instance: an existing one (--local), or a subprocess of this binary
61 // exactly like `lchat try` runs.
62 localAddr := *local
63 adminToken := ""
64 freshDB := false
65 if localAddr == "" {
66 l, err := net.Listen("tcp", "127.0.0.1:0")
67 if err != nil {
68 die("relay: %v", err)
69 }
70 localAddr = l.Addr().String()
71 l.Close()
72
73 tokenFile := *dbPath + ".admin-token"
74 adminToken = strings.TrimSpace(readFileOr(tokenFile, ""))
75 freshDB = !fileExists(*dbPath)
76 if adminToken == "" {
77 if adminToken, err = randSlugToken(); err != nil {
78 die("relay: %v", err)
79 }
80 }
81 fmt.Println(" Starting your space…")
82 child := exec.Command(mustExecutable(), "-addr", localAddr, "-db", *dbPath)
83 child.Env = append(os.Environ(), "LCHAT_ADMIN_TOKEN="+adminToken)
84 logFile, err := os.OpenFile(*dbPath+".log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
85 if err != nil {
86 die("relay: %v", err)
87 }
88 child.Stdout, child.Stderr = logFile, logFile
89 if err := child.Start(); err != nil {
90 die("relay: start local instance: %v", err)
91 }
92 defer func() {
93 _ = child.Process.Signal(syscall.SIGTERM)
94 _, _ = child.Process.Wait()
95 }()
96 if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 15*time.Second); err != nil {
97 die("relay: the local instance didn't come up (%v) — see %s.log", err, *dbPath)
98 }
99 if freshDB {
100 if err := os.WriteFile(tokenFile, []byte(adminToken+"\n"), 0o600); err != nil {
101 die("relay: %v", err)
102 }
103 }
104 } else if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 5*time.Second); err != nil {
105 die("relay --local: nothing answering at %s (%v)", localAddr, err)
106 }
107
108 // Connect, and keep connecting: relay mode's contract is that a dropped
109 // line is a blip, not an outage. Backoff doubles to 30s with jitter; the
110 // gateway parks the hostname for its grace window between attempts.
111 printedBanner := false
112 attempt := 0
113 for {
114 cl := &tunnel.Client{Gateway: *gateway, LocalAddr: localAddr, Version: buildVersion, Identity: identity}
115 err := cl.Run(ctx, func(host string) {
116 attempt = 0
117 origin := "https://" + host
118 if printedBanner {
119 fmt.Printf(" ✔ reconnected — %s is live again\n", origin)
120 return
121 }
122 printedBanner = true
123 if err := tunnel.WaitReachable(origin+"/api/version", 30*time.Second); err != nil {
124 fmt.Printf(" ! %s isn't answering yet (%v) — it usually settles in seconds\n", origin, err)
125 }
126 fmt.Printf("\n ✔ Your space is live: %s\n", origin)
127 fmt.Println(" This hostname is yours: it belongs to your relay identity and")
128 fmt.Println(" survives restarts, reboots, and moving house.")
129 if freshDB && adminToken != "" {
130 if link, err := mintSetupInvite(http.DefaultClient, "http://"+localAddr, origin, adminToken); err == nil {
131 fmt.Printf(" ✔ Claim it (you become the admin): %s\n", link)
132 }
133 }
134 fmt.Println()
135 })
136 if ctx.Err() != nil {
137 fmt.Println(" Stopping — your hostname keeps waiting for you.")
138 return
139 }
140 if err != nil {
141 fmt.Printf(" tunnel dropped: %v — reconnecting…\n", err)
142 }
143 attempt++
144 delay := time.Duration(1<<min(attempt, 5)) * time.Second // 2s..32s
145 if j, err := rand.Int(rand.Reader, big.NewInt(int64(delay/4)+1)); err == nil {
146 delay += time.Duration(j.Int64())
147 }
148 select {
149 case <-time.After(delay):
150 case <-ctx.Done():
151 fmt.Println(" Stopping — your hostname keeps waiting for you.")
152 return
153 }
154 }
155}
156
157// loadOrCreateRelayIdentity reads (or mints) the Ed25519 seed that owns the
158// relay hostname. The file is the identity: lose it, lose the name.
159func loadOrCreateRelayIdentity(path string) (ed25519.PrivateKey, bool, error) {
160 if b, err := os.ReadFile(path); err == nil {
161 seed, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(b)))
162 if err != nil || len(seed) != ed25519.SeedSize {
163 return nil, false, fmt.Errorf("%s doesn't look like a relay identity", path)
164 }
165 return ed25519.NewKeyFromSeed(seed), false, nil
166 }
167 seed := make([]byte, ed25519.SeedSize)
168 if _, err := rand.Read(seed); err != nil {
169 return nil, false, err
170 }
171 if err := os.WriteFile(path, []byte(base64.RawURLEncoding.EncodeToString(seed)+"\n"), 0o600); err != nil {
172 return nil, false, err
173 }
174 return ed25519.NewKeyFromSeed(seed), true, nil
175}
176