Self-hosted router: host push/home + import-migrate instances (#185)
* push, home: listen on a unix socket when ADDR is a path
The instance router (router.go) reverse-proxies each host to a unix
socket, and add-service requires a --socket. push and home only did
http.ListenAndServe on a TCP addr, so they couldn't sit behind the
router. Teach both to listen on a unix socket when the addr is a path
(clobber-guarding a stale socket, chmod 0600), mirroring the main
binary's listen(); a non-path addr still binds TCP exactly as before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* deploy: ship push + home systemd units in bootstrap
Generic, host-agnostic units for the two optional sibling services (one
each per box), listening on /run/lchat/{push,home}.sock so the router
fronts them. Installed by `lchat bootstrap` like the bot unit but inert
until enabled with a /data/{push,home}.env present, so a box that runs
neither is unaffected. The public host lives only in the router registry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* control: add-instance --admin-token to import an existing token
Migrating an instance means placing its DB on the new box and keeping
the SAME admin token — a fresh one rewrites admin_token_hash on boot and
orphans every saved admin link. add-instance minted unconditionally, so
the import step was done by hand. --admin-token uses the supplied token
instead of minting; combined with the existing --push-relay-* flags it
imports an instance in one command. Documented in instance-migration.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: note on-box clients must resolve hosted hostnames to loopback
A sidecar (or an instance's own call to a sibling service like the push
relay) connects to a hosted host by its public name. The box must resolve
that to itself — pin the hosts to loopback in /etc/hosts, else you hair-pin
out to the public IP, or (after a migration + DNS flip) an external
resolver's stale cache silently points the on-box client at the old box.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8 files changed,
+139
−19
bootstrap.go+12 −0control.go+10 −3deploy/systemd/lchat-home.service+15 −0deploy/systemd/lchat-push.service+16 −0docs/instance-migration.md+14 −13docs/instance-router.md+10 −1home/main.go+30 −1push/main.go+32 −1
diff --git a/bootstrap.go b/bootstrap.go| index fbb9c89..628ffc3 100644 |
| --- a/bootstrap.go |
| +++ b/bootstrap.go |
| @@ -20,6 +20,16 @@ var unitInstance string |
| //go:embed deploy/systemd/lchat-bot@.service |
| var unitBot string |
| +// The push relay + home server are optional sibling services (one each per box). |
| +// Their units install alongside the rest but stay inert until enabled with a |
| +// /data/{push,home}.env present, so a box that runs neither is unaffected. |
| +// |
| +//go:embed deploy/systemd/lchat-push.service |
| +var unitPush string |
| + |
| +//go:embed deploy/systemd/lchat-home.service |
| +var unitHome string |
| + |
| // tmpfilesConf keeps /run/lchat across reboots (/run is tmpfs); bootstrap also |
| // creates it now. |
| const tmpfilesConf = "d /run/lchat 0710 root root -\n" |
| @@ -52,6 +62,8 @@ func runBootstrap(args []string) { |
| write(filepath.Join(*unitDir, "lchat-router.service"), unitRouter) |
| write(filepath.Join(*unitDir, "lchat@.service"), unitInstance) |
| write(filepath.Join(*unitDir, "lchat-bot@.service"), unitBot) |
| + write(filepath.Join(*unitDir, "lchat-push.service"), unitPush) |
| + write(filepath.Join(*unitDir, "lchat-home.service"), unitHome) |
| write("/etc/tmpfiles.d/lchat.conf", tmpfilesConf) |
| if err := systemctl("daemon-reload"); err != nil { |
diff --git a/control.go b/control.go| index 62e6181..8a650df 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -96,6 +96,7 @@ func runAddInstance(args []string) { |
| 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") |
| + adminToken := fs.String("admin-token", "", "import: use this admin token instead of minting a new one (preserves saved admin links when migrating an instance whose DB is already in place)") |
| 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") |
| @@ -125,9 +126,15 @@ func runAddInstance(args []string) { |
| // 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) |
| + // --admin-token imports an existing token (migration): the instance's boot |
| + // rewrites admin_token_hash from the env, so passing the old token over a |
| + // pre-placed migrated DB leaves the hash — and every saved admin link — intact. |
| + token := *adminToken |
| + if token == "" { |
| + var err error |
| + if token, err = randToken(); err != nil { |
| + die("add-instance: token: %v", err) |
| + } |
| } |
| env := map[string]string{"LCHAT_ADMIN_TOKEN": token} |
| for k, v := range map[string]string{ |
diff --git a/deploy/systemd/lchat-home.service b/deploy/systemd/lchat-home.service| new file mode 100644 |
| index 0000000..1d1845e |
| --- /dev/null |
| +++ b/deploy/systemd/lchat-home.service |
| @@ -0,0 +1,15 @@ |
| +[Unit] |
| +Description=lchat home server (passkey blob backup) |
| +After=network-online.target |
| +Wants=network-online.target |
| + |
| +[Service] |
| +# /data/home.env holds LCHAT_HOME_ORIGIN — the WebAuthn RP identity. It must equal |
| +# the public host the router serves this on, or previously-enrolled passkeys orphan. |
| +EnvironmentFile=/data/home.env |
| +ExecStart=/opt/lchat/home --addr /run/lchat/home.sock --db /data/home.db |
| +Restart=always |
| +RestartSec=1 |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/deploy/systemd/lchat-push.service b/deploy/systemd/lchat-push.service| new file mode 100644 |
| index 0000000..04aec21 |
| --- /dev/null |
| +++ b/deploy/systemd/lchat-push.service |
| @@ -0,0 +1,16 @@ |
| +[Unit] |
| +Description=lchat push relay |
| +After=network-online.target |
| +Wants=network-online.target |
| + |
| +[Service] |
| +# /data/push.env holds LCHAT_ADMIN_TOKEN + the APNs signing key (LCHAT_APNS_*). |
| +# Required (no leading '-'): a push relay with no signing key is a fleet-wide iOS |
| +# push outage, so it must fail to boot rather than start silently broken. |
| +EnvironmentFile=/data/push.env |
| +ExecStart=/opt/lchat/push --addr /run/lchat/push.sock --db /data/push.db |
| +Restart=always |
| +RestartSec=1 |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/docs/instance-migration.md b/docs/instance-migration.md| index 77d98bf..887c744 100644 |
| --- a/docs/instance-migration.md |
| +++ b/docs/instance-migration.md |
| @@ -47,21 +47,22 @@ 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: |
| +With the migrated DB already at `/data/<host>.db`, run `add-instance` with |
| +`--admin-token` so it **imports** the existing token instead of minting a new one |
| +(a fresh token would rewrite `admin_token_hash` on boot and orphan every saved |
| +admin link). It writes the EnvironmentFile, registers the route, starts the unit |
| +against the pre-placed DB, health-checks it, and reloads the router: |
| -- 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. |
| +```sh |
| +lchat add-instance <host> \ |
| + --admin-token "<existing token>" \ |
| + --push-relay-url https://push.example.com --push-relay-token "<existing relay token>" |
| +``` |
| -> 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. |
| +Pass whatever per-instance config the source had (`--push-relay-url`/`-token`, |
| +`--home-origin`, `--app-store-url`). 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. |
| ### 5. Gap check |
diff --git a/docs/instance-router.md b/docs/instance-router.md| index 3683a81..475536a 100644 |
| --- a/docs/instance-router.md |
| +++ b/docs/instance-router.md |
| @@ -193,7 +193,16 @@ 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. |
| + connection that happens to originate on the same box. One wrinkle: the box must |
| + resolve that public hostname **to itself**. Pin every hostname the box hosts to |
| + loopback in `/etc/hosts` (`127.0.0.1 chat.example.com …`) so on-box clients — |
| + sidecars, and an instance's own calls to a sibling service like the push relay — |
| + reach the local router directly (still over TLS+SNI, so the right cert is served) |
| + instead of hair-pinning out to the public IP. This also sidesteps a migration |
| + trap: after you move onto this box and flip DNS, an external resolver may still |
| + cache the *old* box's IP for these hosts, so an on-box client that resolves them |
| + externally can silently talk to the old box — or fail once it's gone. (On cloud |
| + images that regenerate `/etc/hosts` on boot, disable that so the pins persist.) |
| 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. |
diff --git a/home/main.go b/home/main.go| index 31e07b0..7cf0cf3 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -10,6 +10,7 @@ import ( |
| "embed" |
| "encoding/json" |
| "flag" |
| + "fmt" |
| "io" |
| "io/fs" |
| "log" |
| @@ -65,11 +66,39 @@ func main() { |
| log.Fatalf("server: %v", err) |
| } |
| log.Printf("home listening on %s (db: %s, origin: %s)", *addr, *dbPath, *origin) |
| - if err := http.ListenAndServe(*addr, s.handler()); err != nil { |
| + if err := serve(*addr, s.handler()); err != nil { |
| log.Fatal(err) |
| } |
| } |
| +// serve listens on addr and serves h. A path-like addr (starts with "/") is a |
| +// unix socket — how the instance router fronts this service, reverse-proxying to |
| +// a socket rather than a TCP port; anything else is a TCP address (the default, |
| +// e.g. on a container platform that health-checks over HTTP). On the socket path |
| +// it clobbers only a stale socket (nothing listening) and locks it to 0600, |
| +// mirroring the main binary's listen(). |
| +func serve(addr string, h http.Handler) error { |
| + if !strings.HasPrefix(addr, "/") { |
| + return http.ListenAndServe(addr, h) |
| + } |
| + if c, err := net.DialTimeout("unix", addr, 200*time.Millisecond); err == nil { |
| + c.Close() |
| + return fmt.Errorf("socket %s is already in use by another process", addr) |
| + } |
| + if err := os.Remove(addr); err != nil && !os.IsNotExist(err) { |
| + return fmt.Errorf("remove stale socket %s: %w", addr, err) |
| + } |
| + ln, err := net.Listen("unix", addr) |
| + if err != nil { |
| + return err |
| + } |
| + defer ln.Close() |
| + if err := os.Chmod(addr, 0o600); err != nil { |
| + return fmt.Errorf("chmod socket %s: %w", addr, err) |
| + } |
| + return http.Serve(ln, h) |
| +} |
| + |
| func newServer(db *DB, origin string) (*Server, error) { |
| u, err := url.Parse(origin) |
| if err != nil { |
diff --git a/push/main.go b/push/main.go| index afab3bd..07c7377 100644 |
| --- a/push/main.go |
| +++ b/push/main.go |
| @@ -17,9 +17,12 @@ import ( |
| "encoding/json" |
| "errors" |
| "flag" |
| + "fmt" |
| "log" |
| + "net" |
| "net/http" |
| "os" |
| + "strings" |
| "sync" |
| "time" |
| @@ -82,11 +85,39 @@ func main() { |
| global: newRateLimiter(4000, 2000), |
| } |
| log.Printf("push relay listening on %s (db: %s, topic: %s)", *addr, *dbPath, client.Topic()) |
| - if err := http.ListenAndServe(*addr, s.handler()); err != nil { |
| + if err := serve(*addr, s.handler()); err != nil { |
| log.Fatal(err) |
| } |
| } |
| +// serve listens on addr and serves h. A path-like addr (starts with "/") is a |
| +// unix socket — how the instance router fronts this service, reverse-proxying to |
| +// a socket rather than a TCP port; anything else is a TCP address (the default, |
| +// e.g. on a container platform that health-checks over HTTP). On the socket path |
| +// it clobbers only a stale socket (nothing listening) and locks it to 0600, |
| +// mirroring the main binary's listen(). |
| +func serve(addr string, h http.Handler) error { |
| + if !strings.HasPrefix(addr, "/") { |
| + return http.ListenAndServe(addr, h) |
| + } |
| + if c, err := net.DialTimeout("unix", addr, 200*time.Millisecond); err == nil { |
| + c.Close() |
| + return fmt.Errorf("socket %s is already in use by another process", addr) |
| + } |
| + if err := os.Remove(addr); err != nil && !os.IsNotExist(err) { |
| + return fmt.Errorf("remove stale socket %s: %w", addr, err) |
| + } |
| + ln, err := net.Listen("unix", addr) |
| + if err != nil { |
| + return err |
| + } |
| + defer ln.Close() |
| + if err := os.Chmod(addr, 0o600); err != nil { |
| + return fmt.Errorf("chmod socket %s: %w", addr, err) |
| + } |
| + return http.Serve(ln, h) |
| +} |
| + |
| // handler wires the routes. A method so tests exercise the real router via |
| // httptest. |
| func (s *Server) handler() http.Handler { |