eleven / ocho Public

Instance router: self-hosted single-machine bootstrap (drop the orchestrator)

One static Go binary + systemd replaces the orchestrator for single-box multi-instance hosting: the lchat router (autocert HTTP-01 TLS + host routing over unix sockets), per-instance instances, add-instance / deploy (zero-downtime) / remove / instances / admin-link, add-service (home/push/third-party), add-bot (Node sidecar), and lchat bootstrap. Validated end-to-end on a real box, including a live instance migration. Docs: docs/instance-router.md, docs/instance-migration.md.
Paul Campbell 4f7388946f0ae8f254773d1fe54e2cb8d8e2fb2c parent 62df77f
20 files changed, +2301 −4
  • agent/mint-invite.mjs +52 −0
  • bootstrap.go +69 −0
  • bootstrap_test.go +26 −0
  • bot.go +127 −0
  • control.go +499 −0
  • control_test.go +117 −0
  • deploy/README.md +53 −0
  • deploy/systemd/lchat-bot@.service +19 −0
  • deploy/systemd/lchat-router.service +14 −0
  • deploy/systemd/lchat@.service +15 −0
  • docs/instance-migration.md +108 −0
  • docs/instance-router.md +418 −0
  • go.mod +3 −1
  • go.sum +2 −0
  • hub.go +21 −0
  • hub_test.go +21 −0
  • main.go +98 −3
  • registry.go +149 −0
  • router.go +215 −0
  • router_test.go +275 −0
