| 1 | package main |
| 2 | |
| 3 | // `lchat try` — try Eleven in one command, from any machine, with nothing to |
| 4 | // configure: it starts a full local instance (a subprocess of this same |
| 5 | // binary, so the ordinary single-instance path is exercised unchanged) and |
| 6 | // opens an outbound reverse tunnel to the public gateway, which hands back a |
| 7 | // real https://try-<slug>.<domain> address under the fleet's wildcard cert. |
| 8 | // No inbound ports, no DNS, no certificates, no root. |
| 9 | // |
| 10 | // `lchat tunnel serve` is the other half: the gateway service that runs on |
| 11 | // the router box behind the ordinary router, publishing each live tunnel as a |
| 12 | // registry route. See internal/tunnel and docs/tunnel-try.md. |
| 13 | |
| 14 | import ( |
| 15 | "context" |
| 16 | "crypto/rand" |
| 17 | "flag" |
| 18 | "fmt" |
| 19 | "net" |
| 20 | "net/http" |
| 21 | "os" |
| 22 | "os/exec" |
| 23 | "os/signal" |
| 24 | "strings" |
| 25 | "syscall" |
| 26 | "time" |
| 27 | |
| 28 | "lchat/internal/tunnel" |
| 29 | ) |
| 30 | |
| 31 | const defaultTunnelGateway = "https://tunnel.11ch.at" |
| 32 | |
| 33 | func runTryCmd(args []string) { |
| 34 | fs := flag.NewFlagSet("try", flag.ExitOnError) |
| 35 | gateway := fs.String("gateway", envOr("LCHAT_TUNNEL_GATEWAY", defaultTunnelGateway), "tunnel gateway origin") |
| 36 | dbPath := fs.String("db", "lchat-try.db", "where the trial space's data lives (kept across runs)") |
| 37 | local := fs.String("local", "", "tunnel an ALREADY-RUNNING local instance at this addr (e.g. 127.0.0.1:1747) instead of starting one") |
| 38 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 39 | |
| 40 | if *local != "" { |
| 41 | runTryLocal(*gateway, *local) |
| 42 | return |
| 43 | } |
| 44 | |
| 45 | // A local port for the instance subprocess. |
| 46 | l, err := net.Listen("tcp", "127.0.0.1:0") |
| 47 | if err != nil { |
| 48 | die("try: %v", err) |
| 49 | } |
| 50 | localAddr := l.Addr().String() |
| 51 | l.Close() |
| 52 | |
| 53 | // The subprocess is this same binary running the plain single-instance |
| 54 | // path. A fresh admin token per run authorises the setup-link mint below; |
| 55 | // on an existing db the stored token stays (ensureAdminToken ignores the |
| 56 | // env when one exists), so re-running `try` on kept data still works — |
| 57 | // we then mint via the token recorded on first run. |
| 58 | tokenFile := *dbPath + ".admin-token" |
| 59 | adminToken := strings.TrimSpace(readFileOr(tokenFile, "")) |
| 60 | fresh := !fileExists(*dbPath) |
| 61 | if adminToken == "" { |
| 62 | if adminToken, err = randSlugToken(); err != nil { |
| 63 | die("try: %v", err) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | fmt.Println(" Starting your trial space…") |
| 68 | child := exec.Command(mustExecutable(), "-addr", localAddr, "-db", *dbPath) |
| 69 | child.Env = append(os.Environ(), "LCHAT_ADMIN_TOKEN="+adminToken) |
| 70 | logPath := *dbPath + ".log" |
| 71 | logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) |
| 72 | if err != nil { |
| 73 | die("try: %v", err) |
| 74 | } |
| 75 | child.Stdout, child.Stderr = logFile, logFile |
| 76 | if err := child.Start(); err != nil { |
| 77 | die("try: start local instance: %v", err) |
| 78 | } |
| 79 | defer func() { |
| 80 | _ = child.Process.Signal(syscall.SIGTERM) |
| 81 | _, _ = child.Process.Wait() |
| 82 | }() |
| 83 | if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 15*time.Second); err != nil { |
| 84 | die("try: the local instance didn't come up (%v) — see %s", err, logPath) |
| 85 | } |
| 86 | if fresh { |
| 87 | if err := os.WriteFile(tokenFile, []byte(adminToken+"\n"), 0o600); err != nil { |
| 88 | die("try: %v", err) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
| 93 | defer stop() |
| 94 | |
| 95 | cl := &tunnel.Client{Gateway: *gateway, LocalAddr: localAddr, Version: buildVersion} |
| 96 | errCh := make(chan error, 1) |
| 97 | hostCh := make(chan string, 1) |
| 98 | go func() { errCh <- cl.Run(ctx, func(h string) { hostCh <- h }) }() |
| 99 | |
| 100 | var host string |
| 101 | select { |
| 102 | case host = <-hostCh: |
| 103 | case err := <-errCh: |
| 104 | die("try: %v", err) |
| 105 | case <-time.After(45 * time.Second): |
| 106 | die("try: the tunnel gateway didn't answer") |
| 107 | } |
| 108 | |
| 109 | origin := "https://" + host |
| 110 | fmt.Println(" Address assigned, waiting for it to go live…") |
| 111 | if err := tunnel.WaitReachable(origin+"/api/version", 30*time.Second); err != nil { |
| 112 | die("try: %s %v", origin, err) |
| 113 | } |
| 114 | setupLink, err := mintSetupInvite(http.DefaultClient, "http://"+localAddr, origin, adminToken) |
| 115 | if err != nil { |
| 116 | die("try: mint setup link: %v", err) |
| 117 | } |
| 118 | |
| 119 | fmt.Printf(` |
| 120 | ✔ Your space is live: %s |
| 121 | ✔ Claim it here first: %s |
| 122 | |
| 123 | That second link makes you the admin — open it, pick a name, and |
| 124 | invite people straight away. Anyone you invite can join from any |
| 125 | browser while this command is running. |
| 126 | |
| 127 | This is a tryout: the address works while this command runs and |
| 128 | retires when you stop it (Ctrl-C) or after 24 hours. Your space's |
| 129 | data stays here in %s — nothing leaves this machine |
| 130 | except the same encrypted traffic a hosted space would send. |
| 131 | |
| 132 | Liked it? A permanent home is one small install: |
| 133 | https://elevenmessenger.com/self-host/ |
| 134 | |
| 135 | `, origin, setupLink, *dbPath) |
| 136 | |
| 137 | select { |
| 138 | case err := <-errCh: |
| 139 | if err != nil { |
| 140 | die("try: %v", err) |
| 141 | } |
| 142 | case <-ctx.Done(): |
| 143 | fmt.Println(" Stopping — thanks for trying Eleven.") |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // runTryLocal is `lchat try --local <addr>`: lend a public trial address to an |
| 148 | // instance you're already running (a dev server, a home-lab box) — no |
| 149 | // subprocess, no setup link; the instance's own links keep working because it |
| 150 | // sees the public hostname in every proxied request's Host header. |
| 151 | func runTryLocal(gateway, localAddr string) { |
| 152 | if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 5*time.Second); err != nil { |
| 153 | die("try --local: nothing answering at %s (%v)", localAddr, err) |
| 154 | } |
| 155 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
| 156 | defer stop() |
| 157 | cl := &tunnel.Client{Gateway: gateway, LocalAddr: localAddr, Version: buildVersion} |
| 158 | errCh := make(chan error, 1) |
| 159 | hostCh := make(chan string, 1) |
| 160 | go func() { errCh <- cl.Run(ctx, func(h string) { hostCh <- h }) }() |
| 161 | var host string |
| 162 | select { |
| 163 | case host = <-hostCh: |
| 164 | case err := <-errCh: |
| 165 | die("try --local: %v", err) |
| 166 | case <-time.After(45 * time.Second): |
| 167 | die("try --local: the tunnel gateway didn't answer") |
| 168 | } |
| 169 | origin := "https://" + host |
| 170 | if err := tunnel.WaitReachable(origin+"/api/version", 30*time.Second); err != nil { |
| 171 | die("try --local: %s %v", origin, err) |
| 172 | } |
| 173 | fmt.Printf("\n ✔ %s → %s\n The address retires when you stop this (Ctrl-C) or after 24 hours.\n\n", localAddr, origin) |
| 174 | select { |
| 175 | case err := <-errCh: |
| 176 | if err != nil { |
| 177 | die("try --local: %v", err) |
| 178 | } |
| 179 | case <-ctx.Done(): |
| 180 | fmt.Println(" Stopped.") |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func runTunnelCmd(args []string) { |
| 185 | if len(args) >= 1 && args[0] == "kill" { |
| 186 | runTunnelKill(args[1:]) |
| 187 | return |
| 188 | } |
| 189 | if len(args) < 1 || args[0] != "serve" { |
| 190 | die("usage: lchat tunnel serve [--socket P] [--registry P] [--host H] [--domain D]\n lchat tunnel kill <host> [--registry P] [--forget]") |
| 191 | } |
| 192 | fs := flag.NewFlagSet("tunnel serve", flag.ExitOnError) |
| 193 | socket := fs.String("socket", envOr("LCHAT_TUNNEL_SOCKET", "/run/lchat/tunnel.sock"), "unix socket to serve on (the router fronts it)") |
| 194 | registry := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry to publish tunnel routes in") |
| 195 | host := fs.String("host", envOr("LCHAT_TUNNEL_HOST", "tunnel.11ch.at"), "the gateway's own control hostname") |
| 196 | domain := fs.String("domain", envOr("LCHAT_TUNNEL_DOMAIN", "11ch.at"), "domain trial hosts are minted under (needs the wildcard cert)") |
| 197 | maxTunnels := fs.Int("max", 50, "maximum concurrent trial tunnels") |
| 198 | relayDomain := fs.String("relay-domain", envOr("LCHAT_TUNNEL_RELAY_DOMAIN", ""), "offer stable relay hostnames under this domain (empty = relays off; docs/relay-mode.md)") |
| 199 | claimsPath := fs.String("claims", envOr("LCHAT_TUNNEL_CLAIMS", "/data/tunnel-claims.db"), "relay claim store (with --relay-domain)") |
| 200 | maxRelays := fs.Int("max-relays", 200, "maximum claimed relay hostnames") |
| 201 | claimIdleDays := fs.Int("claim-idle-days", 45, "free a relay hostname whose owner hasn't connected for this many days") |
| 202 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 203 | |
| 204 | reg, err := openRegistry(*registry) |
| 205 | if err != nil { |
| 206 | die("tunnel serve: open registry: %v", err) |
| 207 | } |
| 208 | // Any try- routes pointing at our socket are leftovers from a previous |
| 209 | // gateway process — their tunnels died with it. |
| 210 | if err := reg.deleteTunnelRoutes(*socket); err != nil { |
| 211 | die("tunnel serve: clean stale routes: %v", err) |
| 212 | } |
| 213 | |
| 214 | gw := &tunnel.Gateway{ |
| 215 | ControlHost: *host, |
| 216 | Domain: *domain, |
| 217 | MaxTunnels: *maxTunnels, |
| 218 | MaxRelays: *maxRelays, |
| 219 | OnRegister: func(h string) error { |
| 220 | if existing, err := reg.get(h); err != nil { |
| 221 | return err |
| 222 | } else if existing != nil && existing.Socket != *socket { |
| 223 | // Never steal a hostname something else answers for. Our own |
| 224 | // rows are fine to re-assert: a relay reattaching within its |
| 225 | // grace window re-registers a route that never went away. |
| 226 | return fmt.Errorf("host %s already routed elsewhere", h) |
| 227 | } |
| 228 | // The router's registry watcher (router.go) notices the write and |
| 229 | // reloads within a couple of seconds — no signal needed from here. |
| 230 | return reg.upsert(Route{Host: h, Kind: "service", Socket: *socket, Status: "ready"}) |
| 231 | }, |
| 232 | OnClose: func(h string) { |
| 233 | if err := reg.delete(h); err != nil { |
| 234 | fmt.Fprintf(os.Stderr, "tunnel serve: remove route %s: %v\n", h, err) |
| 235 | } |
| 236 | }, |
| 237 | } |
| 238 | |
| 239 | if *relayDomain != "" { |
| 240 | claims, err := tunnel.OpenClaims(*claimsPath) |
| 241 | if err != nil { |
| 242 | die("tunnel serve: open claims: %v", err) |
| 243 | } |
| 244 | gw.RelayDomain = *relayDomain |
| 245 | gw.ClaimsDB = claims |
| 246 | gw.ClaimIdle = time.Duration(*claimIdleDays) * 24 * time.Hour |
| 247 | gw.StartClaimJanitor() |
| 248 | } |
| 249 | |
| 250 | _ = os.Remove(*socket) |
| 251 | ln, err := net.Listen("unix", *socket) |
| 252 | if err != nil { |
| 253 | die("tunnel serve: %v", err) |
| 254 | } |
| 255 | _ = os.Chmod(*socket, 0o660) |
| 256 | fmt.Printf("tunnel gateway: %s (control %s, minting try-*.%s)\n", *socket, *host, *domain) |
| 257 | srv := &http.Server{Handler: gw, ReadHeaderTimeout: 10 * time.Second} |
| 258 | if err := srv.Serve(ln); err != nil { |
| 259 | die("tunnel serve: %v", err) |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // runTunnelKill is the operator kill-switch for one tunnel host: removing its |
| 264 | // registry row makes the router stop serving it within a couple of seconds. |
| 265 | // (Restarting lchat-tunnel additionally drops every live tunnel connection.) |
| 266 | // --forget additionally deletes a relay's claim, so the hostname returns to |
| 267 | // the pool and the keypair that held it starts fresh next time it connects. |
| 268 | func runTunnelKill(args []string) { |
| 269 | fs := flag.NewFlagSet("tunnel kill", flag.ExitOnError) |
| 270 | registry := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry") |
| 271 | claimsPath := fs.String("claims", envOr("LCHAT_TUNNEL_CLAIMS", "/data/tunnel-claims.db"), "relay claim store") |
| 272 | forget := fs.Bool("forget", false, "also delete the relay claim (frees the hostname permanently)") |
| 273 | _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| 274 | host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) |
| 275 | if !strings.HasPrefix(host, "try-") && !strings.HasPrefix(host, "r-") { |
| 276 | die("tunnel kill: %q isn't a tunnel host (try-… or r-…)", host) |
| 277 | } |
| 278 | reg, err := openRegistry(*registry) |
| 279 | if err != nil { |
| 280 | die("tunnel kill: %v", err) |
| 281 | } |
| 282 | if err := reg.delete(host); err != nil { |
| 283 | die("tunnel kill: %v", err) |
| 284 | } |
| 285 | fmt.Printf("removed %s — the router drops it within a couple of seconds.\n", host) |
| 286 | if *forget { |
| 287 | claims, err := tunnel.OpenClaims(*claimsPath) |
| 288 | if err != nil { |
| 289 | die("tunnel kill: open claims: %v", err) |
| 290 | } |
| 291 | if err := claims.DeleteByHost(host); err != nil { |
| 292 | die("tunnel kill: forget claim: %v", err) |
| 293 | } |
| 294 | fmt.Printf("forgot the claim on %s — the hostname is back in the pool.\n", host) |
| 295 | } |
| 296 | fmt.Println("(to also cut the live tunnel connection: systemctl restart lchat-tunnel)") |
| 297 | } |
| 298 | |
| 299 | // --- small helpers ----------------------------------------------------- |
| 300 | |
| 301 | func mustExecutable() string { |
| 302 | exe, err := os.Executable() |
| 303 | if err != nil { |
| 304 | die("try: %v", err) |
| 305 | } |
| 306 | return exe |
| 307 | } |
| 308 | |
| 309 | func readFileOr(path, def string) string { |
| 310 | b, err := os.ReadFile(path) |
| 311 | if err != nil { |
| 312 | return def |
| 313 | } |
| 314 | return string(b) |
| 315 | } |
| 316 | |
| 317 | func fileExists(path string) bool { |
| 318 | _, err := os.Stat(path) |
| 319 | return err == nil |
| 320 | } |
| 321 | |
| 322 | func randSlugToken() (string, error) { |
| 323 | // 32 hex chars, matching the admin tokens add-instance mints. |
| 324 | var b [16]byte |
| 325 | if _, err := rand.Read(b[:]); err != nil { |
| 326 | return "", err |
| 327 | } |
| 328 | return fmt.Sprintf("%x", b), nil |
| 329 | } |
| 330 | |
| 331 | // deleteTunnelRoutes removes every try- service route pointing at socket — |
| 332 | // the gateway's own rows from a previous life. Scoped tightly so a registry |
| 333 | // with a real host that merely starts with "try-" but lives elsewhere is |
| 334 | // never touched. |
| 335 | func (r *Registry) deleteTunnelRoutes(socket string) error { |
| 336 | _, err := r.sql.Exec( |
| 337 | `DELETE FROM routes WHERE kind = 'service' AND socket = ? |
| 338 | AND (host LIKE 'try-%' OR host LIKE 'r-%')`, socket) |
| 339 | return err |
| 340 | } |
| 341 | |