eleven / messenger Public

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>
Paul Campbell bc74990539f0a3284f1b8d64cbf18210b0fa1a80 parent c071793
11 files changed, +1334 −0
  • bootstrap.go +8 −0
  • changelog/2026-07-11-try-eleven-in-one-command.md +7 −0
  • deploy/systemd/lchat-tunnel.service +44 −0
  • docs/tunnel-try.md +88 −0
  • internal/tunnel/client.go +137 −0
  • internal/tunnel/gateway.go +471 −0
  • internal/tunnel/tunnel.go +43 −0
  • internal/tunnel/tunnel_test.go +196 −0
  • main.go +6 −0
  • router.go +28 −0
  • trycmd.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
+}