package main // `lchat try` — try Eleven in one command, from any machine, with nothing to // configure: it starts a full local instance (a subprocess of this same // binary, so the ordinary single-instance path is exercised unchanged) and // opens an outbound reverse tunnel to the public gateway, which hands back a // real https://try-. address under the fleet's wildcard cert. // No inbound ports, no DNS, no certificates, no root. // // `lchat tunnel serve` is the other half: the gateway service that runs on // the router box behind the ordinary router, publishing each live tunnel as a // registry route. See internal/tunnel and docs/tunnel-try.md. import ( "context" "crypto/rand" "flag" "fmt" "net" "net/http" "os" "os/exec" "os/signal" "strings" "syscall" "time" "lchat/internal/tunnel" ) const defaultTunnelGateway = "https://tunnel.11ch.at" func runTryCmd(args []string) { fs := flag.NewFlagSet("try", flag.ExitOnError) gateway := fs.String("gateway", envOr("LCHAT_TUNNEL_GATEWAY", defaultTunnelGateway), "tunnel gateway origin") dbPath := fs.String("db", "lchat-try.db", "where the trial space's data lives (kept across runs)") local := fs.String("local", "", "tunnel an ALREADY-RUNNING local instance at this addr (e.g. 127.0.0.1:1747) instead of starting one") _ = fs.Parse(reorderFlagsFirst(fs, args)) if *local != "" { runTryLocal(*gateway, *local) return } // A local port for the instance subprocess. l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { die("try: %v", err) } localAddr := l.Addr().String() l.Close() // The subprocess is this same binary running the plain single-instance // path. A fresh admin token per run authorises the setup-link mint below; // on an existing db the stored token stays (ensureAdminToken ignores the // env when one exists), so re-running `try` on kept data still works — // we then mint via the token recorded on first run. tokenFile := *dbPath + ".admin-token" adminToken := strings.TrimSpace(readFileOr(tokenFile, "")) fresh := !fileExists(*dbPath) if adminToken == "" { if adminToken, err = randSlugToken(); err != nil { die("try: %v", err) } } fmt.Println(" Starting your trial space…") child := exec.Command(mustExecutable(), "-addr", localAddr, "-db", *dbPath) child.Env = append(os.Environ(), "LCHAT_ADMIN_TOKEN="+adminToken) logPath := *dbPath + ".log" logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { die("try: %v", err) } child.Stdout, child.Stderr = logFile, logFile if err := child.Start(); err != nil { die("try: 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("try: the local instance didn't come up (%v) — see %s", err, logPath) } if fresh { if err := os.WriteFile(tokenFile, []byte(adminToken+"\n"), 0o600); err != nil { die("try: %v", err) } } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() cl := &tunnel.Client{Gateway: *gateway, LocalAddr: localAddr, Version: buildVersion} errCh := make(chan error, 1) hostCh := make(chan string, 1) go func() { errCh <- cl.Run(ctx, func(h string) { hostCh <- h }) }() var host string select { case host = <-hostCh: case err := <-errCh: die("try: %v", err) case <-time.After(45 * time.Second): die("try: the tunnel gateway didn't answer") } origin := "https://" + host fmt.Println(" Address assigned, waiting for it to go live…") if err := tunnel.WaitReachable(origin+"/api/version", 30*time.Second); err != nil { die("try: %s %v", origin, err) } setupLink, err := mintSetupInvite(http.DefaultClient, "http://"+localAddr, origin, adminToken) if err != nil { die("try: mint setup link: %v", err) } fmt.Printf(` ✔ Your space is live: %s ✔ Claim it here first: %s That second link makes you the admin — open it, pick a name, and invite people straight away. Anyone you invite can join from any browser while this command is running. This is a tryout: the address works while this command runs and retires when you stop it (Ctrl-C) or after 24 hours. Your space's data stays here in %s — nothing leaves this machine except the same encrypted traffic a hosted space would send. Liked it? A permanent home is one small install: https://elevenmessenger.com/self-host/ `, origin, setupLink, *dbPath) select { case err := <-errCh: if err != nil { die("try: %v", err) } case <-ctx.Done(): fmt.Println(" Stopping — thanks for trying Eleven.") } } // runTryLocal is `lchat try --local `: lend a public trial address to an // instance you're already running (a dev server, a home-lab box) — no // subprocess, no setup link; the instance's own links keep working because it // sees the public hostname in every proxied request's Host header. func runTryLocal(gateway, localAddr string) { if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 5*time.Second); err != nil { die("try --local: nothing answering at %s (%v)", localAddr, err) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() cl := &tunnel.Client{Gateway: gateway, LocalAddr: localAddr, Version: buildVersion} errCh := make(chan error, 1) hostCh := make(chan string, 1) go func() { errCh <- cl.Run(ctx, func(h string) { hostCh <- h }) }() var host string select { case host = <-hostCh: case err := <-errCh: die("try --local: %v", err) case <-time.After(45 * time.Second): die("try --local: the tunnel gateway didn't answer") } origin := "https://" + host if err := tunnel.WaitReachable(origin+"/api/version", 30*time.Second); err != nil { die("try --local: %s %v", origin, err) } fmt.Printf("\n ✔ %s → %s\n The address retires when you stop this (Ctrl-C) or after 24 hours.\n\n", localAddr, origin) select { case err := <-errCh: if err != nil { die("try --local: %v", err) } case <-ctx.Done(): fmt.Println(" Stopped.") } } func runTunnelCmd(args []string) { if len(args) >= 1 && args[0] == "kill" { runTunnelKill(args[1:]) return } if len(args) < 1 || args[0] != "serve" { die("usage: lchat tunnel serve [--socket P] [--registry P] [--host H] [--domain D]\n lchat tunnel kill [--registry P] [--forget]") } fs := flag.NewFlagSet("tunnel serve", flag.ExitOnError) socket := fs.String("socket", envOr("LCHAT_TUNNEL_SOCKET", "/run/lchat/tunnel.sock"), "unix socket to serve on (the router fronts it)") registry := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry to publish tunnel routes in") host := fs.String("host", envOr("LCHAT_TUNNEL_HOST", "tunnel.11ch.at"), "the gateway's own control hostname") domain := fs.String("domain", envOr("LCHAT_TUNNEL_DOMAIN", "11ch.at"), "domain trial hosts are minted under (needs the wildcard cert)") maxTunnels := fs.Int("max", 50, "maximum concurrent trial tunnels") relayDomain := fs.String("relay-domain", envOr("LCHAT_TUNNEL_RELAY_DOMAIN", ""), "offer stable relay hostnames under this domain (empty = relays off; docs/relay-mode.md)") claimsPath := fs.String("claims", envOr("LCHAT_TUNNEL_CLAIMS", "/data/tunnel-claims.db"), "relay claim store (with --relay-domain)") maxRelays := fs.Int("max-relays", 200, "maximum claimed relay hostnames") claimIdleDays := fs.Int("claim-idle-days", 45, "free a relay hostname whose owner hasn't connected for this many days") _ = fs.Parse(reorderFlagsFirst(fs, args)) reg, err := openRegistry(*registry) if err != nil { die("tunnel serve: open registry: %v", err) } // Any try- routes pointing at our socket are leftovers from a previous // gateway process — their tunnels died with it. if err := reg.deleteTunnelRoutes(*socket); err != nil { die("tunnel serve: clean stale routes: %v", err) } gw := &tunnel.Gateway{ ControlHost: *host, Domain: *domain, MaxTunnels: *maxTunnels, MaxRelays: *maxRelays, OnRegister: func(h string) error { if existing, err := reg.get(h); err != nil { return err } else if existing != nil && existing.Socket != *socket { // Never steal a hostname something else answers for. Our own // rows are fine to re-assert: a relay reattaching within its // grace window re-registers a route that never went away. return fmt.Errorf("host %s already routed elsewhere", h) } // The router's registry watcher (router.go) notices the write and // reloads within a couple of seconds — no signal needed from here. return reg.upsert(Route{Host: h, Kind: "service", Socket: *socket, Status: "ready"}) }, OnClose: func(h string) { if err := reg.delete(h); err != nil { fmt.Fprintf(os.Stderr, "tunnel serve: remove route %s: %v\n", h, err) } }, } if *relayDomain != "" { claims, err := tunnel.OpenClaims(*claimsPath) if err != nil { die("tunnel serve: open claims: %v", err) } gw.RelayDomain = *relayDomain gw.ClaimsDB = claims gw.ClaimIdle = time.Duration(*claimIdleDays) * 24 * time.Hour gw.StartClaimJanitor() } _ = os.Remove(*socket) ln, err := net.Listen("unix", *socket) if err != nil { die("tunnel serve: %v", err) } _ = os.Chmod(*socket, 0o660) fmt.Printf("tunnel gateway: %s (control %s, minting try-*.%s)\n", *socket, *host, *domain) srv := &http.Server{Handler: gw, ReadHeaderTimeout: 10 * time.Second} if err := srv.Serve(ln); err != nil { die("tunnel serve: %v", err) } } // runTunnelKill is the operator kill-switch for one tunnel host: removing its // registry row makes the router stop serving it within a couple of seconds. // (Restarting lchat-tunnel additionally drops every live tunnel connection.) // --forget additionally deletes a relay's claim, so the hostname returns to // the pool and the keypair that held it starts fresh next time it connects. func runTunnelKill(args []string) { fs := flag.NewFlagSet("tunnel kill", flag.ExitOnError) registry := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry") claimsPath := fs.String("claims", envOr("LCHAT_TUNNEL_CLAIMS", "/data/tunnel-claims.db"), "relay claim store") forget := fs.Bool("forget", false, "also delete the relay claim (frees the hostname permanently)") _ = fs.Parse(reorderFlagsFirst(fs, args)) host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) if !strings.HasPrefix(host, "try-") && !strings.HasPrefix(host, "r-") { die("tunnel kill: %q isn't a tunnel host (try-… or r-…)", host) } reg, err := openRegistry(*registry) if err != nil { die("tunnel kill: %v", err) } if err := reg.delete(host); err != nil { die("tunnel kill: %v", err) } fmt.Printf("removed %s — the router drops it within a couple of seconds.\n", host) if *forget { claims, err := tunnel.OpenClaims(*claimsPath) if err != nil { die("tunnel kill: open claims: %v", err) } if err := claims.DeleteByHost(host); err != nil { die("tunnel kill: forget claim: %v", err) } fmt.Printf("forgot the claim on %s — the hostname is back in the pool.\n", host) } fmt.Println("(to also cut the live tunnel connection: systemctl restart lchat-tunnel)") } // --- small helpers ----------------------------------------------------- func mustExecutable() string { exe, err := os.Executable() if err != nil { die("try: %v", err) } return exe } func readFileOr(path, def string) string { b, err := os.ReadFile(path) if err != nil { return def } return string(b) } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } func randSlugToken() (string, error) { // 32 hex chars, matching the admin tokens add-instance mints. var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return fmt.Sprintf("%x", b), nil } // deleteTunnelRoutes removes every try- service route pointing at socket — // the gateway's own rows from a previous life. Scoped tightly so a registry // with a real host that merely starts with "try-" but lives elsewhere is // never touched. func (r *Registry) deleteTunnelRoutes(socket string) error { _, err := r.sql.Exec( `DELETE FROM routes WHERE kind = 'service' AND socket = ? AND (host LIKE 'try-%' OR host LIKE 'r-%')`, socket) return err }