lchat try: one-command public trial spaces over an outbound tunnel (#736)
* server: lchat try — one-command public trial via an outbound reverse tunnel lchat try starts a full local instance (subprocess of this binary, the plain single-instance path) and opens an outbound tunnel to the new gateway (lchat tunnel serve, lchat-tunnel.service), which mints https://try-<slug>.11ch.at under the fleet's existing wildcard cert and publishes it as an ordinary registry service route. One control WebSocket per tunnel, one dialled-back data WebSocket per backend connection (websocket.NetConn byte pipes), so HTTP and the chat's own WebSockets pass through unmodified. The router gains a registry watcher (PRAGMA data_version poll, 2s) so an unprivileged writer can add routes without signal rights; SIGHUP reload stays. Guardrails on the gateway: 5 tunnels/IP/hour, 50 concurrent, 24h lifetime, stale-route sweep on restart. Docs: docs/tunnel-try.md. Tests: internal/tunnel end-to-end (GET + WebSocket echo through the full chain) and the rate limiter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * try: --local — lend a trial address to an already-running instance Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * tunnel: harden the trial gateway — door probe, connection cap, kill switch The gateway now proves the tunnelled backend is an Eleven instance (GET /api/version through the tunnel) before publishing its hostname, and re-checks every 10 minutes — the trial domain never lends its name or TLS to arbitrary content. Data connections are capped at 32 per tunnel so one hostile visitor can't claim a trial machine's uplink, and lchat tunnel kill <host> is the operator kill switch. Client reads handle dial-before-ready (the probe's dial-backs precede the ready). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
11 files changed,
+1334
−0
bootstrap.go+8 −0changelog/2026-07-11-try-eleven-in-one-command.md+7 −0deploy/systemd/lchat-tunnel.service+44 −0docs/tunnel-try.md+88 −0internal/tunnel/client.go+137 −0internal/tunnel/gateway.go+471 −0internal/tunnel/tunnel.go+43 −0internal/tunnel/tunnel_test.go+196 −0main.go+6 −0router.go+28 −0trycmd.go+306 −0
diff --git a/bootstrap.go b/bootstrap.go| index c0b395c..9540dbb 100644 |
| --- a/bootstrap.go |
| +++ b/bootstrap.go |
| @@ -44,6 +44,13 @@ var unitControl string |
| //go:embed deploy/systemd/lchat-cloud.service |
| var unitCloud string |
| +// The trial-tunnel gateway (`lchat try` — see docs/tunnel-try.md). Installs |
| +// everywhere, stays inert until enabled; it only matters on a box that fronts |
| +// a wildcard domain. |
| +// |
| +//go:embed deploy/systemd/lchat-tunnel.service |
| +var unitTunnel string |
| + |
| // The auto-updater (updater.go): a root oneshot fired hourly by its timer. |
| // Enabled by bootstrap — a pass with no reachable release feed is a no-op, so |
| // it's safe everywhere, and a box provisioned tomorrow auto-updates without a |
| @@ -153,6 +160,7 @@ func runBootstrap(args []string) { |
| write(filepath.Join(*unitDir, "lchat-home.service"), unitHome) |
| write(filepath.Join(*unitDir, "lchat-control.service"), unitControl) |
| write(filepath.Join(*unitDir, "lchat-cloud.service"), unitCloud) |
| + write(filepath.Join(*unitDir, "lchat-tunnel.service"), unitTunnel) |
| write(filepath.Join(*unitDir, "lchat-update.service"), unitUpdate) |
| write(filepath.Join(*unitDir, "lchat-update.timer"), unitUpdateTimer) |
| write("/etc/tmpfiles.d/lchat.conf", tmpfilesConf) |
diff --git a/changelog/2026-07-11-try-eleven-in-one-command.md b/changelog/2026-07-11-try-eleven-in-one-command.md| new file mode 100644 |
| index 0000000..08ff0b9 |
| --- /dev/null |
| +++ b/changelog/2026-07-11-try-eleven-in-one-command.md |
| @@ -0,0 +1,7 @@ |
| +🤖 **Try Eleven in one command.** Curious what running your own space feels |
| +like? Download the server binary and run `lchat try`: your machine gets a real |
| +public address (like `try-x7k2m.11ch.at`) serving a full, end-to-end-encrypted |
| +space — no ports to open, no domain to buy, no certificates, no root, nothing |
| +to configure. Invite people, chat from any browser or the apps, and when you |
| +stop the command the trial address politely retires (your data stays on your |
| +machine). Liked it? A permanent home is one small install away. |
diff --git a/deploy/systemd/lchat-tunnel.service b/deploy/systemd/lchat-tunnel.service| new file mode 100644 |
| index 0000000..72d06d1 |
| --- /dev/null |
| +++ b/deploy/systemd/lchat-tunnel.service |
| @@ -0,0 +1,44 @@ |
| +[Unit] |
| +Description=lchat trial-tunnel gateway (lchat try) |
| +After=network-online.target |
| +Wants=network-online.target |
| + |
| +[Service] |
| +# Optional overrides (LCHAT_TUNNEL_HOST/_DOMAIN/_SOCKET); absent file is fine — |
| +# the defaults (tunnel.11ch.at / 11ch.at / /run/lchat/tunnel.sock) are the fleet's. |
| +EnvironmentFile=-/data/tunnel.env |
| +ExecStart=/opt/lchat/lchat tunnel serve |
| +Restart=always |
| +RestartSec=1 |
| + |
| +# --- hardening (see docs/instance-router.md § Hardening) --- |
| +User=lchat |
| +Group=lchat |
| +CapabilityBoundingSet= |
| +NoNewPrivileges=yes |
| +ProtectSystem=strict |
| +ProtectHome=yes |
| +ReadWritePaths=/data /run/lchat |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +ProtectControlGroups=yes |
| +ProtectProc=invisible |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +RestrictNamespaces=yes |
| +RestrictRealtime=yes |
| +RestrictSUIDSGID=yes |
| +LockPersonality=yes |
| +MemoryDenyWriteExecute=yes |
| +SystemCallFilter=@system-service |
| +SystemCallErrorNumber=EPERM |
| +SystemCallArchitectures=native |
| +MemoryMax=256M |
| +TasksMax=256 |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/docs/tunnel-try.md b/docs/tunnel-try.md| new file mode 100644 |
| index 0000000..d09c37f |
| --- /dev/null |
| +++ b/docs/tunnel-try.md |
| @@ -0,0 +1,88 @@ |
| +# 🤖 `lchat try` — the one-command trial tunnel |
| + |
| +`lchat try` is the zero-setup way to experience a real, self-run Eleven |
| +space: download the binary, run one command, get a public |
| +`https://try-<slug>.11ch.at` address serving a full instance from your own |
| +machine — through NAT, with no inbound ports, no DNS record, no certificate, |
| +and no root. Stop the command and the address retires; the space's data stays |
| +in `./lchat-try.db`. |
| + |
| +```sh |
| +curl -LO https://github.com/elevenmessenger/releases/releases/download/server/lchat-linux-amd64 |
| +chmod +x lchat-linux-amd64 |
| +./lchat-linux-amd64 try |
| +``` |
| + |
| +It prints two links: the space's address, and a one-time setup link that makes |
| +you its admin (the same conversational setup a hosted space gets). |
| + |
| +## How it works |
| + |
| +Two halves, both in the single binary (`internal/tunnel/`): |
| + |
| +- **The client** (`lchat try`) starts a normal local instance (a subprocess |
| + running the ordinary single-instance path against `lchat-try.db`), then |
| + dials out to the gateway over one **control WebSocket** |
| + (`wss://tunnel.11ch.at/api/tunnel/register`). The gateway assigns |
| + `try-<slug>.11ch.at` and sends `{"type":"ready","host":…}`. For every |
| + backend connection the public side needs, the gateway sends |
| + `{"type":"dial","id":…}` and the client dials back one **data WebSocket** |
| + (`/api/tunnel/conn/<id>`), which both ends treat as a plain byte pipe |
| + (`websocket.NetConn`) spliced to the local instance's TCP port. Ordinary |
| + HTTP/1.1 flows through it — including WebSocket upgrades, which is what |
| + makes the chat itself work through the tunnel unmodified. |
| +- **The gateway** (`lchat tunnel serve`, unit `lchat-tunnel.service`) listens |
| + on `/run/lchat/tunnel.sock` behind the ordinary router. Each live tunnel is |
| + published as a plain registry route (`kind=service`, `host=try-…`, |
| + `socket=/run/lchat/tunnel.sock`), so the router needs no tunnel awareness at |
| + all: TLS comes from the existing `*.11ch.at` wildcard cert, and routing is |
| + the same host→socket lookup every instance uses. The router notices new |
| + routes via its registry watcher (a `PRAGMA data_version` poll every 2s), so |
| + the unprivileged gateway never needs rights to signal anything. |
| + |
| +The E2EE story is unchanged: what crosses the tunnel is the same |
| +already-ciphertext traffic the router fronts for every instance. The gateway |
| +can see exactly what the router can see — nothing readable. |
| + |
| +## Why single-label slugs |
| + |
| +`try-x7k2m.11ch.at`, not `x7k2m.try.11ch.at`: TLS wildcards match one label, |
| +and the fleet's wildcard cert is `*.11ch.at`. A nested scheme would need its |
| +own wildcard cert and DNS; the prefix gets the same effect for free. |
| + |
| +## Operations |
| + |
| +- **Enable on a box:** `systemctl enable --now lchat-tunnel`, then route its |
| + control host once: `lchat add-service tunnel.11ch.at --socket |
| + /run/lchat/tunnel.sock`. (Bootstrap installs the unit everywhere; it's inert |
| + until enabled.) |
| +- **Config** (`/data/tunnel.env`, optional): `LCHAT_TUNNEL_HOST`, |
| + `LCHAT_TUNNEL_DOMAIN`, `LCHAT_TUNNEL_SOCKET`, plus `--max` (default 50 |
| + concurrent tunnels). |
| +- **Guardrails:** 5 new tunnels per IP per hour, 50 concurrent tunnels, 32 |
| + concurrent connections per tunnel, 24-hour lifetime, dial-back timeout 15s. |
| + And the door check: before a hostname goes public — and every 10 minutes |
| + after — the gateway probes the tunnelled backend through the tunnel |
| + (`GET /api/version`) and refuses anything that isn't an Eleven instance, |
| + so the trial domain never lends its name (or its TLS) to arbitrary |
| + content. This is a trial surface, not free hosting — the limits say so |
| + politely. |
| +- **Kill switch:** `lchat tunnel kill try-<slug>.11ch.at` removes the route |
| + (the router stops serving it within seconds); `systemctl restart |
| + lchat-tunnel` additionally drops every live tunnel connection. |
| +- **Cleanup is automatic:** a tunnel's route is removed when its client |
| + disconnects; on gateway restart, leftover `try-%` rows pointing at its |
| + socket are swept. `lchat instances` will show live trials as `service` |
| + rows — transient by design. |
| +- **Self-hosters** can run their own gateway anywhere the router fronts a |
| + wildcard domain; `lchat try --gateway https://tunnel.your.domain` points the |
| + client at it. |
| + |
| +## Honest limits |
| + |
| +- A trial's address dies with the process (or at 24h). `lchat try` again |
| + mints a fresh address; the data in `lchat-try.db` persists, but members' |
| + saved links won't follow it. The printed copy says so — a keeper space |
| + should graduate to a real install (`docs/self-hosting.md`). |
| +- The gateway is a relay for one machine's trial, not a CDN: throughput is |
| + whatever the trial machine's uplink gives it. |
diff --git a/internal/tunnel/client.go b/internal/tunnel/client.go| new file mode 100644 |
| index 0000000..59b9741 |
| --- /dev/null |
| +++ b/internal/tunnel/client.go |
| @@ -0,0 +1,137 @@ |
| +package tunnel |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "github.com/coder/websocket" |
| +) |
| + |
| +// Client keeps one tunnel open: a control connection to the gateway, and one |
| +// dialled-back data connection per backend connection the public side needs, |
| +// each bridged to the local instance with two plain io.Copy loops. |
| +type Client struct { |
| + Gateway string // gateway origin, e.g. "https://tunnel.11ch.at" (or http:// in tests) |
| + LocalAddr string // where the local instance listens, e.g. "127.0.0.1:52801" |
| + Version string // informative, sent in the hello |
| +} |
| + |
| +// Run registers, reports the assigned public host on ready, then serves dials |
| +// until the control connection dies or ctx is cancelled. The error return is |
| +// why the tunnel ended (nil on clean ctx cancellation). |
| +func (cl *Client) Run(ctx context.Context, ready func(host string)) error { |
| + dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) |
| + c, _, err := websocket.Dial(dialCtx, cl.wsURL("/api/tunnel/register"), nil) |
| + cancel() |
| + if err != nil { |
| + return fmt.Errorf("reach the tunnel gateway: %w", err) |
| + } |
| + defer c.CloseNow() |
| + c.SetReadLimit(1 << 16) |
| + |
| + if err := writeJSON(ctx, c, ControlMsg{Type: "hello", Version: cl.Version}); err != nil { |
| + return err |
| + } |
| + |
| + // Keepalive: a ping every 30s keeps NATs and the router's idle timers |
| + // friendly, and notices a dead gateway. |
| + go func() { |
| + t := time.NewTicker(30 * time.Second) |
| + defer t.Stop() |
| + for { |
| + select { |
| + case <-t.C: |
| + pctx, pcancel := context.WithTimeout(ctx, 10*time.Second) |
| + _ = c.Ping(pctx) |
| + pcancel() |
| + case <-ctx.Done(): |
| + return |
| + } |
| + } |
| + }() |
| + |
| + // One loop for the whole conversation. "dial" can arrive BEFORE "ready" — |
| + // the gateway probes the backend through the tunnel before it publishes |
| + // the hostname — so dials are served from the first message on. |
| + gotReady := false |
| + for { |
| + var m ControlMsg |
| + if err := readJSON(ctx, c, &m); err != nil { |
| + if ctx.Err() != nil { |
| + return nil |
| + } |
| + return fmt.Errorf("tunnel closed: %w", err) |
| + } |
| + switch m.Type { |
| + case "dial": |
| + go cl.serveDial(ctx, m.ID) |
| + case "ready": |
| + if !gotReady { |
| + gotReady = true |
| + ready(m.Host) |
| + } |
| + case "error": |
| + return fmt.Errorf("%s", m.Error) |
| + } |
| + } |
| +} |
| + |
| +// serveDial opens the data connection for one id and splices it to the local |
| +// instance. |
| +func (cl *Client) serveDial(ctx context.Context, id string) { |
| + dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second) |
| + c, _, err := websocket.Dial(dialCtx, cl.wsURL("/api/tunnel/conn/"+id), nil) |
| + cancel() |
| + if err != nil { |
| + return |
| + } |
| + c.SetReadLimit(-1) |
| + remote := websocket.NetConn(context.Background(), c, websocket.MessageBinary) |
| + local, err := net.DialTimeout("tcp", cl.LocalAddr, 10*time.Second) |
| + if err != nil { |
| + remote.Close() |
| + return |
| + } |
| + go splice(local, remote) |
| + splice(remote, local) |
| +} |
| + |
| +func splice(dst io.WriteCloser, src io.Reader) { |
| + _, _ = io.Copy(dst, src) |
| + dst.Close() |
| +} |
| + |
| +func (cl *Client) wsURL(path string) string { |
| + origin := strings.TrimRight(cl.Gateway, "/") |
| + origin = strings.Replace(origin, "https://", "wss://", 1) |
| + origin = strings.Replace(origin, "http://", "ws://", 1) |
| + return origin + path |
| +} |
| + |
| +// WaitReachable polls url until it answers or the timeout passes — used by |
| +// `lchat try` so the printed link is live before anyone can click it (the |
| +// router notices new routes within a couple of seconds). |
| +func WaitReachable(url string, timeout time.Duration) error { |
| + deadline := time.Now().Add(timeout) |
| + client := &http.Client{Timeout: 5 * time.Second} |
| + var last error |
| + for time.Now().Before(deadline) { |
| + resp, err := client.Get(url) |
| + if err == nil { |
| + resp.Body.Close() |
| + if resp.StatusCode < 500 { |
| + return nil |
| + } |
| + last = fmt.Errorf("HTTP %d", resp.StatusCode) |
| + } else { |
| + last = err |
| + } |
| + time.Sleep(700 * time.Millisecond) |
| + } |
| + return fmt.Errorf("not reachable in %s: %v", timeout, last) |
| +} |
diff --git a/internal/tunnel/gateway.go b/internal/tunnel/gateway.go| new file mode 100644 |
| index 0000000..60324ec |
| --- /dev/null |
| +++ b/internal/tunnel/gateway.go |
| @@ -0,0 +1,471 @@ |
| +package tunnel |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| + "fmt" |
| + "log" |
| + "math/big" |
| + "net" |
| + "net/http" |
| + "net/http/httputil" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "time" |
| + |
| + "github.com/coder/websocket" |
| +) |
| + |
| +// Gateway terminates the public side of every tunnel. It serves two kinds of |
| +// host: its own control host (register/conn endpoints) and the try-* slug |
| +// hosts, which it reverse-proxies down the matching tunnel. Route |
| +// registration is delegated through OnRegister/OnClose so this package never |
| +// touches the router's registry directly. |
| +type Gateway struct { |
| + ControlHost string // e.g. "tunnel.11ch.at" — where clients register |
| + Domain string // e.g. "11ch.at" — slugs become try-<slug>.<domain> |
| + |
| + // OnRegister makes the public hostname routable (a registry row pointing |
| + // back at this gateway's listener); OnClose removes it. Both must be safe |
| + // to call from multiple goroutines. |
| + OnRegister func(host string) error |
| + OnClose func(host string) |
| + |
| + MaxTunnels int // concurrent tunnels (0 = default 50) |
| + MaxConns int // concurrent data connections per tunnel (0 = default 32) |
| + MaxPerIP int // new tunnels per IP per hour (0 = default 5) |
| + MaxAge time.Duration // tunnel lifetime (0 = default 24h) |
| + DialTimeout time.Duration // client dial-back wait (0 = default 15s) |
| + |
| + mu sync.Mutex |
| + tunnels map[string]*liveTunnel // by public host |
| + pending map[string]chan net.Conn |
| + byIP map[string][]time.Time // register timestamps, pruned hourly |
| +} |
| + |
| +type liveTunnel struct { |
| + host string |
| + control *websocket.Conn |
| + proxy *httputil.ReverseProxy |
| + created time.Time |
| + closed chan struct{} |
| + once sync.Once |
| + // conns counts live + in-flight data connections, bounding how much of the |
| + // trial machine's uplink one hostile visitor can claim. |
| + conns atomic.Int32 |
| +} |
| + |
| +func (g *Gateway) maxTunnels() int { return orInt(g.MaxTunnels, 50) } |
| +func (g *Gateway) maxConns() int { return orInt(g.MaxConns, 32) } |
| +func (g *Gateway) maxPerIP() int { return orInt(g.MaxPerIP, 5) } |
| +func (g *Gateway) maxAge() time.Duration { |
| + if g.MaxAge > 0 { |
| + return g.MaxAge |
| + } |
| + return 24 * time.Hour |
| +} |
| +func (g *Gateway) dialTimeout() time.Duration { |
| + if g.DialTimeout > 0 { |
| + return g.DialTimeout |
| + } |
| + return 15 * time.Second |
| +} |
| +func orInt(v, def int) int { |
| + if v > 0 { |
| + return v |
| + } |
| + return def |
| +} |
| + |
| +func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + host := r.Host |
| + if h, _, err := net.SplitHostPort(host); err == nil { |
| + host = h |
| + } |
| + if host == g.ControlHost { |
| + switch { |
| + case r.URL.Path == "/api/tunnel/register": |
| + g.handleRegister(w, r) |
| + case strings.HasPrefix(r.URL.Path, "/api/tunnel/conn/"): |
| + g.handleConn(w, r) |
| + case r.URL.Path == "/healthz": |
| + fmt.Fprintln(w, "ok") |
| + default: |
| + // A human wandered in; point them somewhere useful. |
| + http.Redirect(w, r, "https://elevenmessenger.com/self-host/", http.StatusFound) |
| + } |
| + return |
| + } |
| + g.mu.Lock() |
| + t := g.tunnels[host] |
| + g.mu.Unlock() |
| + if t == nil { |
| + http.Error(w, "this trial space isn't running — its lchat try command has stopped", http.StatusBadGateway) |
| + return |
| + } |
| + t.proxy.ServeHTTP(w, r) |
| +} |
| + |
| +// handleRegister accepts a client's control connection, assigns it a slug, |
| +// makes the host routable, and then sits in a read loop until the client goes |
| +// away (the read also services the client's keepalive pings). |
| +func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) { |
| + ip := clientIP(r) |
| + if err := g.admit(ip); err != nil { |
| + http.Error(w, err.Error(), http.StatusTooManyRequests) |
| + return |
| + } |
| + c, err := websocket.Accept(w, r, nil) |
| + if err != nil { |
| + return |
| + } |
| + defer c.CloseNow() |
| + c.SetReadLimit(1 << 16) |
| + |
| + ctx := r.Context() |
| + var hello ControlMsg |
| + if err := readJSON(ctx, c, &hello); err != nil || hello.Type != "hello" { |
| + writeJSON(ctx, c, ControlMsg{Type: "error", Error: "expected hello"}) |
| + return |
| + } |
| + |
| + host, err := g.newHost() |
| + if err != nil { |
| + writeJSON(ctx, c, ControlMsg{Type: "error", Error: err.Error()}) |
| + return |
| + } |
| + t := &liveTunnel{host: host, control: c, created: time.Now(), closed: make(chan struct{})} |
| + t.proxy = g.proxyFor(t) |
| + |
| + g.mu.Lock() |
| + g.tunnels[host] = t |
| + g.byIP[ip] = append(g.byIP[ip], time.Now()) |
| + g.mu.Unlock() |
| + |
| + // The hostname goes public only after the backend proves it's an Eleven |
| + // instance (the client serves dial-backs from the moment it says hello). |
| + if err := g.probeBackend(ctx, t); err != nil { |
| + msg := "only Eleven instances can be tunnelled: " + err.Error() |
| + writeJSON(ctx, c, ControlMsg{Type: "error", Error: msg}) |
| + // Graceful close so the error frame is delivered before the socket |
| + // dies (CloseNow would race it and the client would just see EOF). |
| + c.Close(websocket.StatusPolicyViolation, "refused") |
| + g.drop(t) |
| + log.Printf("tunnel: %s refused (probe): %v", host, err) |
| + return |
| + } |
| + |
| + if g.OnRegister != nil { |
| + if err := g.OnRegister(host); err != nil { |
| + g.drop(t) |
| + writeJSON(ctx, c, ControlMsg{Type: "error", Error: "couldn't publish the address, try again"}) |
| + log.Printf("tunnel: register %s: %v", host, err) |
| + return |
| + } |
| + } |
| + log.Printf("tunnel: %s open (from %s)", host, ip) |
| + if err := writeJSON(ctx, c, ControlMsg{Type: "ready", Host: host}); err != nil { |
| + g.drop(t) |
| + return |
| + } |
| + |
| + // Hold the connection until the client leaves or the tunnel ages out. The |
| + // read loop keeps ping/pong serviced; clients never send data messages. |
| + ageOut := time.NewTimer(g.maxAge()) |
| + defer ageOut.Stop() |
| + // Keep re-checking that the backend is still an Eleven instance; two |
| + // consecutive failures drop the tunnel (a client can't swap in a |
| + // different local server after passing the door check). |
| + reprobe := time.NewTicker(10 * time.Minute) |
| + defer reprobe.Stop() |
| + probeFails := 0 |
| + readErr := make(chan error, 1) |
| + go func() { |
| + for { |
| + if _, _, err := c.Read(ctx); err != nil { |
| + readErr <- err |
| + return |
| + } |
| + } |
| + }() |
| + for done := false; !done; { |
| + select { |
| + case <-readErr: |
| + done = true |
| + case <-reprobe.C: |
| + if err := g.probeBackend(ctx, t); err != nil { |
| + probeFails++ |
| + log.Printf("tunnel: %s re-probe failed (%d): %v", host, probeFails, err) |
| + if probeFails >= 2 { |
| + writeJSON(ctx, c, ControlMsg{Type: "error", Error: "the tunnelled backend stopped looking like an Eleven instance"}) |
| + done = true |
| + } |
| + } else { |
| + probeFails = 0 |
| + } |
| + case <-ageOut.C: |
| + writeJSON(ctx, c, ControlMsg{Type: "error", Error: "trial tunnel expired (24h) — run lchat try again for a fresh address"}) |
| + done = true |
| + case <-t.closed: |
| + done = true |
| + case <-ctx.Done(): |
| + done = true |
| + } |
| + } |
| + g.drop(t) |
| + log.Printf("tunnel: %s closed", host) |
| +} |
| + |
| +// handleConn is the client dialling back with the data connection for one |
| +// pending id; it becomes a raw byte pipe paired with a waiting proxy dial. |
| +func (g *Gateway) handleConn(w http.ResponseWriter, r *http.Request) { |
| + id := strings.TrimPrefix(r.URL.Path, "/api/tunnel/conn/") |
| + g.mu.Lock() |
| + ch := g.pending[id] |
| + delete(g.pending, id) |
| + g.mu.Unlock() |
| + if ch == nil { |
| + http.Error(w, "unknown conn id", http.StatusNotFound) |
| + return |
| + } |
| + c, err := websocket.Accept(w, r, nil) |
| + if err != nil { |
| + close(ch) |
| + return |
| + } |
| + c.SetReadLimit(-1) |
| + // Lifetime belongs to the proxied exchange now, not this handler's request |
| + // context — NetConn with Background keeps it open until either side closes. |
| + conn := websocket.NetConn(context.Background(), c, websocket.MessageBinary) |
| + select { |
| + case ch <- conn: |
| + // The proxy owns it now; block here isn't needed — Accept hijacked the |
| + // HTTP connection, and returning is fine. |
| + case <-time.After(5 * time.Second): |
| + conn.Close() |
| + } |
| +} |
| + |
| +// proxyFor builds the reverse proxy whose transport dials the client through |
| +// the control channel: every new backend connection is one "dial" message and |
| +// one dialled-back WebSocket. |
| +func (g *Gateway) proxyFor(t *liveTunnel) *httputil.ReverseProxy { |
| + transport := &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return g.dialBack(ctx, t) |
| + }, |
| + MaxIdleConns: 8, |
| + IdleConnTimeout: 60 * time.Second, |
| + MaxIdleConnsPerHost: 8, |
| + } |
| + return &httputil.ReverseProxy{ |
| + Rewrite: func(pr *httputil.ProxyRequest) { |
| + pr.SetXForwarded() |
| + pr.Out.URL.Scheme = "http" |
| + pr.Out.URL.Host = "tunnel-local" |
| + pr.Out.Host = pr.In.Host // the local instance sees its public hostname |
| + }, |
| + Transport: transport, |
| + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { |
| + http.Error(w, "the trial space didn't answer — is lchat try still running?", http.StatusBadGateway) |
| + }, |
| + } |
| +} |
| + |
| +func (g *Gateway) dialBack(ctx context.Context, t *liveTunnel) (net.Conn, error) { |
| + if int(t.conns.Add(1)) > g.maxConns() { |
| + t.conns.Add(-1) |
| + return nil, fmt.Errorf("tunnel connection cap reached") |
| + } |
| + release := func() { t.conns.Add(-1) } |
| + id, err := randToken(18) |
| + if err != nil { |
| + release() |
| + return nil, err |
| + } |
| + ch := make(chan net.Conn, 1) |
| + g.mu.Lock() |
| + g.pending[id] = ch |
| + g.mu.Unlock() |
| + cleanup := func() { |
| + g.mu.Lock() |
| + delete(g.pending, id) |
| + g.mu.Unlock() |
| + } |
| + if err := writeJSON(ctx, t.control, ControlMsg{Type: "dial", ID: id}); err != nil { |
| + cleanup() |
| + release() |
| + g.drop(t) // control channel is dead — the tunnel is gone |
| + return nil, fmt.Errorf("tunnel control write: %w", err) |
| + } |
| + select { |
| + case conn, ok := <-ch: |
| + if !ok || conn == nil { |
| + cleanup() |
| + release() |
| + return nil, fmt.Errorf("tunnel dial-back failed") |
| + } |
| + return &countedConn{Conn: conn, release: release}, nil |
| + case <-time.After(g.dialTimeout()): |
| + cleanup() |
| + release() |
| + return nil, fmt.Errorf("tunnel dial-back timed out") |
| + case <-ctx.Done(): |
| + cleanup() |
| + release() |
| + return nil, ctx.Err() |
| + } |
| +} |
| + |
| +// countedConn releases its tunnel's connection slot exactly once on close. |
| +type countedConn struct { |
| + net.Conn |
| + release func() |
| + once sync.Once |
| +} |
| + |
| +func (c *countedConn) Close() error { |
| + c.once.Do(c.release) |
| + return c.Conn.Close() |
| +} |
| + |
| +// probeBackend asks the tunnelled backend to prove it's an lchat instance |
| +// before (and periodically after) its hostname goes public: GET /api/version |
| +// through the tunnel must return 200 and a version. This keeps the trial |
| +// domain lending its name — and its TLS — only to actual Eleven instances, |
| +// not to whatever a modified client points --local at. |
| +func (g *Gateway) probeBackend(ctx context.Context, t *liveTunnel) error { |
| + client := &http.Client{ |
| + Timeout: 10 * time.Second, |
| + Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return g.dialBack(ctx, t) |
| + }, |
| + }, |
| + } |
| + req, err := http.NewRequestWithContext(ctx, "GET", "http://tunnel-local/api/version", nil) |
| + if err != nil { |
| + return err |
| + } |
| + req.Host = t.host |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + return fmt.Errorf("backend didn't answer: %w", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return fmt.Errorf("backend /api/version: HTTP %d", resp.StatusCode) |
| + } |
| + var v struct { |
| + Version string `json:"version"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil || v.Version == "" { |
| + return fmt.Errorf("backend isn't an Eleven instance") |
| + } |
| + return nil |
| +} |
| + |
| +// drop removes the tunnel everywhere, once. |
| +func (g *Gateway) drop(t *liveTunnel) { |
| + t.once.Do(func() { |
| + close(t.closed) |
| + g.mu.Lock() |
| + delete(g.tunnels, t.host) |
| + g.mu.Unlock() |
| + if g.OnClose != nil { |
| + g.OnClose(t.host) |
| + } |
| + t.control.CloseNow() |
| + }) |
| +} |
| + |
| +// admit enforces the per-IP rate and the global cap. Guardrails, not a wall: |
| +// this is a free trial surface on a shared box, and the failure mode we're |
| +// preventing is someone scripting a thousand tunnels, not a family trying it |
| +// twice. |
| +func (g *Gateway) admit(ip string) error { |
| + g.mu.Lock() |
| + defer g.mu.Unlock() |
| + if g.tunnels == nil { |
| + g.tunnels = map[string]*liveTunnel{} |
| + g.pending = map[string]chan net.Conn{} |
| + g.byIP = map[string][]time.Time{} |
| + } |
| + if len(g.tunnels) >= g.maxTunnels() { |
| + return fmt.Errorf("the trial gateway is full right now — please try again later") |
| + } |
| + cutoff := time.Now().Add(-time.Hour) |
| + kept := g.byIP[ip][:0] |
| + for _, ts := range g.byIP[ip] { |
| + if ts.After(cutoff) { |
| + kept = append(kept, ts) |
| + } |
| + } |
| + g.byIP[ip] = kept |
| + if len(kept) >= g.maxPerIP() { |
| + return fmt.Errorf("too many trial spaces from this address — please try again in an hour") |
| + } |
| + return nil |
| +} |
| + |
| +func (g *Gateway) newHost() (string, error) { |
| + for range 20 { |
| + slug := make([]byte, 5) |
| + for i := range slug { |
| + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(slugAlphabet)))) |
| + if err != nil { |
| + return "", err |
| + } |
| + slug[i] = slugAlphabet[n.Int64()] |
| + } |
| + host := "try-" + string(slug) + "." + g.Domain |
| + g.mu.Lock() |
| + _, taken := g.tunnels[host] |
| + g.mu.Unlock() |
| + if !taken { |
| + return host, nil |
| + } |
| + } |
| + return "", fmt.Errorf("couldn't find a free address") |
| +} |
| + |
| +// clientIP prefers the router's X-Forwarded-For (it fronts us over a unix |
| +// socket, so RemoteAddr is meaningless). |
| +func clientIP(r *http.Request) string { |
| + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { |
| + return strings.TrimSpace(strings.Split(xff, ",")[0]) |
| + } |
| + if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { |
| + return h |
| + } |
| + return r.RemoteAddr |
| +} |
| + |
| +func randToken(n int) (string, error) { |
| + b := make([]byte, n) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", err |
| + } |
| + return base64.RawURLEncoding.EncodeToString(b), nil |
| +} |
| + |
| +func readJSON(ctx context.Context, c *websocket.Conn, v any) error { |
| + _, data, err := c.Read(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + return json.Unmarshal(data, v) |
| +} |
| + |
| +func writeJSON(ctx context.Context, c *websocket.Conn, v any) error { |
| + data, err := json.Marshal(v) |
| + if err != nil { |
| + return err |
| + } |
| + wctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| + defer cancel() |
| + return c.Write(wctx, websocket.MessageText, data) |
| +} |
diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go| new file mode 100644 |
| index 0000000..929fc84 |
| --- /dev/null |
| +++ b/internal/tunnel/tunnel.go |
| @@ -0,0 +1,43 @@ |
| +// Package tunnel is the "try Eleven in one command" transport: an outbound |
| +// reverse tunnel that gives a locally-running instance a public |
| +// https://try-<slug>.<domain> address, with no inbound ports, no DNS step, and |
| +// no certificate work (the router's wildcard cert already covers the domain). |
| +// |
| +// The shape is deliberately simple — two WebSocket flavours and zero clever |
| +// framing — so it stays understandable and maintainable by a human with an |
| +// afternoon (CLAUDE.md: built to outlive AI): |
| +// |
| +// - One CONTROL connection per tunnel: the client dials |
| +// wss://<gateway>/api/tunnel/register and holds it open. The gateway |
| +// assigns the slug, registers the route, and later writes small JSON |
| +// "dial" messages down it when the public side needs a connection. |
| +// - One DATA connection per backend connection: for each "dial" the client |
| +// opens wss://<gateway>/api/tunnel/conn/<id>, and both ends treat that |
| +// WebSocket as a plain byte pipe (websocket.NetConn) — the gateway's |
| +// reverse proxy speaks ordinary HTTP/1.1 through it to the client's local |
| +// instance, which is what makes WebSockets (the chat!) work through the |
| +// tunnel unmodified. |
| +// |
| +// The server stays blind here exactly as everywhere else: what crosses the |
| +// tunnel is the same TLS-stripped HTTP the router already fronts for every |
| +// instance — message bodies are ciphertext before they ever reach it. |
| +package tunnel |
| + |
| +// ControlMsg is every message that crosses a control connection, in either |
| +// direction. Type discriminates: |
| +// |
| +// client → gateway: "hello" (opens the exchange; Version is informative) |
| +// gateway → client: "ready" (Host is the assigned public hostname) |
| +// gateway → client: "dial" (open a data connection for ID) |
| +// gateway → client: "error" (human-readable refusal, then close) |
| +type ControlMsg struct { |
| + Type string `json:"type"` |
| + Version string `json:"version,omitempty"` |
| + Host string `json:"host,omitempty"` |
| + ID string `json:"id,omitempty"` |
| + Error string `json:"error,omitempty"` |
| +} |
| + |
| +// slugAlphabet is lowercase and unambiguous (no 0/o, 1/l/i) — these end up in |
| +// hostnames people read out loud. |
| +const slugAlphabet = "abcdefghjkmnpqrstvwxyz23456789" |
diff --git a/internal/tunnel/tunnel_test.go b/internal/tunnel/tunnel_test.go| new file mode 100644 |
| index 0000000..cc19441 |
| --- /dev/null |
| +++ b/internal/tunnel/tunnel_test.go |
| @@ -0,0 +1,196 @@ |
| +package tunnel |
| + |
| +import ( |
| + "context" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/coder/websocket" |
| +) |
| + |
| +// The whole loop, no router required: a local "instance" (plain HTTP + |
| +// a WebSocket echo), a gateway, and a client tunnelling between them. Verifies |
| +// ordinary requests, header passthrough, and — the part that matters for the |
| +// chat — a live WebSocket upgraded end-to-end through the tunnel. |
| +func TestTunnelEndToEnd(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch r.URL.Path { |
| + case "/api/version": |
| + w.Header().Set("Content-Type", "application/json") |
| + io.WriteString(w, `{"build":"test","version":"test-1"}`) |
| + case "/ws": |
| + c, err := websocket.Accept(w, r, nil) |
| + if err != nil { |
| + return |
| + } |
| + defer c.CloseNow() |
| + ctx := r.Context() |
| + for { |
| + typ, data, err := c.Read(ctx) |
| + if err != nil { |
| + return |
| + } |
| + if err := c.Write(ctx, typ, append([]byte("echo:"), data...)); err != nil { |
| + return |
| + } |
| + } |
| + default: |
| + w.Header().Set("X-Seen-Host", r.Host) |
| + io.WriteString(w, "hello through the tunnel") |
| + } |
| + })) |
| + defer backend.Close() |
| + |
| + gw := &Gateway{Domain: "test.local", DialTimeout: 5 * time.Second} |
| + registered := make(chan string, 1) |
| + gw.OnRegister = func(host string) error { registered <- host; return nil } |
| + closed := make(chan string, 1) |
| + gw.OnClose = func(host string) { closed <- host } |
| + |
| + gwSrv := httptest.NewServer(gw) |
| + defer gwSrv.Close() |
| + u, _ := url.Parse(gwSrv.URL) |
| + gw.ControlHost = u.Hostname() // ServeHTTP compares hosts port-stripped |
| + |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + cl := &Client{Gateway: gwSrv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://")} |
| + ready := make(chan string, 1) |
| + clientDone := make(chan error, 1) |
| + go func() { clientDone <- cl.Run(ctx, func(h string) { ready <- h }) }() |
| + |
| + var host string |
| + select { |
| + case host = <-ready: |
| + case err := <-clientDone: |
| + t.Fatalf("client exited early: %v", err) |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("no ready") |
| + } |
| + if !strings.HasPrefix(host, "try-") || !strings.HasSuffix(host, ".test.local") { |
| + t.Fatalf("unexpected host %q", host) |
| + } |
| + if got := <-registered; got != host { |
| + t.Fatalf("OnRegister got %q, ready got %q", got, host) |
| + } |
| + |
| + // A plain request, addressed to the slug host through the gateway. |
| + hostRewrite := func(rt http.RoundTripper) http.RoundTripper { |
| + return roundTripperFunc(func(r *http.Request) (*http.Response, error) { |
| + r.Host = host |
| + return rt.RoundTrip(r) |
| + }) |
| + } |
| + httpc := &http.Client{Transport: hostRewrite(http.DefaultTransport.(*http.Transport).Clone())} |
| + resp, err := httpc.Get(gwSrv.URL + "/anything") |
| + if err != nil { |
| + t.Fatalf("GET: %v", err) |
| + } |
| + body, _ := io.ReadAll(resp.Body) |
| + resp.Body.Close() |
| + if string(body) != "hello through the tunnel" { |
| + t.Fatalf("body = %q", body) |
| + } |
| + if got := resp.Header.Get("X-Seen-Host"); got != host { |
| + t.Fatalf("backend saw Host %q, want %q (public hostname must reach the instance)", got, host) |
| + } |
| + |
| + // A WebSocket, upgraded end-to-end through the tunnel. |
| + wsCtx, wsCancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer wsCancel() |
| + c, _, err := websocket.Dial(wsCtx, "ws://"+u.Host+"/ws", &websocket.DialOptions{ |
| + HTTPClient: &http.Client{Transport: hostRewrite(http.DefaultTransport.(*http.Transport).Clone())}, |
| + }) |
| + if err != nil { |
| + t.Fatalf("ws dial: %v", err) |
| + } |
| + defer c.CloseNow() |
| + if err := c.Write(wsCtx, websocket.MessageText, []byte("ping")); err != nil { |
| + t.Fatalf("ws write: %v", err) |
| + } |
| + _, data, err := c.Read(wsCtx) |
| + if err != nil { |
| + t.Fatalf("ws read: %v", err) |
| + } |
| + if string(data) != "echo:ping" { |
| + t.Fatalf("ws echo = %q", data) |
| + } |
| + |
| + // Tearing the client down retires the route. |
| + cancel() |
| + select { |
| + case got := <-closed: |
| + if got != host { |
| + t.Fatalf("OnClose got %q", got) |
| + } |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("route never closed") |
| + } |
| +} |
| + |
| +// The per-IP limiter refuses the sixth tunnel in an hour from one address. |
| +func TestTunnelRateLimit(t *testing.T) { |
| + gw := &Gateway{Domain: "test.local"} |
| + for range 5 { |
| + if err := gw.admit("203.0.113.9"); err != nil { |
| + t.Fatalf("admit: %v", err) |
| + } |
| + gw.byIP["203.0.113.9"] = append(gw.byIP["203.0.113.9"], time.Now()) |
| + } |
| + if err := gw.admit("203.0.113.9"); err == nil { |
| + t.Fatal("sixth tunnel admitted") |
| + } |
| + if err := gw.admit("203.0.113.10"); err != nil { |
| + t.Fatalf("other address blocked: %v", err) |
| + } |
| +} |
| + |
| +// A backend that is not an Eleven instance is refused before its hostname is |
| +// ever published. |
| +func TestTunnelRejectsNonEleven(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + io.WriteString(w, "just a web server") |
| + })) |
| + defer backend.Close() |
| + |
| + gw := &Gateway{Domain: "test.local", DialTimeout: 5 * time.Second} |
| + registered := false |
| + gw.OnRegister = func(string) error { registered = true; return nil } |
| + gwSrv := httptest.NewServer(gw) |
| + defer gwSrv.Close() |
| + u, _ := url.Parse(gwSrv.URL) |
| + gw.ControlHost = u.Hostname() |
| + |
| + cl := &Client{Gateway: gwSrv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://")} |
| + err := cl.Run(context.Background(), func(string) {}) |
| + if err == nil || !strings.Contains(err.Error(), "only Eleven instances") { |
| + t.Fatalf("expected probe refusal, got %v", err) |
| + } |
| + if registered { |
| + t.Fatal("route was published for a non-Eleven backend") |
| + } |
| +} |
| + |
| +// The per-tunnel connection cap refuses dial-backs once live + in-flight |
| +// connections hit the limit. |
| +func TestTunnelConnCap(t *testing.T) { |
| + g := &Gateway{MaxConns: 2} |
| + tun := &liveTunnel{closed: make(chan struct{})} |
| + tun.conns.Store(2) |
| + if _, err := g.dialBack(context.Background(), tun); err == nil || |
| + !strings.Contains(err.Error(), "connection cap") { |
| + t.Fatalf("expected cap refusal, got %v", err) |
| + } |
| + if got := tun.conns.Load(); got != 2 { |
| + t.Fatalf("refused dial leaked a slot: %d", got) |
| + } |
| +} |
| + |
| +type roundTripperFunc func(*http.Request) (*http.Response, error) |
| + |
| +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
diff --git a/main.go b/main.go| index 03a699f..a205611 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -178,6 +178,12 @@ func main() { |
| case "cloud": |
| runCloud(os.Args[2:]) |
| return |
| + case "try": |
| + runTryCmd(os.Args[2:]) |
| + return |
| + case "tunnel": |
| + runTunnelCmd(os.Args[2:]) |
| + return |
| } |
| } |
diff --git a/router.go b/router.go| index d8ba225..3f797c0 100644 |
| --- a/router.go |
| +++ b/router.go |
| @@ -312,6 +312,34 @@ func runRouter(args []string) { |
| } |
| } |
| + // Watch the registry for writes from other processes (the tunnel gateway |
| + // publishes trial routes this way, without needing rights to signal us). |
| + // SQLite's data_version increments on any commit by another connection, so |
| + // this is one cheap PRAGMA every couple of seconds — the route map itself |
| + // is only rebuilt when something actually changed. |
| + go func() { |
| + var last int64 = -1 |
| + for range time.Tick(2 * time.Second) { |
| + var v int64 |
| + if err := reg.sql.QueryRow("PRAGMA data_version").Scan(&v); err != nil { |
| + continue |
| + } |
| + if last == -1 { |
| + last = v |
| + continue |
| + } |
| + if v == last { |
| + continue |
| + } |
| + last = v |
| + if err := rt.reload(); err != nil { |
| + log.Printf("router: registry-watch reload failed: %v", err) |
| + continue |
| + } |
| + log.Printf("router: registry changed — reloaded (%d route(s))", len(rt.current())) |
| + } |
| + }() |
| + |
| // Reload routes on SIGHUP (add-instance/deploy will also trigger this via the |
| // control socket in a later step). |
| sighup := make(chan os.Signal, 1) |
diff --git a/trycmd.go b/trycmd.go| new file mode 100644 |
| index 0000000..d2329e3 |
| --- /dev/null |
| +++ b/trycmd.go |
| @@ -0,0 +1,306 @@ |
| +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-<slug>.<domain> 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 <addr>`: 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 <try-…host> [--registry P]") |
| + } |
| + 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") |
| + _ = 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, |
| + OnRegister: func(h string) error { |
| + if existing, err := reg.get(h); err != nil { |
| + return err |
| + } else if existing != nil { |
| + return fmt.Errorf("host %s already routed", 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) |
| + } |
| + }, |
| + } |
| + |
| + _ = 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} |
| + if err := srv.Serve(ln); err != nil { |
| + die("tunnel serve: %v", err) |
| + } |
| +} |
| + |
| +// runTunnelKill is the operator kill-switch for one trial 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.) |
| +func runTunnelKill(args []string) { |
| + fs := flag.NewFlagSet("tunnel kill", flag.ExitOnError) |
| + registry := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry") |
| + _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| + host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) |
| + if !strings.HasPrefix(host, "try-") { |
| + die("tunnel kill: %q isn't a trial host (try-…)", 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) |
| + 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-%'`, socket) |
| + return err |
| +} |