eleven / messenger Public

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>
Paul Campbell 1b0a16143fac1d8c4ce4051e14b775bec344673a parent d3b4661
8 files changed, +139 −19
  • bootstrap.go +12 −0
  • control.go +10 −3
  • deploy/systemd/lchat-home.service +15 −0
  • deploy/systemd/lchat-push.service +16 −0
  • docs/instance-migration.md +14 −13
  • docs/instance-router.md +10 −1
  • home/main.go +30 −1
  • push/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 {