diff --git a/agent/mint-invite.mjs b/agent/mint-invite.mjs
new file mode 100644
index 0000000..15b564b
--- /dev/null
+++ b/agent/mint-invite.mjs
@@ -0,0 +1,52 @@
+// mint-invite.mjs — mint one agent invite via the admin API and print its secret.
+//
+// Used by `lchat add-bot` to provision a sidecar identity. It reuses
+// web/static/crypto.js so the id + claim_hash it derives from the secret T match
+// exactly what the sidecar (provision.mjs) computes from the same T — the crypto
+// never diverges (the whole reason the bot is Node). Reads LCHAT_BASE +
+// LCHAT_ADMIN_TOKEN from the env; takes --label / --description / --model /
+// --listen. Prints the bare secret T (the sidecar's LCHAT_*_INVITE) to stdout;
+// all diagnostics go to stderr.
+import { randomSecret, deriveInvite } from "../web/static/crypto.js";
+
+const BASE = (process.env.LCHAT_BASE || "").replace(/\/$/, "");
+const ADMIN = (process.env.LCHAT_ADMIN_TOKEN || "").trim();
+if (!BASE || !ADMIN) {
+ console.error("mint-invite: set LCHAT_BASE and LCHAT_ADMIN_TOKEN");
+ process.exit(1);
+}
+
+function arg(name, def = "") {
+ const i = process.argv.indexOf(`--${name}`);
+ return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : def;
+}
+
+const label = arg("label");
+if (!label) {
+ console.error("mint-invite: --label is required");
+ process.exit(1);
+}
+
+const T = randomSecret();
+const { id, claimHash } = await deriveInvite(T);
+const res = await fetch(`${BASE}/api/admin/key-invites`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${ADMIN}` },
+ body: JSON.stringify({
+ label,
+ id,
+ wrapped: "",
+ claim_hash: claimHash,
+ agent: true,
+ available_to_members: true, // in the Helpers catalog, so Eleven's roster baseline sees members
+ description: arg("description"),
+ model: arg("model"),
+ listen: arg("listen"), // "", "mention", or "name"
+ }),
+});
+if (!res.ok) {
+ const detail = await res.text().catch(() => "");
+ console.error(`mint-invite: POST /api/admin/key-invites -> HTTP ${res.status} ${detail}`);
+ process.exit(1);
+}
+process.stdout.write(T); // the bare secret; add-bot writes it as the sidecar's LCHAT_*_INVITE
diff --git a/bootstrap.go b/bootstrap.go
new file mode 100644
index 0000000..fbb9c89
--- /dev/null
+++ b/bootstrap.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+ _ "embed"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// The systemd units ship inside the binary (source of truth: deploy/systemd), so
+// `lchat bootstrap` can provision a fresh box with no separate files to copy.
+//
+//go:embed deploy/systemd/lchat-router.service
+var unitRouter string
+
+//go:embed deploy/systemd/lchat@.service
+var unitInstance string
+
+//go:embed deploy/systemd/lchat-bot@.service
+var unitBot string
+
+// tmpfilesConf keeps /run/lchat across reboots (/run is tmpfs); bootstrap also
+// creates it now.
+const tmpfilesConf = "d /run/lchat 0710 root root -\n"
+
+// runBootstrap installs the systemd units + runtime dir and starts the router, so
+// provisioning a fresh box is one command after the binary is copied to
+// /opt/lchat/lchat. Idempotent — safe to re-run. See docs/instance-router.md.
+func runBootstrap(args []string) {
+ fs := flag.NewFlagSet("bootstrap", flag.ExitOnError)
+ dataDir := fs.String("data-dir", "/data", "dir for per-instance DBs, env files, the registry, and the acme cache")
+ unitDir := fs.String("unit-dir", "/etc/systemd/system", "systemd unit directory")
+ noStart := fs.Bool("no-start", false, "install units but don't enable/start the router")
+ _ = fs.Parse(args)
+
+ for _, d := range []string{*dataDir, filepath.Join(*dataDir, "acme"), "/run/lchat"} {
+ if err := os.MkdirAll(d, 0o750); err != nil {
+ die("bootstrap: mkdir %s: %v", d, err)
+ }
+ }
+ if err := os.Chmod("/run/lchat", 0o710); err != nil {
+ die("bootstrap: chmod /run/lchat: %v", err)
+ }
+
+ write := func(path, content string) {
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ die("bootstrap: write %s: %v", path, err)
+ }
+ fmt.Printf(" wrote %s\n", path)
+ }
+ write(filepath.Join(*unitDir, "lchat-router.service"), unitRouter)
+ write(filepath.Join(*unitDir, "lchat@.service"), unitInstance)
+ write(filepath.Join(*unitDir, "lchat-bot@.service"), unitBot)
+ write("/etc/tmpfiles.d/lchat.conf", tmpfilesConf)
+
+ if err := systemctl("daemon-reload"); err != nil {
+ die("bootstrap: %v", err)
+ }
+ if *noStart {
+ fmt.Println("bootstrap: units installed (router not started; --no-start)")
+ return
+ }
+ if err := systemctl("enable", "--now", "lchat-router"); err != nil {
+ die("bootstrap: start router: %v", err)
+ }
+ fmt.Println("bootstrap: router installed and started")
+ fmt.Println(" next: lchat add-instance <host>")
+}
diff --git a/bootstrap_test.go b/bootstrap_test.go
new file mode 100644
index 0000000..054d65d
--- /dev/null
+++ b/bootstrap_test.go
@@ -0,0 +1,26 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestEmbeddedUnits guards the embedded systemd units against drift — the
+// instance template must carry the EnvironmentFile add-instance relies on.
+func TestEmbeddedUnits(t *testing.T) {
+ if !strings.Contains(unitRouter, "ExecStart=/opt/lchat/lchat router") {
+ t.Error("router unit missing its ExecStart line")
+ }
+ if !strings.Contains(unitInstance, "EnvironmentFile=-/data/%i.env") {
+ t.Error("instance unit missing EnvironmentFile directive")
+ }
+ if !strings.Contains(unitInstance, "ExecStart=/opt/lchat/lchat --socket /run/lchat/%i.sock --db /data/%i.db") {
+ t.Error("instance unit missing its ExecStart line")
+ }
+ if !strings.Contains(unitBot, "ExecStart=/usr/bin/node /opt/lchat/agent/agent.mjs") {
+ t.Error("bot unit missing its node ExecStart line")
+ }
+ if !strings.Contains(unitBot, "EnvironmentFile=-/data/%i-bot.env") {
+ t.Error("bot unit missing its EnvironmentFile")
+ }
+}
diff --git a/bot.go b/bot.go
new file mode 100644
index 0000000..1e95551
--- /dev/null
+++ b/bot.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// runAddBot: `lchat add-bot <host>` — provision the Node sidecar (Claude + Eleven +
+// Announcer) for an instance. It mints an agent invite per identity via the
+// instance's admin API (through agent/mint-invite.mjs, which reuses
+// web/static/crypto.js so the derived id/claim match what the sidecar computes),
+// writes the sidecar's EnvironmentFile, and starts lchat-bot@<host>. The instance
+// must be up first (Eleven baselines the roster on its first tick). See
+// docs/instance-router.md.
+func runAddBot(args []string) {
+ fs := flag.NewFlagSet("add-bot", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ model := fs.String("model", "", "LCHAT_MODEL for the bots (optional; sidecar default otherwise)")
+ agentDir := fs.String("agent-dir", "/opt/lchat/agent", "dir holding the Node sidecar (agent/)")
+ nodeBin := fs.String("node", "/usr/bin/node", "path to the node binary")
+ botUnit := fs.String("unit", envOr("LCHAT_BOT_UNIT", "lchat-bot@"), "systemd bot unit template prefix")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+ // The API key is a secret, so read it from the environment (a flag would leak it
+ // into the process table + shell history).
+ apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY"))
+
+ host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
+ if !validHost(host) {
+ die("add-bot: invalid host %q", host)
+ }
+
+ // The instance must exist (the bot connects to it), and we need its admin token
+ // (from the instance's EnvironmentFile) to mint agent invites.
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("add-bot: open registry: %v", err)
+ }
+ rt, err := reg.get(host)
+ if err != nil {
+ die("add-bot: %v", err)
+ }
+ if rt == nil || rt.Kind != "instance" {
+ die("add-bot: %q is not a messenger instance (run add-instance first)", host)
+ }
+ env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
+ if err != nil {
+ die("add-bot: read instance env: %v", err)
+ }
+ admin := env["LCHAT_ADMIN_TOKEN"]
+ if admin == "" {
+ die("add-bot: no LCHAT_ADMIN_TOKEN for %s", host)
+ }
+
+ base := "https://" + host
+ mintScript := filepath.Join(*agentDir, "mint-invite.mjs")
+ sessionDir := filepath.Join(instanceDataDir, host+"-bot")
+
+ // One agent invite per identity. All three are catalog-available so Eleven's
+ // roster baseline sees the joined members (docs/bots.md).
+ identities := []struct{ envVar, label, desc string }{
+ {"LCHAT_INVITE", "Claude", "General assistant — DM me or @mention me in a group."},
+ {"LCHAT_ELEVEN_INVITE", "Eleven", "Your guide to the app — I welcome new members."},
+ {"LCHAT_ANNOUNCER_INVITE", "Announcer", "Posts what's new after each update."},
+ }
+ botEnv := map[string]string{
+ "LCHAT_BASE": base,
+ "LCHAT_SESSION": filepath.Join(sessionDir, "session.json"),
+ "LCHAT_ELEVEN_SESSION": filepath.Join(sessionDir, "eleven-session.json"),
+ "LCHAT_ANNOUNCER_SESSION": filepath.Join(sessionDir, "announcer-session.json"),
+ }
+ if apiKey != "" {
+ botEnv["ANTHROPIC_API_KEY"] = apiKey
+ }
+ if *model != "" {
+ botEnv["LCHAT_MODEL"] = *model
+ }
+ for _, id := range identities {
+ t, err := mintAgentInvite(*nodeBin, mintScript, base, admin, id.label, id.desc, *model)
+ if err != nil {
+ die("add-bot: mint %s invite: %v", id.label, err)
+ }
+ botEnv[id.envVar] = t
+ }
+
+ // Persistent per-instance session dir + the sidecar's EnvironmentFile (0600 —
+ // it holds the invites and the API key).
+ if err := os.MkdirAll(sessionDir, 0o750); err != nil {
+ die("add-bot: mkdir %s: %v", sessionDir, err)
+ }
+ botEnvPath := filepath.Join(instanceDataDir, host+"-bot.env")
+ if err := writeEnvFile(botEnvPath, botEnv); err != nil {
+ die("add-bot: write bot env: %v", err)
+ }
+
+ unit := *botUnit + host
+ if err := systemctl("enable", "--now", unit); err != nil {
+ die("add-bot: start %s: %v (invites minted; env written to %s)", unit, err, botEnvPath)
+ }
+ fmt.Printf("added bot sidecar for %s (%s)\n", host, unit)
+ if apiKey == "" {
+ fmt.Printf(" note: no ANTHROPIC_API_KEY in env — bots are deterministic-only (Eleven welcomes +\n"+
+ " slash commands); add ANTHROPIC_API_KEY to %s and restart %s for AI replies\n", botEnvPath, unit)
+ }
+ fmt.Printf(" logs: journalctl -u %s -f\n", unit)
+}
+
+// mintAgentInvite runs the Node minter (which reuses crypto.js) for one identity
+// and returns the bare invite secret it prints.
+func mintAgentInvite(nodeBin, script, base, admin, label, desc, model string) (string, error) {
+ cmd := exec.Command(nodeBin, script, "--label", label, "--description", desc, "--model", model, "--listen", "mention")
+ cmd.Env = append(os.Environ(), "LCHAT_BASE="+base, "LCHAT_ADMIN_TOKEN="+admin)
+ var out, errb strings.Builder
+ cmd.Stdout = &out
+ cmd.Stderr = &errb
+ if err := cmd.Run(); err != nil {
+ return "", fmt.Errorf("%v: %s", err, strings.TrimSpace(errb.String()))
+ }
+ t := strings.TrimSpace(out.String())
+ if t == "" {
+ return "", fmt.Errorf("minter returned no secret: %s", strings.TrimSpace(errb.String()))
+ }
+ return t, nil
+}
diff --git a/control.go b/control.go
new file mode 100644
index 0000000..62e6181
--- /dev/null
+++ b/control.go
@@ -0,0 +1,499 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "flag"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+)
+
+// The control subcommands automate what an operator would otherwise do by hand to
+// stand up an instance behind the router: mint the admin token, write the unit's
+// EnvironmentFile, start the instance, register + reload the route. See
+// docs/instance-router.md.
+
+// Instance paths are fixed by the systemd units (deploy/systemd/lchat@.service),
+// so the control commands use these constants rather than per-invocation flags
+// that would silently diverge from where the instance actually listens + stores.
+const (
+ instanceDataDir = "/data" // <host>.db + <host>.env live here
+ instanceSocketDir = "/run/lchat" // <host>.sock lives here
+)
+
+var hostnameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
+
+// validHost reports whether h is a plausible DNS hostname. It's strict because
+// <host> becomes a filename, a systemd instance name, and a unix socket path.
+func validHost(h string) bool {
+ return len(h) > 0 && len(h) <= 253 && hostnameRe.MatchString(h)
+}
+
+// checkSocketLen guards the unix sun_path limit (104 bytes on darwin/BSD, 108 on
+// Linux — stay under the smaller so a path is portable). A too-long path fails at
+// bind() with a cryptic error, so catch it early.
+func checkSocketLen(path string) error {
+ if len(path) >= 104 {
+ return fmt.Errorf("socket path %q is too long (%d bytes; unix limit ~104)", path, len(path))
+ }
+ return nil
+}
+
+func die(format string, a ...any) {
+ fmt.Fprintf(os.Stderr, format+"\n", a...)
+ os.Exit(1)
+}
+
+// reorderFlagsFirst moves positional args after all flags so the stdlib flag
+// parser (which stops at the first positional) still sees every flag — letting
+// `add-instance <host> --flag v` work as naturally as `--flag v <host>`.
+func reorderFlagsFirst(fs *flag.FlagSet, args []string) []string {
+ var flags, positional []string
+ for i := 0; i < len(args); i++ {
+ a := args[i]
+ if a == "--" {
+ positional = append(positional, args[i+1:]...)
+ break
+ }
+ if len(a) > 1 && a[0] == '-' {
+ flags = append(flags, a)
+ name := strings.TrimLeft(a, "-")
+ if strings.IndexByte(name, '=') >= 0 {
+ continue // --flag=value carries its own value
+ }
+ f := fs.Lookup(name)
+ if f == nil {
+ continue // unknown flag; let Parse report it
+ }
+ if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
+ continue // bool flag takes no value
+ }
+ // Pull the value along, unless the next token is itself a flag (a
+ // missing value) — let Parse report that rather than silently eating it.
+ if i+1 < len(args) && !(len(args[i+1]) > 1 && args[i+1][0] == '-') {
+ i++
+ flags = append(flags, args[i])
+ }
+ continue
+ }
+ positional = append(positional, a)
+ }
+ return append(flags, positional...)
+}
+
+// runAddInstance: `lchat add-instance <host>` — mint token + env file, start the
+// instance unit, register the route, reload the router.
+func runAddInstance(args []string) {
+ fs := flag.NewFlagSet("add-instance", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ unitTmpl := fs.String("unit", envOr("LCHAT_UNIT", "lchat@"), "systemd instance unit template prefix")
+ routerUnit := fs.String("router-unit", envOr("LCHAT_ROUTER_UNIT", "lchat-router"), "router systemd unit to reload")
+ appStoreURL := fs.String("app-store-url", "", "per-instance LCHAT_APP_STORE_URL")
+ homeOrigin := fs.String("home-origin", "", "per-instance LCHAT_HOME_ORIGIN")
+ pushRelayURL := fs.String("push-relay-url", "", "per-instance LCHAT_PUSH_RELAY_URL")
+ pushRelayToken := fs.String("push-relay-token", "", "per-instance LCHAT_PUSH_RELAY_TOKEN")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
+ if !validHost(host) {
+ die("add-instance: invalid host %q (want a hostname like chat.example.com)", host)
+ }
+ socket := filepath.Join(instanceSocketDir, host+".sock")
+ if err := checkSocketLen(socket); err != nil {
+ die("add-instance: %v", err)
+ }
+
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("add-instance: open registry: %v", err)
+ }
+ if existing, err := reg.get(host); err != nil {
+ die("add-instance: %v", err)
+ } else if existing != nil && existing.Status == "ready" {
+ die("add-instance: host %q already exists", host)
+ }
+ // A prior failed/creating attempt is overwritten on retry.
+
+ // The instance stores only the token's hash, so this is the one chance to
+ // capture the plaintext. Persist it (and per-instance config) to an
+ // EnvironmentFile the unit reads — that keeps it recoverable via `admin-link`.
+ token, err := randToken()
+ if err != nil {
+ die("add-instance: token: %v", err)
+ }
+ env := map[string]string{"LCHAT_ADMIN_TOKEN": token}
+ for k, v := range map[string]string{
+ "LCHAT_APP_STORE_URL": *appStoreURL,
+ "LCHAT_HOME_ORIGIN": *homeOrigin,
+ "LCHAT_PUSH_RELAY_URL": *pushRelayURL,
+ "LCHAT_PUSH_RELAY_TOKEN": *pushRelayToken,
+ } {
+ if v != "" {
+ env[k] = v
+ }
+ }
+ envPath := filepath.Join(instanceDataDir, host+".env")
+ if err := writeEnvFile(envPath, env); err != nil {
+ die("add-instance: write env file: %v", err)
+ }
+
+ dbPath := filepath.Join(instanceDataDir, host+".db")
+ route := Route{Host: host, Kind: "instance", Socket: socket, DBPath: dbPath, Status: "creating",
+ AppStoreURL: *appStoreURL, HomeOrigin: *homeOrigin, PushRelayURL: *pushRelayURL}
+ if err := reg.upsert(route); err != nil {
+ die("add-instance: register: %v", err)
+ }
+
+ unit := *unitTmpl + host
+ if err := systemctl("enable", "--now", unit); err != nil {
+ markInstanceFailed(reg, host, unit, "start: "+err.Error())
+ die("add-instance: start %s: %v", unit, err)
+ }
+ if err := waitHealthy(socket, 15*time.Second); err != nil {
+ markInstanceFailed(reg, host, unit, "health: "+err.Error())
+ die("add-instance: %v", err)
+ }
+
+ route.Status = "ready"
+ if err := reg.upsert(route); err != nil {
+ die("add-instance: mark ready: %v", err)
+ }
+ if err := systemctl("reload", *routerUnit); err != nil {
+ die("add-instance: reload %s: %v (route added; run: systemctl reload %s)", *routerUnit, err, *routerUnit)
+ }
+
+ fmt.Printf("added %s\n admin: https://%s/admin#token=%s\n (recover later: lchat admin-link %s)\n", host, host, token, host)
+}
+
+// runAddService: `lchat add-service <host> --socket <path>` — front an
+// externally-managed backend (the home server, push relay, or a third party's).
+// Unlike an instance, the router only proxies it; it doesn't start or supervise
+// the process. See docs/instance-router.md.
+func runAddService(args []string) {
+ fs := flag.NewFlagSet("add-service", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ socket := fs.String("socket", "", "unix socket the service already listens on (required)")
+ routerUnit := fs.String("router-unit", envOr("LCHAT_ROUTER_UNIT", "lchat-router"), "router systemd unit to reload")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
+ if !validHost(host) {
+ die("add-service: invalid host %q", host)
+ }
+ if *socket == "" {
+ die("add-service: --socket <path> is required (where the service listens)")
+ }
+ if err := checkSocketLen(*socket); err != nil {
+ die("add-service: %v", err)
+ }
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("add-service: open registry: %v", err)
+ }
+ if existing, err := reg.get(host); err != nil {
+ die("add-service: %v", err)
+ } else if existing != nil {
+ die("add-service: host %q already exists (status %s)", host, existing.Status)
+ }
+ // The backend must already be up — the router only proxies it. Use a lax
+ // reachability check (any HTTP response, redirects not followed): a service's
+ // "/" may legitimately 3xx/4xx/5xx, unlike a messenger instance.
+ if err := waitReachable(*socket, 10*time.Second); err != nil {
+ die("add-service: %s not answering on %s: %v (start the service first)", host, *socket, err)
+ }
+ if err := reg.upsert(Route{Host: host, Kind: "service", Socket: *socket, Status: "ready"}); err != nil {
+ die("add-service: register: %v", err)
+ }
+ if err := systemctl("reload", *routerUnit); err != nil {
+ die("add-service: reload %s: %v (route added; run: systemctl reload %s)", *routerUnit, err, *routerUnit)
+ }
+ fmt.Printf("added service %s -> %s\n", host, *socket)
+}
+
+// runRemove: `lchat remove <host>` — stop routing a host. Data-safe by default:
+// it deletes the route and reloads the router but leaves the instance running and
+// its DB/env in place; --stop also stops+disables an instance's unit.
+func runRemove(args []string) {
+ fs := flag.NewFlagSet("remove", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ unitTmpl := fs.String("unit", envOr("LCHAT_UNIT", "lchat@"), "systemd instance unit template prefix")
+ botUnit := fs.String("bot-unit", envOr("LCHAT_BOT_UNIT", "lchat-bot@"), "systemd bot unit template prefix")
+ routerUnit := fs.String("router-unit", envOr("LCHAT_ROUTER_UNIT", "lchat-router"), "router systemd unit to reload")
+ stop := fs.Bool("stop", false, "for an instance, also stop + disable its systemd unit (DB/env still kept)")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
+ if !validHost(host) {
+ die("remove: invalid host %q", host)
+ }
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("remove: open registry: %v", err)
+ }
+ rt, err := reg.get(host)
+ if err != nil {
+ die("remove: %v", err)
+ }
+ if rt == nil {
+ die("remove: unknown host %q", host)
+ }
+ if err := reg.delete(host); err != nil {
+ die("remove: %v", err)
+ }
+ if err := systemctl("reload", *routerUnit); err != nil {
+ die("remove: reload %s: %v", *routerUnit, err)
+ }
+ if *stop && rt.Kind == "instance" {
+ _ = systemctl("disable", "--now", *botUnit+host) // best-effort: a sidecar may not exist
+ if err := systemctl("disable", "--now", *unitTmpl+host); err != nil {
+ fmt.Printf("removed %s (route deleted), but stopping the unit failed: %v\n", host, err)
+ return
+ }
+ fmt.Printf("removed %s (route deleted, instance + sidecar stopped; DB/env kept in place)\n", host)
+ return
+ }
+ fmt.Printf("removed %s (route deleted, router reloaded)\n", host)
+ if rt.Kind == "instance" {
+ fmt.Printf(" note: instance %s%s is still running and its DB/env remain; pass --stop to stop it\n", *unitTmpl, host)
+ }
+}
+
+// runInstances: `lchat instances` — the route/status table.
+func runInstances(args []string) {
+ fs := flag.NewFlagSet("instances", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("instances: %v", err)
+ }
+ routes, err := reg.list()
+ if err != nil {
+ die("instances: %v", err)
+ }
+ if len(routes) == 0 {
+ fmt.Println("(no routes)")
+ return
+ }
+ fmt.Printf("%-34s %-9s %-9s %s\n", "HOST", "KIND", "STATUS", "SOCKET")
+ for _, r := range routes {
+ fmt.Printf("%-34s %-9s %-9s %s\n", r.Host, r.Kind, r.Status, r.Socket)
+ if r.LastError != "" {
+ fmt.Printf(" last error: %s\n", r.LastError)
+ }
+ }
+}
+
+// runAdminLink: `lchat admin-link <host>` — recover the admin link from the
+// instance's EnvironmentFile (the DB holds only the token's hash).
+func runAdminLink(args []string) {
+ fs := flag.NewFlagSet("admin-link", flag.ExitOnError)
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+ host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
+ if !validHost(host) {
+ die("admin-link: invalid host %q", host)
+ }
+ envPath := filepath.Join(instanceDataDir, host+".env")
+ env, err := readEnvFile(envPath)
+ if err != nil {
+ die("admin-link: %v", err)
+ }
+ tok := env["LCHAT_ADMIN_TOKEN"]
+ if tok == "" {
+ die("admin-link: no LCHAT_ADMIN_TOKEN in %s", envPath)
+ }
+ fmt.Printf("https://%s/admin#token=%s\n", host, tok)
+}
+
+// runDeploy: `lchat deploy <host>` (or --all) — restart the instance on the new
+// on-disk binary. SIGTERM drains it gracefully and the router retries dials across
+// the brief gap, so the swap drops no requests. Ship the new binary first (scp +
+// atomic mv over /opt/lchat/lchat); deploy then rolls it in. See
+// docs/instance-router.md.
+func runDeploy(args []string) {
+ fs := flag.NewFlagSet("deploy", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ unitTmpl := fs.String("unit", envOr("LCHAT_UNIT", "lchat@"), "systemd instance unit template prefix")
+ all := fs.Bool("all", false, "deploy every instance, one at a time")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ die("deploy: open registry: %v", err)
+ }
+
+ var hosts []string
+ if *all {
+ routes, err := reg.list()
+ if err != nil {
+ die("deploy: %v", err)
+ }
+ for _, r := range routes {
+ if r.Kind == "instance" {
+ hosts = append(hosts, r.Host)
+ }
+ }
+ } else {
+ h := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
+ if !validHost(h) {
+ die("deploy: invalid host %q (or pass --all)", h)
+ }
+ hosts = []string{h}
+ }
+ if len(hosts) == 0 {
+ die("deploy: no instances to deploy")
+ }
+
+ for _, host := range hosts {
+ rt, err := reg.get(host)
+ if err != nil {
+ die("deploy %s: %v", host, err)
+ }
+ if rt == nil {
+ die("deploy %s: unknown host (run add-instance first)", host)
+ }
+ if rt.Kind != "instance" {
+ die("deploy %s: not an instance (kind %q) — only messenger instances are deployed", host, rt.Kind)
+ }
+ if err := systemctl("restart", *unitTmpl+host); err != nil {
+ die("deploy %s: restart: %v", host, err)
+ }
+ // rt.Socket is what the router actually dials (stored at add-instance time),
+ // so health-check exactly that rather than recomputing a path.
+ if err := waitHealthy(rt.Socket, 20*time.Second); err != nil {
+ die("deploy %s: %v", host, err)
+ }
+ fmt.Printf("deployed %s\n", host)
+ }
+}
+
+// waitHealthy blocks until the instance answers an HTTP request on its socket, or
+// the timeout elapses. It retries, so it's robust to the restart window (socket
+// briefly absent, or a stale socket left by an abruptly-killed old process) — and
+// a broken new binary that never becomes healthy fails the deploy before `--all`
+// rolls it to the rest of the fleet.
+func waitHealthy(socket string, timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ client := &http.Client{
+ Timeout: 3 * time.Second,
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", socket)
+ },
+ },
+ }
+ var last error
+ for time.Now().Before(deadline) {
+ resp, err := client.Get("http://lchat/")
+ if err == nil {
+ resp.Body.Close()
+ if resp.StatusCode < 500 {
+ return nil
+ }
+ last = fmt.Errorf("unhealthy: HTTP %d", resp.StatusCode)
+ } else {
+ last = err
+ }
+ time.Sleep(150 * time.Millisecond)
+ }
+ return fmt.Errorf("instance not healthy within %s: %v", timeout, last)
+}
+
+// waitReachable blocks until the backend accepts a connection and returns any HTTP
+// response on its socket, or the timeout elapses. Unlike waitHealthy it accepts any
+// status and does not follow redirects — a sibling service's "/" may 3xx/4xx/5xx yet
+// still be up; only a failure to connect means "not up".
+func waitReachable(socket string, timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ client := &http.Client{
+ Timeout: 3 * time.Second,
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", socket)
+ },
+ },
+ }
+ var last error
+ for time.Now().Before(deadline) {
+ resp, err := client.Get("http://svc/")
+ if err == nil {
+ resp.Body.Close()
+ return nil // any HTTP response means the backend is listening
+ }
+ last = err
+ time.Sleep(150 * time.Millisecond)
+ }
+ return fmt.Errorf("backend not reachable within %s: %v", timeout, last)
+}
+
+// --- helpers ---------------------------------------------------------------
+
+func systemctl(args ...string) error {
+ out, err := exec.Command("systemctl", args...).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+// writeEnvFile writes KEY=value lines (sorted for a stable file) at 0600 — it
+// holds the admin token, so it's operator-secret, like the platform env it
+// replaces.
+func writeEnvFile(path string, kv map[string]string) error {
+ keys := make([]string, 0, len(kv))
+ for k := range kv {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ var b strings.Builder
+ for _, k := range keys {
+ fmt.Fprintf(&b, "%s=%s\n", k, kv[k])
+ }
+ return os.WriteFile(path, []byte(b.String()), 0o600)
+}
+
+// readEnvFile parses a KEY=value EnvironmentFile (blank lines and #comments
+// ignored).
+func readEnvFile(path string) (map[string]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ m := map[string]string{}
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ if k, v, ok := strings.Cut(line, "="); ok {
+ m[strings.TrimSpace(k)] = strings.TrimSpace(v)
+ }
+ }
+ return m, sc.Err()
+}
+
+// markInstanceFailed handles a partial add-instance: stop + disable the unit (so it
+// can't crash-loop) and record status=failed + last_error for `lchat instances`,
+// all best-effort. The env file and DB are KEPT — the env file is the only copy of
+// the admin token (the DB stores just its hash), so removing it could strand a
+// still-running instance with an unrecoverable token. add-instance overwrites a
+// non-ready route on retry, so the failed row doesn't block a re-run.
+func markInstanceFailed(reg *Registry, host, unit, reason string) {
+ _ = systemctl("disable", "--now", unit)
+ if r, err := reg.get(host); err == nil && r != nil {
+ r.Status = "failed"
+ r.LastError = reason
+ _ = reg.upsert(*r)
+ }
+}
diff --git a/control_test.go b/control_test.go
new file mode 100644
index 0000000..503ac0b
--- /dev/null
+++ b/control_test.go
@@ -0,0 +1,117 @@
+package main
+
+import (
+ "flag"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestFlagsAfterPositional(t *testing.T) {
+ fs := flag.NewFlagSet("t", flag.ContinueOnError)
+ socket := fs.String("socket", "", "")
+ all := fs.Bool("all", false, "")
+ // host BEFORE the flags — the natural order that the stdlib parser drops.
+ if err := fs.Parse(reorderFlagsFirst(fs, []string{"chat.example.com", "--socket", "/x.sock", "--all"})); err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ if *socket != "/x.sock" {
+ t.Errorf("socket = %q, want /x.sock", *socket)
+ }
+ if !*all {
+ t.Error("all = false, want true")
+ }
+ if fs.Arg(0) != "chat.example.com" {
+ t.Errorf("Arg(0) = %q, want chat.example.com", fs.Arg(0))
+ }
+}
+
+func TestReorderDoesNotPullFlagAsValue(t *testing.T) {
+ fs := flag.NewFlagSet("t", flag.ContinueOnError)
+ fs.String("socket", "", "")
+ fs.String("router-unit", "", "")
+ // --socket is given no value (next token is another flag); reorder must not
+ // swallow --router-unit as --socket's value.
+ out := reorderFlagsFirst(fs, []string{"host", "--socket", "--router-unit", "foo"})
+ if !strings.Contains(strings.Join(out, " "), "--router-unit foo") {
+ t.Errorf("reorder paired a flag as a value: %v", out)
+ }
+}
+
+func TestValidHost(t *testing.T) {
+ ok := []string{"chat.example.com", "rtr-test.luan.chat", "a.b", "x1.y2.example.com"}
+ bad := []string{"", "nodots", "-bad.example.com", "bad-.example.com", "has space.com", "Chat.Example.com", "a/b.com", "a..b"}
+ for _, h := range ok {
+ if !validHost(h) {
+ t.Errorf("validHost(%q) = false, want true", h)
+ }
+ }
+ for _, h := range bad {
+ if validHost(h) {
+ t.Errorf("validHost(%q) = true, want false", h)
+ }
+ }
+}
+
+func TestCheckSocketLen(t *testing.T) {
+ if err := checkSocketLen("/run/lchat/chat.example.com.sock"); err != nil {
+ t.Errorf("short path: unexpected error %v", err)
+ }
+ long := "/run/lchat/" + strings.Repeat("a", 120) + ".sock"
+ if err := checkSocketLen(long); err == nil {
+ t.Error("long path: expected error, got nil")
+ }
+}
+
+func TestEnvFileRoundTrip(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "chat.example.com.env")
+ in := map[string]string{"LCHAT_ADMIN_TOKEN": "tok-123", "LCHAT_HOME_ORIGIN": "https://home.example.com"}
+ if err := writeEnvFile(path, in); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ fi, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != 0o600 {
+ t.Errorf("perms = %v, want -rw-------", fi.Mode().Perm())
+ }
+ out, err := readEnvFile(path)
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ for k, v := range in {
+ if out[k] != v {
+ t.Errorf("readEnvFile[%q] = %q, want %q", k, out[k], v)
+ }
+ }
+}
+
+func TestRegistryGet(t *testing.T) {
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, err := reg.get("missing.example.com"); err != nil || got != nil {
+ t.Fatalf("get(missing) = %v, %v; want nil, nil", got, err)
+ }
+ if err := reg.upsert(Route{Host: "chat.example.com", Kind: "instance", Socket: "/run/lchat/chat.example.com.sock", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.get("chat.example.com")
+ if err != nil || got == nil {
+ t.Fatalf("get(existing) = %v, %v", got, err)
+ }
+ if got.Socket != "/run/lchat/chat.example.com.sock" || got.Status != "ready" || got.Kind != "instance" {
+ t.Errorf("get returned %+v", *got)
+ }
+
+ // delete stops routing the host.
+ if err := reg.delete("chat.example.com"); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if got, err := reg.get("chat.example.com"); err != nil || got != nil {
+ t.Fatalf("after delete, get = %v, %v; want nil, nil", got, err)
+ }
+}
diff --git a/deploy/README.md b/deploy/README.md
new file mode 100644
index 0000000..f3e19cd
--- /dev/null
+++ b/deploy/README.md
@@ -0,0 +1,53 @@
+# Single-machine bootstrap
+
+Running the messenger behind the `lchat` router on one box (see
+`docs/instance-router.md`). The Go side is a single static binary — no runtime.
+
+```sh
+# 1. copy the binary, then let the binary provision the box
+scp lchat root@<box>:/opt/lchat/lchat
+ssh root@<box> '/opt/lchat/lchat bootstrap' # writes units + dirs, starts the router
+
+# 2. add an instance (mints token + env file, starts it, registers + reloads)
+ssh root@<box> '/opt/lchat/lchat add-instance chat.example.com'
+```
+
+`lchat bootstrap` embeds the unit files (source `deploy/systemd/`) and writes
+them, creates `/data` + `/run/lchat`, and starts the router. To install by hand
+instead: `scp deploy/systemd/*.service` to `/etc/systemd/system/`, drop a
+`/etc/tmpfiles.d/lchat.conf` (`d /run/lchat 0710 root root -`), `daemon-reload`,
+`systemctl enable --now lchat-router`.
+
+- `lchat-router.service` — the front door (binds :80/:443, autocert HTTP-01).
+- `lchat@.service` — an instance template; runs one instance on
+ `/run/lchat/<host>.sock`, reading its token + config from `/data/<host>.env`.
+- `lchat-bot@.service` — the optional per-instance Node sidecar (see Bots).
+- Runs as root here so it can bind :80/:443; for a non-root user add
+ `AmbientCapabilities=CAP_NET_BIND_SERVICE` to the router unit and a dedicated
+ `User=`/`Group=` with matching ownership on `/run/lchat` + `/data`.
+
+## Bots (sidecars)
+
+The sidecar is **Node**, not the Go binary (it reuses `web/static/crypto.js` for
+E2E — that's why it's Node), so a box that runs bots needs a Node runtime and the
+`agent/` code + its deps:
+
+```sh
+# one-time per box: Node 24 + the sidecar source + deps
+ssh root@<box> 'curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && apt-get install -y nodejs'
+rsync -a agent web/static root@<box>:/opt/lchat/ # sidecar imports ../web/static/crypto.js
+ssh root@<box> 'cd /opt/lchat/agent && npm ci'
+
+# per instance: mint the three agent invites + start the sidecar
+# (API key via env, not a flag, so it doesn't leak into ps/shell history)
+ssh root@<box> 'ANTHROPIC_API_KEY=sk-ant-... /opt/lchat/lchat add-bot chat.example.com'
+```
+
+`add-bot` mints an agent invite per identity (Claude / Eleven / Announcer) via the
+instance's admin API — running `agent/mint-invite.mjs`, which reuses `crypto.js` so
+the derived invite matches what the sidecar provisions with — writes
+`/data/<host>-bot.env` (invites + session paths + optional `ANTHROPIC_API_KEY`),
+and starts `lchat-bot@<host>`. Omit `ANTHROPIC_API_KEY` for a deterministic-only bot
+(Eleven still welcomes new members + answers slash commands). Update a bot's code
+with `systemctl restart lchat-bot@<host>` (it backfills on reconnect). Start the
+**instance before** the sidecar so Eleven baselines the roster correctly.
diff --git a/deploy/systemd/lchat-bot@.service b/deploy/systemd/lchat-bot@.service
new file mode 100644
index 0000000..4d826c7
--- /dev/null
+++ b/deploy/systemd/lchat-bot@.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=lchat bot sidecar for %i
+After=network-online.target lchat@%i.service
+Wants=network-online.target
+
+[Service]
+# add-bot writes /data/%i-bot.env (0600) with LCHAT_BASE, the three agent invites,
+# session paths, and (optionally) ANTHROPIC_API_KEY.
+EnvironmentFile=-/data/%i-bot.env
+WorkingDirectory=/opt/lchat/agent
+# The sidecar is Node (not the static Go binary): the box needs a node runtime and
+# `npm ci` in /opt/lchat/agent (see deploy/README.md). It connects to its instance
+# over the public URL, backfills on reconnect, so a deploy is just a restart.
+ExecStart=/usr/bin/node /opt/lchat/agent/agent.mjs
+Restart=always
+RestartSec=2
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/systemd/lchat-router.service b/deploy/systemd/lchat-router.service
new file mode 100644
index 0000000..c8c5c28
--- /dev/null
+++ b/deploy/systemd/lchat-router.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=lchat router (TLS terminator + host router)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+ExecStart=/opt/lchat/lchat router --registry /data/registry.db --acme-cache /data/acme
+# add-instance triggers `systemctl reload lchat-router` to re-read the route table.
+ExecReload=/bin/kill -HUP $MAINPID
+Restart=always
+RestartSec=1
+
+[Install]
+WantedBy=multi-user.target
diff --git a/deploy/systemd/lchat@.service b/deploy/systemd/lchat@.service
new file mode 100644
index 0000000..068d5a0
--- /dev/null
+++ b/deploy/systemd/lchat@.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=lchat instance %i
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+# add-instance writes /data/<host>.env (0600) with LCHAT_ADMIN_TOKEN + per-instance
+# config. The leading '-' makes it optional so a self-provisioning instance still boots.
+EnvironmentFile=-/data/%i.env
+ExecStart=/opt/lchat/lchat --socket /run/lchat/%i.sock --db /data/%i.db
+Restart=always
+RestartSec=1
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/instance-migration.md b/docs/instance-migration.md
new file mode 100644
index 0000000..77d98bf
--- /dev/null
+++ b/docs/instance-migration.md
@@ -0,0 +1,108 @@
+# Migrating an instance from one server to another
+
+Status: **procedure** (validated once in practice). Moving a running messenger
+instance to a new box is **data movement + a DNS flip**, done so it's reversible:
+copy the DB, keep the old instance running as a fallback, and only cut DNS over
+once the new box is verified. The flip itself is reversible too.
+
+The key property: **keep the hostname the same**. Browser keys (and passkeys, for
+the home server) are per-origin, so an unchanged origin means members' keys,
+sessions, and history are all unaffected — only the DNS record moves.
+
+## Before you start
+
+- SSH to both the source and destination boxes.
+- The destination is already bootstrapped (`lchat bootstrap`; router running).
+- Know the instance's **admin token** and any per-instance config (see step 1).
+
+## Steps
+
+### 1. Recover the instance's config from the source
+
+The instance's DB stores only the **hash** of the admin token — the plaintext
+lives in the source's deploy env / secret store. Recover it, plus any per-instance
+config the instance reads (`LCHAT_PUSH_RELAY_URL`/`_TOKEN`, `LCHAT_HOME_ORIGIN`,
+`LCHAT_APP_STORE_URL`, APNs vars, …). You'll write these into the destination's
+EnvironmentFile in step 4. **Preserve the admin token** — minting a new one
+invalidates every saved admin link.
+
+### 2. Snapshot the DB consistently
+
+Use SQLite's `VACUUM INTO` — it produces a single, compacted, transaction-consistent
+file and is **safe on a live DB** (no need to stop the instance):
+
+```sh
+sqlite3 /path/to/lchat.db "VACUUM INTO '/tmp/snapshot.db'"
+sqlite3 /tmp/snapshot.db "PRAGMA integrity_check" # expect: ok
+```
+
+If the source hides the DB behind an opaque store (many identically-named DB files),
+identify the right one by **content** — e.g. grep the files for the instance's
+`admin_token_hash` (`hex(sha256(admin token))`), which is unique per instance.
+
+### 3. Copy the snapshot to the destination
+
+Copy `/tmp/snapshot.db` to the destination as `/data/<host>.db` and verify it
+landed intact (`integrity_check` + a couple of row counts against the source).
+
+### 4. Import on the destination (preserve token + config)
+
+This is an **import**, not a fresh `add-instance` (which would mint a *new* token
+and a *new* DB). With the DB already in place:
+
+- write `/data/<host>.env` (mode `0600`) with the **existing** `LCHAT_ADMIN_TOKEN`
+ + the recovered config vars;
+- register the route (`routes` row: host, kind `instance`, socket, db_path,
+ status `ready`);
+- `systemctl enable --now lchat@<host>` and reload the router.
+
+Verify **before any DNS change**, over the instance's unix socket: `GET /` → 200,
+an admin-token request → 200, and the row counts match the source.
+
+> Tooling gap worth closing: `add-instance` mints fresh; there's no first-class
+> "import existing DB + token" command yet, so step 4 is done by hand. A `--admin-token`
+> flag (or an `import-instance` command) would make this one command.
+
+### 5. Gap check
+
+Confirm nothing was written to the source between the snapshot and the cutover
+(compare a row count, e.g. `messages`, source-now vs snapshot). For a busy
+instance, freeze writes or take the snapshot at cutover time — writes that land on
+the source after the snapshot are **not** on the destination.
+
+### 6. Cut over DNS
+
+Flip the host's A record to the destination's IP. **Lower the record's TTL ahead of
+time** (to ~60s, ideally a TTL-worth before the cutover) so the flip propagates
+fast; otherwise resolvers that cached the old IP keep hitting the old box until the
+old TTL expires (harmless while the old box is still up as a fallback).
+
+The TLS cert **can't be pre-issued**: autocert's HTTP-01 validates by resolving the
+host over public DNS, so the cert only issues once DNS points at the destination.
+Authoritative DNS updates in seconds (only resolver *caches* lag), so the cert
+issues on the first HTTPS hit right after the flip.
+
+### 7. Verify + keep the fallback
+
+`GET https://<host>/` → 200 with a valid (Let's Encrypt) cert, serving the migrated
+data. **Leave the source instance running** until you're confident — the migration
+is a copy, so flipping DNS back is an instant rollback. Retire the source only
+afterward.
+
+## Bots (sidecars)
+
+The instance's **bot agent-users migrate with the DB** (they're rows), but the
+**sidecar** — the Node process and its session files (the bots' identities) — is
+separate and does *not* come across in the DB copy. Do **not** re-run `add-bot` on
+the destination: that mints a *new* set of agent identities (duplicate bots in the
+Helpers catalog). Instead either copy the sidecar's session files to the
+destination and start `lchat-bot@<host>` there, or point the existing sidecar at
+the destination (it follows the host over DNS and reconnects with its persisted
+token). Plan the bot move as its own step.
+
+## What makes this safe
+
+- **Reversible**: the source is copied, never moved; DNS flip is reversible; the
+ cert issues automatically post-flip.
+- **No key churn**: same origin ⇒ per-origin browser keys/passkeys untouched.
+- **Consistent snapshot**: `VACUUM INTO` captures a clean point-in-time DB.
diff --git a/docs/instance-router.md b/docs/instance-router.md
new file mode 100644
index 0000000..3683a81
--- /dev/null
+++ b/docs/instance-router.md
@@ -0,0 +1,418 @@
+# The instance router — a single-machine bootstrap
+
+Status: **proposal for discussion** (no code yet). This is the design note; when
+it ships the code becomes the source of truth.
+
+## The problem
+
+The messenger is a single static, no-cgo binary with SQLite compiled in, and it
+runs as **one fully-isolated instance per chat** — each with its own disk, admin
+token, and subdomain. The obvious way to run several of those on one machine is
+to reach for an orchestrator (Kamal, a PaaS, Kubernetes): it gives you TLS,
+subdomain routing, a per-app disk, env injection, and process supervision.
+
+But that's a lot of machinery around a single file you could `scp`. This doc
+describes a lighter alternative for the common case — many instances on one
+machine — that keeps the two properties process-per-instance gives you and that
+are worth keeping:
+
+- **Per-instance versioning / canary.** Deploy one instance on a new build while
+ the others stay on the old one. A single shared process can't do that.
+- **Isolation.** A crash or a resource hog in one instance must not touch the
+ others.
+
+…and adds **zero-downtime deploys**. Those reinforce each other: zero-downtime
+*means* "run the new version alongside the old and hand over," so per-instance
+versioning falls out of doing it right.
+
+No container platform required — just the binary, a small router, and systemd.
+
+## One binary, many ways to deploy
+
+The messenger makes no assumptions about how it's run. Put it behind nginx or
+Caddy, on a PaaS, in Kubernetes, under Kamal, or one-container-per-instance on
+any platform — all of those work, and nothing here precludes them. The instance
+binary stands alone.
+
+**What this doc describes is a batteries-included path for the common case:**
+bootstrapping a single machine from zero to a running, auto-TLS'd, multi-instance
+box with zero-downtime deploys and no external orchestrator. It's the *simplest*
+way, not the *only* way — the router below is optional convenience.
+
+## The shape
+
+```
+ :443 / :80
+ internet ──▶ lchat router ──(reverse proxy over unix sockets)──▶ lchat instance (chat-a) ── chat-a.db
+ (autocert HTTP-01, lchat instance (chat-b) ── chat-b.db
+ HostPolicy = route table, lchat instance (chat-c) ── chat-c.db
+ atomic upstream map) …
+ │ home server (home/) ── home.db ┐ service
+ │ push relay (push/) ── push.db ┘ routes
+ └── control API on a localhost-only socket (add / deploy / list)
+
+ systemd supervises everything: router + lchat@<host> (instance) + lchat-bot@<host> (sidecar) + home/push units
+```
+
+One binary, three roles (subcommands):
+
+- `lchat router` — the front door (TLS + routing + control plane).
+- `lchat instance` — one messenger instance (today's `main()` minus the TCP
+ listener and minus the process-global env reads).
+- control subcommands — `add-instance`, `deploy`, `instances`, `admin-link`.
+
+## The router
+
+- **`:443`** — TLS via `golang.org/x/crypto/acme/autocert` (stdlib-adjacent, no
+ DNS-API credentials). `HostPolicy` returns nil only for hosts in the route
+ table, so the route list *is* the cert allowlist. `Cache: autocert.DirCache` on
+ the persistent disk. HTTP-01 (well-known) is the challenge; a brand-new host is
+ issued on first hit — no wildcard, no DNS-provider step.
+- **`:80`** — `m.HTTPHandler(nil)`: serves `/.well-known/acme-challenge/…`,
+ 301-redirects everything else to https.
+- **Routing** — the route table maps **host → backend** (a unix socket at a
+ version); `httputil.ReverseProxy` dials it. The upstream map is an
+ `atomic.Pointer` so reloads and deploys swap it without dropping a connection.
+ WebSocket upgrades pass through (Go ≥1.12). A backend is usually one instance —
+ see *Backends*, below.
+- **Control API** — bound to a **localhost-only** unix socket, superadmin-gated;
+ never exposed on a served hostname. This is the god surface (it can create
+ instances and read admin links), so it stays off the public interface.
+
+Threat model is unchanged: TLS terminates at the router, but message bodies are
+already E2E ciphertext — the router sees exactly what the app sees, never
+plaintext.
+
+### Optional: wildcard TLS (DNS-01)
+
+HTTP-01 issues one cert per host on first hit — simple, needs no DNS credentials,
+but needs inbound `:80` and can't cover a host before it's requested. When that's
+not enough the cert source is **pluggable**: point the router at a DNS provider and
+it obtains a **wildcard** (`*.example.com`) over DNS-01 instead, covering every
+subdomain instantly with no port-80 dependency. The two can coexist — a wildcard
+for the base domain, HTTP-01 on demand for custom domains outside it. `HostPolicy`
+still gates which hosts are actually served; the wildcard only changes how the
+cert is obtained. Off by default (no DNS creds required); enable it by configuring
+a provider + credentials.
+
+## The instance process
+
+`lchat instance --db /data/<host>.db --socket /run/lchat/<host>.sock --config <file>`
+
+It's today's server with two changes: it listens on a **unix socket** instead of
+a TCP port, and it takes per-instance config from flags/a file the router writes
+rather than from process-global env (see below). Everything else is unchanged —
+its own SQLite (WAL), its own admin token (`ensureAdminToken`, in the DB), its own
+VAPID (`ensureVAPID`, in the DB). `handler()` is untouched, so `newTestServer`
+and the existing handler tests keep working as-is.
+
+## Backends: one per instance (default), one per version (option)
+
+The router maps **host → backend** (a socket at a version) and doesn't care how
+many hosts a backend serves. That leaves two ways to slice processes:
+
+**One backend per instance (the default).** Each instance is its own process,
+socket, and DB — full crash and resource isolation, and per-version deploys come
+*for free*: point one instance's unit at a new build and leave the rest on the old
+one (a `trunk` instance next to `stable` ones) with no coordination. Canary is
+just "which binary does this unit run."
+
+**One backend per version (an option, later).** Alternatively a single process
+serves *many* hosts at one version (an in-process host map, one DB per host), and
+the router sends each host to the process running its assigned version:
+
+```
+ stable-host ─┐
+ other-host ─┼─▶ backend @ stable (one process, many DBs)
+ another-host ─┘
+ trunk-host ──▶ backend @ trunk (one process)
+```
+
+This trades isolation for fewer processes — fewer Go runtimes (less RAM), fewer
+deploy targets — and the costs are real: a crash or OOM takes down *every* host on
+that version, there's no per-host resource cap, and deploying a version restarts
+all its hosts together (one big reconnect, not a staggered roll). Moving a host
+between versions also needs a clean SQLite hand-off (two processes must never write
+one DB file), so it suits hosts that live permanently on a channel (a dedicated
+trunk instance) more than ad-hoc per-instance canaries.
+
+**Recommendation:** default to one-backend-per-instance — isolation is cheap to
+keep, per-version is already free, and a dozen processes cost a few hundred MB
+(RAM isn't the binding resource; disk is). Keep the route table as host → backend
+so consolidating onto per-version backends stays a config change, not a rewrite,
+and reach for it only if process count ever actually bites. It needs the
+in-process host map, so it's the *second* thing to build, not the first.
+
+## Beyond messenger instances: sibling services
+
+A deployment usually runs sibling services next to the messenger that want the
+same TLS + host routing — for example the other two binaries in this repo:
+
+- **the home server (`home/`)** — the passkey-backup store, its own binary + DB.
+ Its host *is* the WebAuthn RP origin, so it must keep the same hostname across
+ any move (only the DNS record changes) or it orphans every passkey.
+- **the push relay (`push/`)** — a shared APNs relay that lets instances (yours
+ *and* third parties') send iOS push without holding Apple's signing key (see
+ `push-relay.md`).
+
+To the router these are just another **host → upstream** mapping: a `service`-kind
+route to a local unix socket, TLS via autocert, no lifecycle management (they
+deploy on their own cadence). That's the only difference from a messenger
+instance, which the router *also* create/deploy/version-manages.
+
+Because a route is nothing but host → upstream, **anyone running their own home
+server or push relay is first-class** — point a host at their backend the same
+way. This is deliberately the simple version (a flat mapping + a health check); it
+can be hardened — readiness gating, retries, per-service resource isolation —
+later, if it earns it.
+
+## Bots (sidecars)
+
+An instance can have a **sidecar** hosting the default agents (see `agent/`). It's
+a **Node** process, not part of the Go binary, and it connects to its instance as
+a **WebSocket client** over the instance's public host — authenticating with a
+one-time agent invite and persisting its session to disk. It serves no inbound
+traffic, so it never needs a socket or a cert, and the router never routes *to* it.
+
+**Why a separate process, and why Node?** Two different reasons. It's a *separate
+process* because it's a full E2E client — it holds its own keypair, and that
+private key must never live in the server process (the core rule), so a bot is a
+member that runs on a server, not a server feature (a separate *Go* process would
+satisfy this too). It's *Node* specifically because it imports `web/static/
+crypto.js` directly — the same crypto the browser runs, on Node's WebCrypto — so
+its ciphertext can't drift from what clients expect. A Go rewrite would keep the
+separate-process property but give up that shared implementation: you'd
+re-implement crypto.js's exact wire format and keep it byte-compatible forever
+(correctness-critical, silent on failure). Cheaper ways to drop the Node install:
+bundle the sidecar into one executable (Node SEA), or run it off-box.
+
+That makes it the simplest thing to supervise: **one `lchat-bot@<host>.service`
+per instance**, restarted on deploy. Because the sidecar backfills on reconnect, a
+bot deploy is a plain restart — no socket-swap, no drain. Two notes:
+
+- The sidecar is Node, so its host needs a **Node runtime** (the Go server is a
+ static binary; the bot is not); its `agent/` deps install with `npm`.
+- It reaches its instance at the public URL through the router — a normal client
+ connection that happens to originate on the same box, so no special wiring.
+
+Its secrets (an API key, the persisted agent sessions) live in its own env + disk,
+the same way the messenger's admin token lives in the instance DB.
+
+## The registry (a small control DB)
+
+The router owns one SQLite table. Status lives here (not a flat file) so an
+add-in-flight survives a router restart, and it's where per-instance config that
+used to be env now lives:
+
+```sql
+CREATE TABLE routes (
+ host TEXT PRIMARY KEY,
+ kind TEXT NOT NULL, -- 'instance' (messenger, lifecycle-managed) | 'service' (home, push, …)
+ socket TEXT NOT NULL, -- unix socket the router proxies to
+ status TEXT NOT NULL, -- pending|creating|starting|issuing|ready|failed
+ last_error TEXT,
+ -- instance-only fields (null for registered services):
+ db_path TEXT,
+ version TEXT, -- running binary version (per-instance skew is normal)
+ app_store_url TEXT, -- was LCHAT_APP_STORE_URL
+ home_origin TEXT, -- was LCHAT_HOME_ORIGIN
+ push_relay_url TEXT, -- was LCHAT_PUSH_RELAY_URL
+ push_relay_token TEXT, -- was LCHAT_PUSH_RELAY_TOKEN (per-instance bearer)
+ created_at INTEGER
+);
+```
+
+### Per-instance config: a per-instance EnvironmentFile
+
+Config stays env-shaped — the instance reads the same `LCHAT_*` vars it always
+has — but instead of process-global env it comes from a per-instance systemd
+**EnvironmentFile**, `/data/<host>.env` (mode `0600`), written by `add-instance`.
+No instance code changes: `EnvironmentFile=-/data/%i.env` in the unit, and the
+existing `os.Getenv` calls just work.
+
+| Var | Source |
+|---|---|
+| `LCHAT_ADMIN_TOKEN` | `/data/<host>.env` — the DB stores only its *hash*, so this file is the one recoverable copy (`admin-link` reads it) |
+| VAPID keypair | the instance DB (`ensureVAPID`) — self-generated, no config needed |
+| `LCHAT_APP_STORE_URL`, `LCHAT_HOME_ORIGIN`, `LCHAT_PUSH_RELAY_URL`/`_TOKEN` | `/data/<host>.env` (from `add-instance` flags) |
+| `LCHAT_DB` / socket path | derived from `<host>` by the unit |
+| APNs `.p8` | **not on instances at all** — the push relay holds it (see `push-relay.md`) |
+
+The `routes` table tracks routing + status; the EnvironmentFile is the source of
+per-instance config and the admin token. The push-relay work already did the
+hardest part — the one secret that can't sit safely in a per-host file (the Apple
+signer) is out of instances entirely, so each carries only a revocable bearer.
+
+## Provisioning the box
+
+Because the server is a static, no-cgo binary, a fresh box needs **no build
+toolchain and no language runtime** for the messenger, the router, or the
+home/push services — provisioning is just "copy binaries + unit files + make
+directories":
+
+1. `scp` the `lchat` binary (plus `home`/`push` and `litestream`) to the box.
+2. `lchat bootstrap` — writes the systemd units (router + `@` templates), creates
+ `/data`, `/run/lchat`, and the autocert cache dir, sets `LimitNOFILE`, then
+ enables and starts the router.
+3. Open inbound `:80` / `:443`.
+
+That's the whole dependency story for the Go side — nothing to `apt install`, no
+version to keep patched, no runtime to manage. A bare box with *no* Go on it is
+not a concern; it's the point.
+
+**The one runtime dependency is Node**, and only on a box that runs bot sidecars —
+the sidecar (`agent/`) is Node, not a static binary. Install a pinned Node and
+`npm ci` in `agent/`. If you'd rather keep the box to static binaries only, the
+sidecar can be bundled into a single executable (Node SEA / `pkg`) or run
+**off-box** entirely — it's just a WebSocket client to the public URL, so it need
+not live on the router box at all. For the common case, installing Node is the
+pragmatic choice.
+
+## add-instance
+
+`lchat add-instance <host>` runs the steps an operator would otherwise do by hand:
+
+```
+mint token → write /data/<host>.env (0600, the unit's EnvironmentFile)
+ → register route (status: creating)
+ → systemctl enable --now lchat@<host> → wait for the socket
+ → status: ready → systemctl reload lchat-router → print the admin link
+```
+
+The route's `status` (`creating`/`ready`/`failed` + `last_error`) is recorded in
+the registry and shown by `lchat instances`. The cert issues lazily on the host's
+first HTTPS hit (proven fine in practice); pre-warming it
+(`m.GetCertificate(&tls.ClientHelloInfo{ServerName: host})`) and a fully async,
+retrying state machine are refinements the schema already allows.
+`lchat admin-link <host>` recovers the admin link from the EnvironmentFile (the DB
+holds only the token's hash).
+
+## Zero-downtime deploy (per instance)
+
+`lchat deploy <host>` (or `--all`) rolls an instance onto the new on-disk binary
+with no dropped requests — via **graceful drain + a retrying router**, rather than
+a dual-socket blue/green. (Simpler, and enough: clients already auto-reconnect, so
+the marginal gain of true zero-latency didn't justify alternating dual units and
+two processes sharing one SQLite DB.)
+
+1. Ship the new binary (`scp` + atomic `mv` over `/opt/lchat/lchat`).
+2. `systemctl restart lchat@<host>`. On **SIGTERM** the instance drains in-flight
+ HTTP (`http.Server.Shutdown`) and closes WebSockets (so browsers reconnect),
+ then exits; systemd starts the new process, which rebinds the socket.
+3. Across the brief gap (old exited, new not yet bound → dials fail
+ `ENOENT`/`ECONNREFUSED`) the **router retries** the dial for a short window, so
+ requests queue a beat instead of failing.
+4. `deploy` waits for the new process to answer an HTTP health check (retrying —
+ catches a stale socket or a broken binary) before reporting success; `--all`
+ stops on the first failure.
+
+Blast radius is **one instance**: its WebSocket clients reconnect once; HTTP
+requests see at most a little added latency. Canary = deploy one instance alone.
+Verified in practice: 120 requests against an instance during a live deploy all
+returned 200.
+
+The router itself changes rarely; when it does, its own front listener would get
+zero-downtime via `SO_REUSEPORT` / systemd socket activation (not yet wired — a
+router deploy is currently a ~1s blip for all hosts).
+
+The router itself changes rarely; when it does, its own front listener gets
+zero-downtime via `SO_REUSEPORT` / systemd socket activation.
+
+## Supervision (systemd)
+
+- `lchat-router.service` — `Restart=always`, socket-activated (its own
+ zero-downtime).
+- `lchat@.service` — a template unit, one per instance. `MemoryMax=` + `CPUWeight=`
+ give resource isolation; `Restart=always` gives crash isolation. Bump
+ `LimitNOFILE` (instances × WS connections × SQLite files).
+- `lchat-bot@.service` — the optional per-instance sidecar (Node; see *Bots*).
+ `Restart=always`; a deploy is just a restart (it backfills on reconnect).
+
+## Backups
+
+Collapsing onto one box means every instance's data is on one disk — a single
+failure loses everything, and there's no platform to restore from.
+
+- **Litestream per instance DB** → object storage, point-in-time restore.
+- **Back up the autocert cache dir** too — it holds issued certs *and their
+ private keys*. Treat as key material (`0600`); losing it forces re-issue.
+- Encrypted attachments are stored as blobs **inside** SQLite (the `blobs` table),
+ so the DB backup covers them — but they're also what drives disk growth (see
+ sizing).
+
+## Sizing
+
+Server-side memory is light **by design**: crypto and image downscaling happen in
+the browser; the server only moves ciphertext. A modest box goes a long way.
+
+- **RAM:** each instance is ~20–40 MB idle (Go runtime + modernc SQLite + embedded
+ assets); the router ~20 MB. A dozen instances is a few hundred MB. Set
+ `MemoryMax` per instance so one can't OOM the box.
+- **CPU:** no server-side crypto or image work — just HTTP, SQLite, WebSocket
+ fan-out, and TLS termination. Two vCPUs handle a small fleet comfortably.
+- **Disk is the binding resource, not RAM.** Attachments are blobs in SQLite, so
+ DBs grow with media. Watch disk, and let Litestream offload history. If you
+ outgrow anything first, it'll be disk.
+
+## Migrating an existing deployment onto the box
+
+Moving an instance from wherever it runs now onto this setup is data movement plus
+a DNS flip:
+
+1. **Snapshot the DB cleanly.** `VACUUM INTO` a fresh copy (folds the WAL into one
+ consistent file); briefly freeze writes for a clean cut.
+2. **Copy it over** to the box as `/data/<host>.db`.
+3. **Register it** (`add-instance <host>` against the copied DB), which warms the
+ cert.
+4. **Flip DNS** for the host to the box's IP; verify; retire the old deployment.
+
+Two things make this safe:
+
+- **Keep the hostname the same and there's no key work.** Browser keys (and
+ passkeys, for the home server) are per-origin, so an unchanged origin leaves
+ members' keys and history untouched — only the DNS record moves.
+- **A host on a domain not covered by a wildcard cert** simply exercises the
+ on-demand HTTP-01 path — no special handling.
+
+**Rehearse the whole flow with a throwaway instance first** — under a temp host,
+run the router, `add-instance`, cert issuance, a socket-swap deploy, a
+crash-restart, and a Litestream restore, all at zero real-user risk before
+touching anything real.
+
+## What you're trading away (honest costs)
+
+1. **The router is a new SPOF** for all instances. But it's tiny, holds no
+ per-request state, is `Restart=always` (sub-second recovery), and is smaller
+ and more stable than a single-process design's whole-app SPOF. Its own deploys
+ are socket-activated.
+2. **You own backups + supervision now.** Litestream + systemd cover what a
+ platform did, but it's your operational surface — it has to actually be set up
+ and monitored.
+3. **One box.** Multi-node failover is exactly where an orchestrator earns its
+ keep; this is deliberately single-box. Revisit if that changes.
+4. **Migration is a real one-time project** — snapshot DBs, flip DNS, retire the
+ old deployment, per instance.
+
+## Testing
+
+- Router: host→socket routing, unknown-host → 404, `HostPolicy` allowlist, atomic
+ upstream swap under concurrent load, WebSocket pass-through.
+- `add-instance` state machine: creating / issuing / failed+retry transitions.
+- Instance: unchanged — `handler()` tests via `newTestServer` still exercise the
+ real router.
+- Deploy: a socket swap drains without dropping an in-flight request (integration).
+
+## Deploy tooling (replaces deploy-instance.sh)
+
+- `lchat bootstrap` — one-time box setup: write systemd units, make dirs, start
+ the router (no toolchain needed — just the copied binary).
+- `lchat add-instance <host>` — create + start + warm cert (async; `instances`
+ shows status).
+- `lchat deploy <host> [--version …]` — zero-downtime socket swap.
+- `lchat deploy --all` — roll the fleet, one instance at a time.
+- `lchat admin-link <host>` — recover the admin link from the instance DB (no
+ platform env lookup needed).
+- `lchat instances` — the status table.
+
+Operator specifics — box addresses, DNS/record ids, the concrete migration
+runbook — live in `CLAUDE.local.md`, not here.
diff --git a/go.mod b/go.mod
index ac49e79..dfdda43 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coder/websocket v1.8.12
github.com/go-webauthn/webauthn v0.11.2
+ golang.org/x/crypto v0.31.0
modernc.org/sqlite v1.34.4
)
@@ -22,8 +23,9 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/x448/float16 v0.8.4 // indirect
- golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.28.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
diff --git a/go.sum b/go.sum
index 4fa1263..e5b4bb5 100644
--- a/go.sum
+++ b/go.sum
@@ -58,6 +58,7 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -97,6 +98,7 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
diff --git a/hub.go b/hub.go
index c88b37a..a3818a9 100644
--- a/hub.go
+++ b/hub.go
@@ -37,6 +37,7 @@ type Hub struct {
register chan *client
unregister chan *client
broadcast chan outbound
+ control chan func() // runs fn on the hub goroutine (safe map access)
clients map[*client]bool
}
@@ -45,6 +46,7 @@ func newHub() *Hub {
register: make(chan *client),
unregister: make(chan *client),
broadcast: make(chan outbound, 256),
+ control: make(chan func()),
clients: make(map[*client]bool),
}
}
@@ -52,6 +54,8 @@ func newHub() *Hub {
func (h *Hub) run() {
for {
select {
+ case fn := <-h.control:
+ fn()
case c := <-h.register:
h.clients[c] = true
case c := <-h.unregister:
@@ -76,6 +80,23 @@ func (h *Hub) run() {
}
}
+// shutdown closes every connected client so their browsers reconnect — to the
+// new process, after a zero-downtime deploy (see docs/instance-router.md). It
+// runs the close on the hub goroutine so it can't race broadcast/register. The
+// closed send ends each client's writer, which closes the socket; the client's
+// read loop then unregisters (a no-op, already removed).
+func (h *Hub) shutdown() {
+ done := make(chan struct{})
+ h.control <- func() {
+ for c := range h.clients {
+ delete(h.clients, c)
+ close(c.send)
+ }
+ close(done)
+ }
+ <-done
+}
+
// broadcastToThread fans data out to the members of threadID only.
func (s *Server) broadcastToThread(threadID string, data []byte) {
members, err := s.db.memberUserIDs(threadID)
diff --git a/hub_test.go b/hub_test.go
new file mode 100644
index 0000000..9a68fb3
--- /dev/null
+++ b/hub_test.go
@@ -0,0 +1,21 @@
+package main
+
+import "testing"
+
+// TestHubShutdown checks that shutdown() closes every connected client's send
+// channel (which ends its writer and closes the socket, so browsers reconnect).
+func TestHubShutdown(t *testing.T) {
+ h := newHub()
+ go h.run()
+ c := &client{send: make(chan []byte, 1)}
+ h.register <- c
+ h.shutdown()
+ select {
+ case _, ok := <-c.send:
+ if ok {
+ t.Fatal("send channel should be closed after shutdown")
+ }
+ default:
+ t.Fatal("send channel not closed after shutdown")
+ }
+}
diff --git a/main.go b/main.go
index dc2cf13..06d6891 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "context"
"crypto/rand"
"crypto/sha256"
"database/sql"
@@ -13,12 +14,15 @@ import (
"io"
"io/fs"
"log"
+ "net"
"net/http"
"net/url"
"os"
+ "os/signal"
"strconv"
"strings"
"sync"
+ "syscall"
"time"
"lchat/internal/apns"
@@ -73,7 +77,44 @@ type Server struct {
}
func main() {
+ // Subcommands: `lchat router` runs the multi-instance front door and the
+ // add-instance/instances/admin-link commands manage it; anything else (bare
+ // `lchat` plus all existing flags) runs a single instance, so existing
+ // invocations are unchanged. See docs/instance-router.md.
+ if len(os.Args) > 1 {
+ switch os.Args[1] {
+ case "router":
+ runRouter(os.Args[2:])
+ return
+ case "bootstrap":
+ runBootstrap(os.Args[2:])
+ return
+ case "add-instance":
+ runAddInstance(os.Args[2:])
+ return
+ case "deploy":
+ runDeploy(os.Args[2:])
+ return
+ case "add-service":
+ runAddService(os.Args[2:])
+ return
+ case "add-bot":
+ runAddBot(os.Args[2:])
+ return
+ case "remove":
+ runRemove(os.Args[2:])
+ return
+ case "instances":
+ runInstances(os.Args[2:])
+ return
+ case "admin-link":
+ runAdminLink(os.Args[2:])
+ return
+ }
+ }
+
addr := flag.String("addr", envOr("ADDR", ":1747"), "listen address (env: ADDR)")
+ socket := flag.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr (env: LCHAT_SOCKET); how the router fronts an instance")
dbPath := flag.String("db", envOr("LCHAT_DB", "lchat.db"), "path to SQLite database file (env: LCHAT_DB)")
resetAdmin := flag.Bool("reset-admin", false, "generate a new admin token and print its link")
resetDB := flag.Bool("reset-db", false, "delete and recreate the database")
@@ -133,10 +174,64 @@ func main() {
// call sweepDueScheduled directly, so only the real server runs the ticker.
go s.runScheduler()
- log.Printf("lchat listening on %s (db: %s)", *addr, *dbPath)
- if err := http.ListenAndServe(*addr, s.handler()); err != nil {
- log.Fatal(err)
+ ln, where, err := listen(*addr, *socket)
+ if err != nil {
+ log.Fatalf("listen: %v", err)
+ }
+ log.Printf("lchat listening on %s (db: %s)", where, *dbPath)
+
+ srv := &http.Server{Handler: s.handler()}
+ go func() {
+ if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("serve: %v", err)
+ }
+ }()
+
+ // Graceful shutdown on SIGTERM/SIGINT (systemd stop/restart during a deploy):
+ // stop accepting + drain in-flight HTTP, then close WebSockets so clients
+ // reconnect to the new process. The router retries dials across the brief gap,
+ // so no request is dropped. See docs/instance-router.md.
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
+ <-sigCh
+ log.Printf("lchat: draining and shutting down")
+ // Drain in-flight HTTP (e.g. a large upload) before exiting. Kept below the
+ // router's dial-retry window so the socket-absent gap during a deploy stays
+ // within what the router masks.
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _ = srv.Shutdown(ctx)
+ hub.shutdown()
+}
+
+// listen returns the instance's listener. With -socket set it serves on a unix
+// domain socket (how the router fronts an instance — see docs/instance-router.md);
+// otherwise it's a TCP listener on addr (the standalone default, unchanged). A
+// stale socket left by a crashed process is removed first (but a socket another
+// process is still serving on refuses to start), and the socket is chmod 0600 so
+// only the router (same user) can dial it.
+func listen(addr, socketPath string) (net.Listener, string, error) {
+ if socketPath == "" {
+ ln, err := net.Listen("tcp", addr)
+ return ln, addr, err
+ }
+ // Don't clobber a live socket: only a stale one (nothing listening) is removed.
+ if c, err := net.DialTimeout("unix", socketPath, 200*time.Millisecond); err == nil {
+ c.Close()
+ return nil, "", fmt.Errorf("socket %s is already in use by another process", socketPath)
+ }
+ if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
+ return nil, "", fmt.Errorf("remove stale socket %s: %w", socketPath, err)
+ }
+ ln, err := net.Listen("unix", socketPath)
+ if err != nil {
+ return nil, "", err
+ }
+ if err := os.Chmod(socketPath, 0o600); err != nil {
+ ln.Close()
+ return nil, "", fmt.Errorf("chmod socket %s: %w", socketPath, err)
}
+ return ln, "unix:" + socketPath, nil
}
// handler wires up all routes. It's a method so tests can exercise the full
diff --git a/registry.go b/registry.go
new file mode 100644
index 0000000..9339d86
--- /dev/null
+++ b/registry.go
@@ -0,0 +1,149 @@
+package main
+
+import (
+ "database/sql"
+ "time"
+)
+
+// Registry is the router's control DB — the set of hosts it serves and how to
+// reach each. It's deliberately separate from an instance's own SQLite: it holds
+// routing + operator config, never message data. See docs/instance-router.md.
+type Registry struct{ sql *sql.DB }
+
+// Route is one host the router fronts: a messenger instance it lifecycle-manages
+// (kind "instance") or a sibling service it only proxies (kind "service", e.g.
+// the home server or push relay). The instance-only fields hold per-instance
+// config that used to live in process env.
+type Route struct {
+ Host string
+ Kind string
+ Socket string
+ Status string
+ LastError string
+ DBPath string
+ Version string
+ AppStoreURL string
+ HomeOrigin string
+ PushRelayURL string
+ PushRelayToken string
+ CreatedAt int64
+}
+
+func openRegistry(path string) (*Registry, error) {
+ d, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ // One writer at a time keeps WAL happy (mirrors openDB).
+ d.SetMaxOpenConns(1)
+ for _, p := range []string{
+ "PRAGMA journal_mode=WAL",
+ "PRAGMA busy_timeout=5000",
+ "PRAGMA synchronous=NORMAL",
+ } {
+ if _, err := d.Exec(p); err != nil {
+ return nil, err
+ }
+ }
+ r := &Registry{sql: d}
+ if err := r.migrate(); err != nil {
+ return nil, err
+ }
+ return r, nil
+}
+
+// migrate is additive (CREATE TABLE IF NOT EXISTS), like the instance schema, so
+// deploying new code over an existing registry is safe.
+func (r *Registry) migrate() error {
+ _, err := r.sql.Exec(`
+CREATE TABLE IF NOT EXISTS routes (
+ host TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ socket TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'ready',
+ last_error TEXT NOT NULL DEFAULT '',
+ db_path TEXT NOT NULL DEFAULT '',
+ version TEXT NOT NULL DEFAULT '',
+ app_store_url TEXT NOT NULL DEFAULT '',
+ home_origin TEXT NOT NULL DEFAULT '',
+ push_relay_url TEXT NOT NULL DEFAULT '',
+ push_relay_token TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL
+);`)
+ return err
+}
+
+// list returns every route, ordered by host.
+func (r *Registry) list() ([]Route, error) {
+ rows, err := r.sql.Query(`
+SELECT host, kind, socket, status, last_error, db_path, version,
+ app_store_url, home_origin, push_relay_url, push_relay_token, created_at
+FROM routes ORDER BY host`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Route
+ for rows.Next() {
+ var t Route
+ if err := rows.Scan(&t.Host, &t.Kind, &t.Socket, &t.Status, &t.LastError,
+ &t.DBPath, &t.Version, &t.AppStoreURL, &t.HomeOrigin,
+ &t.PushRelayURL, &t.PushRelayToken, &t.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+// get returns the route for host, or (nil, nil) if there is none.
+func (r *Registry) get(host string) (*Route, error) {
+ var t Route
+ err := r.sql.QueryRow(`
+SELECT host, kind, socket, status, last_error, db_path, version,
+ app_store_url, home_origin, push_relay_url, push_relay_token, created_at
+FROM routes WHERE host = ?`, host).Scan(&t.Host, &t.Kind, &t.Socket, &t.Status,
+ &t.LastError, &t.DBPath, &t.Version, &t.AppStoreURL, &t.HomeOrigin,
+ &t.PushRelayURL, &t.PushRelayToken, &t.CreatedAt)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &t, nil
+}
+
+// delete removes a route by host (data-safe: it stops routing the host but never
+// touches its DB or env file).
+func (r *Registry) delete(host string) error {
+ _, err := r.sql.Exec(`DELETE FROM routes WHERE host = ?`, host)
+ return err
+}
+
+// upsert inserts or updates a route by host. (add-instance / register will drive
+// this in a later step; it's here now so the router and its tests can seed
+// routes.)
+func (r *Registry) upsert(t Route) error {
+ if t.CreatedAt == 0 {
+ t.CreatedAt = time.Now().Unix()
+ }
+ if t.Status == "" {
+ t.Status = "ready"
+ }
+ if t.Kind == "" {
+ t.Kind = "instance"
+ }
+ _, err := r.sql.Exec(`
+INSERT INTO routes (host, kind, socket, status, last_error, db_path, version,
+ app_store_url, home_origin, push_relay_url, push_relay_token, created_at)
+VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
+ON CONFLICT(host) DO UPDATE SET
+ kind=excluded.kind, socket=excluded.socket, status=excluded.status,
+ last_error=excluded.last_error, db_path=excluded.db_path, version=excluded.version,
+ app_store_url=excluded.app_store_url, home_origin=excluded.home_origin,
+ push_relay_url=excluded.push_relay_url, push_relay_token=excluded.push_relay_token`,
+ t.Host, t.Kind, t.Socket, t.Status, t.LastError, t.DBPath, t.Version,
+ t.AppStoreURL, t.HomeOrigin, t.PushRelayURL, t.PushRelayToken, t.CreatedAt)
+ return err
+}
diff --git a/router.go b/router.go
new file mode 100644
index 0000000..bae851b
--- /dev/null
+++ b/router.go
@@ -0,0 +1,215 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "os"
+ "os/signal"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "golang.org/x/crypto/acme/autocert"
+)
+
+// router is the multi-instance front door: it terminates TLS (autocert) and
+// reverse-proxies each configured host to its backend's unix socket. The
+// host→backend map is swapped atomically, so reloads and zero-downtime deploys
+// never drop a live connection. See docs/instance-router.md.
+type router struct {
+ reg *Registry
+ upstreams atomic.Pointer[map[string]*httputil.ReverseProxy]
+}
+
+func newRouter(reg *Registry) (*router, error) {
+ rt := &router{reg: reg}
+ if err := rt.reload(); err != nil {
+ return nil, err
+ }
+ return rt, nil
+}
+
+// reload rebuilds the host→backend map from the registry and swaps it in
+// atomically. Safe to call while serving.
+func (rt *router) reload() error {
+ routes, err := rt.reg.list()
+ if err != nil {
+ return err
+ }
+ m := make(map[string]*httputil.ReverseProxy, len(routes))
+ for _, t := range routes {
+ // Only serve ready routes: a 'creating'/'failed' entry has no working
+ // backend, so proxying it (and minting a cert for it) would just stall → 502.
+ if t.Socket == "" || t.Status != "ready" {
+ continue
+ }
+ m[t.Host] = unixProxy(t.Socket)
+ }
+ rt.upstreams.Store(&m)
+ return nil
+}
+
+func (rt *router) current() map[string]*httputil.ReverseProxy {
+ if p := rt.upstreams.Load(); p != nil {
+ return *p
+ }
+ return nil
+}
+
+// allowed reports whether host is configured — used as the autocert HostPolicy,
+// so a certificate is only ever requested for a host we actually serve.
+func (rt *router) allowed(host string) bool {
+ _, ok := rt.current()[host]
+ return ok
+}
+
+func (rt *router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ host := r.Host
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ proxy, ok := rt.current()[host]
+ if !ok {
+ http.Error(w, "unknown host", http.StatusNotFound)
+ return
+ }
+ proxy.ServeHTTP(w, r)
+}
+
+// unixProxy reverse-proxies to a backend listening on a unix socket, preserving
+// the client's Host header (so the backend sees the real hostname) and passing
+// WebSocket upgrades straight through.
+func unixProxy(socketPath string) *httputil.ReverseProxy {
+ dialer := &net.Dialer{}
+ return &httputil.ReverseProxy{
+ Rewrite: func(pr *httputil.ProxyRequest) {
+ pr.SetXForwarded()
+ pr.Out.URL.Scheme = "http"
+ pr.Out.URL.Host = "lchat" // placeholder; the unix dialer ignores it
+ pr.Out.Host = pr.In.Host // backend sees the real hostname
+ },
+ Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return dialUnixWithRetry(ctx, dialer, socketPath)
+ },
+ // Bound idle keep-alives so proxies replaced on reload don't leak their
+ // pooled connections + reader goroutines on the long-running router.
+ MaxIdleConns: 32,
+ IdleConnTimeout: 90 * time.Second,
+ },
+ }
+}
+
+// dialUnixWithRetry masks the brief gap during an instance restart: while the old
+// process has exited and the new one hasn't rebound the socket, dials fail with
+// ENOENT/ECONNREFUSED. Retry those for a short window so no in-flight request is
+// dropped; any other error fails fast. The window must exceed a deploy's
+// socket-absent gap — the instance drain (10s in main.go) plus rebind — so an
+// in-flight request is never 502'd mid-deploy; a sustained-down backend hangs this
+// long before 502 (rare: systemd Restart=always makes "down" transient). True
+// blue/green would remove the gap entirely (see docs/instance-router.md).
+func dialUnixWithRetry(ctx context.Context, d *net.Dialer, socketPath string) (net.Conn, error) {
+ const window = 13 * time.Second
+ deadline := time.Now().Add(window)
+ for {
+ conn, err := d.DialContext(ctx, "unix", socketPath)
+ if err == nil {
+ return conn, nil
+ }
+ if ctx.Err() != nil || !isRetryableDialErr(err) || time.Now().After(deadline) {
+ return nil, err
+ }
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+}
+
+func isRetryableDialErr(err error) bool {
+ return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT)
+}
+
+// runRouter is the `lchat router` subcommand: the shared front door for every
+// instance and sibling service on the box.
+func runRouter(args []string) {
+ fs := flag.NewFlagSet("router", flag.ExitOnError)
+ registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path (env: LCHAT_REGISTRY)")
+ acmeCache := fs.String("acme-cache", envOr("LCHAT_ACME_CACHE", "/data/acme"), "autocert certificate cache dir (env: LCHAT_ACME_CACHE)")
+ httpsAddr := fs.String("https", envOr("LCHAT_HTTPS_ADDR", ":443"), "HTTPS listen address")
+ httpAddr := fs.String("http", envOr("LCHAT_HTTP_ADDR", ":80"), "HTTP listen address (ACME HTTP-01 challenge + redirect to https)")
+ _ = fs.Parse(args)
+
+ reg, err := openRegistry(*registryPath)
+ if err != nil {
+ log.Fatalf("router: open registry %s: %v", *registryPath, err)
+ }
+ rt, err := newRouter(reg)
+ if err != nil {
+ log.Fatalf("router: load routes: %v", err)
+ }
+
+ m := &autocert.Manager{
+ Prompt: autocert.AcceptTOS,
+ Cache: autocert.DirCache(*acmeCache),
+ HostPolicy: func(_ context.Context, host string) error {
+ if rt.allowed(host) {
+ return nil
+ }
+ return fmt.Errorf("acme: host %q not in registry", host)
+ },
+ }
+
+ // 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)
+ signal.Notify(sighup, syscall.SIGHUP)
+ go func() {
+ for range sighup {
+ if err := rt.reload(); err != nil {
+ log.Printf("router: reload failed: %v", err)
+ continue
+ }
+ log.Printf("router: reloaded (%d route(s))", len(rt.current()))
+ }
+ }()
+
+ // ReadHeaderTimeout guards the public front door against Slowloris-style slow-
+ // header connection exhaustion. No ReadTimeout/WriteTimeout — those would break
+ // long-lived proxied WebSockets.
+ httpSrv := &http.Server{Addr: *httpAddr, Handler: m.HTTPHandler(nil), ReadHeaderTimeout: 10 * time.Second}
+ httpsSrv := &http.Server{Addr: *httpsAddr, Handler: rt, TLSConfig: m.TLSConfig(), ReadHeaderTimeout: 10 * time.Second}
+
+ // :80 serves the ACME HTTP-01 challenge and redirects everything else to https.
+ go func() {
+ if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("router: http listener %s: %v", *httpAddr, err)
+ }
+ }()
+ go func() {
+ log.Printf("router: serving %d route(s) on %s (registry %s, acme cache %s)",
+ len(rt.current()), *httpsAddr, *registryPath, *acmeCache)
+ if err := httpsSrv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("router: https listener %s: %v", *httpsAddr, err)
+ }
+ }()
+
+ // Graceful shutdown on SIGTERM/SIGINT (e.g. a router binary upgrade): stop
+ // accepting and drain in-flight HTTP before exiting. Proxied WebSockets are
+ // hijacked (not drained); they reconnect through the new router.
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
+ <-stop
+ log.Printf("router: draining and shutting down")
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _ = httpsSrv.Shutdown(ctx)
+ _ = httpSrv.Shutdown(ctx)
+}
diff --git a/router_test.go b/router_test.go
new file mode 100644
index 0000000..7568b7a
--- /dev/null
+++ b/router_test.go
@@ -0,0 +1,275 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+)
+
+func TestRouterProxiesByHost(t *testing.T) {
+ // A backend on a unix socket that echoes the Host header it receives.
+ sock := tmpSock(t)
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Fatalf("listen unix: %v", err)
+ }
+ backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, "backend:"+r.Host)
+ })}
+ go backend.Serve(ln)
+ t.Cleanup(func() { backend.Close() })
+
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ if err := reg.upsert(Route{Host: "chat.example.com", Kind: "instance", Socket: sock}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ rt, err := newRouter(reg)
+ if err != nil {
+ t.Fatalf("new router: %v", err)
+ }
+ front := httptest.NewServer(rt)
+ t.Cleanup(front.Close)
+
+ // Known host is proxied, and the backend sees the real Host header.
+ if body, code := proxyGet(t, front.URL, "chat.example.com"); code != 200 || body != "backend:chat.example.com" {
+ t.Fatalf("known host: got %d %q, want 200 backend:chat.example.com", code, body)
+ }
+ // Unknown host → 404 (and, as HostPolicy, no cert would be issued for it).
+ if _, code := proxyGet(t, front.URL, "nope.example.com"); code != http.StatusNotFound {
+ t.Fatalf("unknown host: got %d, want 404", code)
+ }
+ if rt.allowed("nope.example.com") {
+ t.Fatal("allowed() should be false for an unconfigured host")
+ }
+
+ // A new route isn't served until reload; after reload it is (atomic swap).
+ if err := reg.upsert(Route{Host: "two.example.com", Kind: "instance", Socket: sock}); err != nil {
+ t.Fatalf("upsert two: %v", err)
+ }
+ if rt.allowed("two.example.com") {
+ t.Fatal("route should not be live before reload")
+ }
+ if err := rt.reload(); err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ if body, code := proxyGet(t, front.URL, "two.example.com"); code != 200 || body != "backend:two.example.com" {
+ t.Fatalf("after reload: got %d %q, want 200 backend:two.example.com", code, body)
+ }
+}
+
+func TestRouterProxiesWebSocket(t *testing.T) {
+ // A backend WebSocket echo server on a unix socket.
+ sock := tmpSock(t)
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Fatalf("listen unix: %v", err)
+ }
+ backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c, err := websocket.Accept(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer c.Close(websocket.StatusNormalClosure, "")
+ mt, data, err := c.Read(r.Context())
+ if err != nil {
+ return
+ }
+ _ = c.Write(r.Context(), mt, append([]byte("echo:"), data...))
+ })}
+ go backend.Serve(ln)
+ t.Cleanup(func() { backend.Close() })
+
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ // Route the loopback host so the client reaches it without a Host override
+ // (ServeHTTP strips the port, matching "127.0.0.1").
+ if err := reg.upsert(Route{Host: "127.0.0.1", Kind: "instance", Socket: sock}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ rt, err := newRouter(reg)
+ if err != nil {
+ t.Fatalf("new router: %v", err)
+ }
+ front := httptest.NewServer(rt)
+ t.Cleanup(front.Close)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ wsURL := "ws" + strings.TrimPrefix(front.URL, "http") + "/ws"
+ c, _, err := websocket.Dial(ctx, wsURL, nil)
+ if err != nil {
+ t.Fatalf("ws dial through router: %v", err)
+ }
+ defer c.Close(websocket.StatusNormalClosure, "")
+ if err := c.Write(ctx, websocket.MessageText, []byte("hi")); err != nil {
+ t.Fatalf("ws write: %v", err)
+ }
+ _, data, err := c.Read(ctx)
+ if err != nil {
+ t.Fatalf("ws read: %v", err)
+ }
+ if string(data) != "echo:hi" {
+ t.Fatalf("ws echo through router: got %q, want %q", data, "echo:hi")
+ }
+}
+
+func TestIsRetryableDialErr(t *testing.T) {
+ sock := tmpSock(t) // a path with no listener → ENOENT
+ if _, err := net.Dial("unix", sock); err == nil {
+ t.Fatal("expected a dial error for a missing socket")
+ } else if !isRetryableDialErr(err) {
+ t.Errorf("missing socket: isRetryableDialErr(%v) = false, want true", err)
+ }
+ if isRetryableDialErr(context.Canceled) {
+ t.Error("context.Canceled should not be retryable")
+ }
+}
+
+func TestRouterRetriesAcrossRestart(t *testing.T) {
+ sock := tmpSock(t)
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.upsert(Route{Host: "chat.example.com", Kind: "instance", Socket: sock}); err != nil {
+ t.Fatal(err)
+ }
+ rt, err := newRouter(reg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ front := httptest.NewServer(rt)
+ t.Cleanup(front.Close)
+
+ // Fire the request before any backend exists; the router must retry the dial.
+ type res struct {
+ code int
+ body string
+ }
+ ch := make(chan res, 1)
+ go func() {
+ req, _ := http.NewRequest(http.MethodGet, front.URL+"/", nil)
+ req.Host = "chat.example.com"
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ ch <- res{0, "err: " + err.Error()}
+ return
+ }
+ b, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ ch <- res{resp.StatusCode, string(b)}
+ }()
+
+ // Bind the backend after a gap, simulating the instance restart window.
+ time.Sleep(300 * time.Millisecond)
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, "late-backend")
+ })}
+ go backend.Serve(ln)
+ t.Cleanup(func() { backend.Close() })
+
+ select {
+ case r := <-ch:
+ if r.code != 200 || r.body != "late-backend" {
+ t.Fatalf("retry across gap: got %d %q, want 200 late-backend", r.code, r.body)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("request never completed; router did not retry across the gap")
+ }
+}
+
+func TestReloadOnlyServesReady(t *testing.T) {
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, r := range []Route{
+ {Host: "ready.example.com", Kind: "instance", Socket: "/run/lchat/a.sock", Status: "ready"},
+ {Host: "failed.example.com", Kind: "instance", Socket: "/run/lchat/b.sock", Status: "failed"},
+ {Host: "creating.example.com", Kind: "instance", Socket: "/run/lchat/c.sock", Status: "creating"},
+ } {
+ if err := reg.upsert(r); err != nil {
+ t.Fatal(err)
+ }
+ }
+ rt, err := newRouter(reg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !rt.allowed("ready.example.com") {
+ t.Error("ready route should be served")
+ }
+ if rt.allowed("failed.example.com") {
+ t.Error("failed route should not be served or cert-eligible")
+ }
+ if rt.allowed("creating.example.com") {
+ t.Error("creating route should not be served")
+ }
+}
+
+func TestWaitHealthy(t *testing.T) {
+ serve := func(status int) string {
+ sock := tmpSock(t)
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) })}
+ go srv.Serve(ln)
+ t.Cleanup(func() { srv.Close() })
+ return sock
+ }
+ if err := waitHealthy(serve(200), 3*time.Second); err != nil {
+ t.Errorf("200 backend: waitHealthy = %v, want nil", err)
+ }
+ if err := waitHealthy(serve(500), 1*time.Second); err == nil {
+ t.Error("500 backend: waitHealthy = nil, want error (a broken binary must fail the deploy gate)")
+ }
+}
+
+// proxyGet sends GET base+"/" with an overridden Host header and returns the
+// response body + status code.
+func proxyGet(t *testing.T, base, host string) (string, int) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodGet, base+"/", nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Host = host
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return string(b), resp.StatusCode
+}
+
+// tmpSock returns a short unix socket path (the sun_path limit is ~104 bytes, so
+// t.TempDir() can be too long on macOS) and schedules its removal.
+func tmpSock(t *testing.T) string {
+ t.Helper()
+ s := fmt.Sprintf("/tmp/lchat-rt-%d-%d.sock", os.Getpid(), time.Now().UnixNano()%1_000_000)
+ os.Remove(s)
+ t.Cleanup(func() { os.Remove(s) })
+ return s
+}