| 1 | package 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 | |
| 13 | import ( |
| 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 | |
| 33 | func 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. |
| 159 | func 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 | |