package main // `lchat relay` — self-hosting with zero routing (docs/relay-mode.md, // issue #738 Phase 1). Like `lchat try`, but permanent: the client holds an // Ed25519 identity, the hostname it claims belongs to that identity, there // is no 24-hour age-out, and the connection heals itself — a dropped line // reconnects with backoff, and the gateway keeps the hostname parked (503) // for a grace window meanwhile. // // Dormant until a gateway offers a relay domain (`lchat tunnel serve // --relay-domain …`); the fleet gateway doesn't yet. import ( "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "flag" "fmt" "math/big" "net" "net/http" "os" "os/exec" "os/signal" "strings" "syscall" "time" "lchat/internal/tunnel" ) func runRelayCmd(args []string) { fs := flag.NewFlagSet("relay", flag.ExitOnError) gateway := fs.String("gateway", envOr("LCHAT_TUNNEL_GATEWAY", defaultTunnelGateway), "tunnel gateway origin") dbPath := fs.String("db", "lchat-relay.db", "where the space's data lives") local := fs.String("local", "", "relay an ALREADY-RUNNING local instance at this addr instead of starting one") idPath := fs.String("identity", "", "relay identity file (default: .relay-id, or lchat-relay.id with --local)") _ = fs.Parse(reorderFlagsFirst(fs, args)) keyFile := *idPath if keyFile == "" { if *local != "" { keyFile = "lchat-relay.id" } else { keyFile = *dbPath + ".relay-id" } } identity, freshIdentity, err := loadOrCreateRelayIdentity(keyFile) if err != nil { die("relay: %v", err) } if freshIdentity { fmt.Printf(" New relay identity saved to %s — it IS your hostname; back it up.\n", keyFile) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // The instance: an existing one (--local), or a subprocess of this binary // exactly like `lchat try` runs. localAddr := *local adminToken := "" freshDB := false if localAddr == "" { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { die("relay: %v", err) } localAddr = l.Addr().String() l.Close() tokenFile := *dbPath + ".admin-token" adminToken = strings.TrimSpace(readFileOr(tokenFile, "")) freshDB = !fileExists(*dbPath) if adminToken == "" { if adminToken, err = randSlugToken(); err != nil { die("relay: %v", err) } } fmt.Println(" Starting your space…") child := exec.Command(mustExecutable(), "-addr", localAddr, "-db", *dbPath) child.Env = append(os.Environ(), "LCHAT_ADMIN_TOKEN="+adminToken) logFile, err := os.OpenFile(*dbPath+".log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { die("relay: %v", err) } child.Stdout, child.Stderr = logFile, logFile if err := child.Start(); err != nil { die("relay: start local instance: %v", err) } defer func() { _ = child.Process.Signal(syscall.SIGTERM) _, _ = child.Process.Wait() }() if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 15*time.Second); err != nil { die("relay: the local instance didn't come up (%v) — see %s.log", err, *dbPath) } if freshDB { if err := os.WriteFile(tokenFile, []byte(adminToken+"\n"), 0o600); err != nil { die("relay: %v", err) } } } else if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 5*time.Second); err != nil { die("relay --local: nothing answering at %s (%v)", localAddr, err) } // Connect, and keep connecting: relay mode's contract is that a dropped // line is a blip, not an outage. Backoff doubles to 30s with jitter; the // gateway parks the hostname for its grace window between attempts. printedBanner := false attempt := 0 for { cl := &tunnel.Client{Gateway: *gateway, LocalAddr: localAddr, Version: buildVersion, Identity: identity} err := cl.Run(ctx, func(host string) { attempt = 0 origin := "https://" + host if printedBanner { fmt.Printf(" ✔ reconnected — %s is live again\n", origin) return } printedBanner = true if err := tunnel.WaitReachable(origin+"/api/version", 30*time.Second); err != nil { fmt.Printf(" ! %s isn't answering yet (%v) — it usually settles in seconds\n", origin, err) } fmt.Printf("\n ✔ Your space is live: %s\n", origin) fmt.Println(" This hostname is yours: it belongs to your relay identity and") fmt.Println(" survives restarts, reboots, and moving house.") if freshDB && adminToken != "" { if link, err := mintSetupInvite(http.DefaultClient, "http://"+localAddr, origin, adminToken); err == nil { fmt.Printf(" ✔ Claim it (you become the admin): %s\n", link) } } fmt.Println() }) if ctx.Err() != nil { fmt.Println(" Stopping — your hostname keeps waiting for you.") return } if err != nil { fmt.Printf(" tunnel dropped: %v — reconnecting…\n", err) } attempt++ delay := time.Duration(1<