eleven / messenger Public

server: materialize a route's first wake via controld, not directly (#844)

Found live during the mesh flip rehearsal: a mesh-replicated route
(meshreplicate.go's meshRouteHandler) only ever inserts a registry
row on the receiving box, never a live symlink — that's normally
materializeInstance's job, at add-instance time. So its first wake
there hit s3Backend.start's fallback path (stage the running binary +
point the live symlink), which tried to write to /opt/lchat directly
— a write this sandboxed process (ProtectSystem=strict,
ReadWritePaths=/data /run/lchat only) has never actually been able to
make. It only ever "worked" because every existing box A instance's
symlink already existed from the unsandboxed add-instance flow, so
this branch was dead code until mesh-seed exercised it for the first
time — two real customer spaces 503'd for several minutes as a result.

Considered widening ReadWritePaths to include /opt/lchat instead: real
downside is that it's not just a data-blast-radius question — it would
mean a bug in request handling could, for the first time, let an
attacker plant a binary the router later executes as a child process,
which ProtectSystem=strict specifically exists to prevent.

Routes the write through the existing control-daemon pattern instead:
controld already runs fully unsandboxed as root for exactly this class
of privileged action (it's what add-instance already goes through).
Added POST /instances/{host}/materialize (controld.go) — stages the
binary + points the live symlink for an already-registered route,
idempotent. The router calls it via a new optional s3Backend.materialize
hook (cloud.go's controldMaterializeFunc, activator.go's extracted
ensureLiveLink) instead of doing the write itself, authenticated with
the control token via systemd LoadCredential — the same secure handoff
lchat-cloud.service already uses, granting read access to one token
file, never a filesystem write grant.

Deliberately NOT added to the universal lchat-router.service template
(every self-hoster gets that on `lchat bootstrap`) — LoadCredential
fails a unit to start outright if its source path is missing, and most
boxes don't run controld at all. Instead: deploy/systemd/lchat-router-
control-token.conf, an optional drop-in for boxes that run controld AND
opt into the mesh. Without it, the router logs once at startup and
falls back to the direct calls — today's exact behavior, unchanged.
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 3dea037e520e7798cce1a7ff885c4a1ee72f2e63 parent 43d03cc
7 files changed, +339 −12
  • activator.go +44 −11
  • activator_test.go +95 −0
  • cloud.go +40 −0
  • controld.go +46 −0
  • controld_test.go +83 −0
  • deploy/systemd/lchat-router-control-token.conf +20 −0
  • router.go +11 −1
diff --git a/activator.go b/activator.go
index 4552ede..53e4bc5 100644
--- a/activator.go
+++ b/activator.go
@@ -1048,6 +1048,13 @@ func (a *activatorSvc) serveHTTP(w http.ResponseWriter, r *http.Request, rt Rout
// versioned binary the systemd unit would run.
type s3Backend struct {
cfg hibernateConfig
+ // materialize, when non-nil, stages the running binary and points a
+ // host's live symlink at it via the control daemon (cloud.go's
+ // controldMaterializeFunc) instead of doing it directly — needed because
+ // this process runs sandboxed and can't write to /opt/lchat itself. nil
+ // falls back to the direct calls (start's else branch), today's
+ // long-standing behavior for any box that hasn't opted in.
+ materialize func(ctx context.Context, host string) error
}
func (b *s3Backend) acquire(ctx context.Context, host string) (actLease, error) {
@@ -1142,6 +1149,39 @@ func (b *s3Backend) nextDue(ctx context.Context, rt Route) (int64, error) {
// socket and answer. A var so tests can shrink the failure path.
var spawnHealthTimeout = 30 * time.Second
+// ensureLiveLink materializes host's live symlink (/opt/lchat/live/<host> →
+// a staged version) if this box hasn't already — the future create-is-a-row
+// flow, or (found live, 2026-07-13) a mesh-replicated route, which only ever
+// inserts a registry row (meshreplicate.go), never a symlink. Split out of
+// s3Backend.start for focused testing: exercising this doesn't need a real
+// spawned process, just the filesystem.
+//
+// Prefers routing the write through controld (materialize, non-nil) over
+// doing it directly: this process runs sandboxed (ProtectSystem=strict) and
+// can't write to /opt/lchat itself — a direct call here only ever worked by
+// accident, when some other, unsandboxed path (add-instance) had already
+// materialized every host this box would ever wake.
+func ensureLiveLink(ctx context.Context, host string, materialize func(ctx context.Context, host string) error) error {
+ bin := liveLink(host)
+ if _, err := os.Stat(bin); err == nil {
+ return nil
+ }
+ if materialize != nil {
+ if err := materialize(ctx, host); err != nil {
+ return fmt.Errorf("materialize via controld: %w", err)
+ }
+ return nil
+ }
+ ver, err := ensureSelfStaged()
+ if err != nil {
+ return fmt.Errorf("stage binary: %w", err)
+ }
+ if err := pointLive(host, ver); err != nil {
+ return fmt.Errorf("point live symlink: %w", err)
+ }
+ return nil
+}
+
func (b *s3Backend) start(ctx context.Context, rt Route) (actProc, string, error) {
host := rt.Host
dbPath := rt.DBPath
@@ -1151,18 +1191,11 @@ func (b *s3Backend) start(ctx context.Context, rt Route) (actProc, string, error
// Exec exactly what the systemd unit would: the per-instance live symlink
// (/opt/lchat/live/<host> → a staged version), same --socket/--db flags,
- // same EnvironmentFile. If the host was registered but never materialized
- // (the future create-is-a-row flow), stage the running binary and point
- // the link, exactly as materializeInstance would have.
+ // same EnvironmentFile. ensureLiveLink materializes it first if this box
+ // never has (a mesh-replicated route, or the future create-is-a-row flow).
bin := liveLink(host)
- if _, err := os.Stat(bin); err != nil {
- ver, err := ensureSelfStaged()
- if err != nil {
- return nil, "", fmt.Errorf("stage binary: %w", err)
- }
- if err := pointLive(host, ver); err != nil {
- return nil, "", fmt.Errorf("point live symlink: %w", err)
- }
+ if err := ensureLiveLink(ctx, host, b.materialize); err != nil {
+ return nil, "", err
}
// The env file is optional, like the unit's "-" prefix.
diff --git a/activator_test.go b/activator_test.go
index fd70fb5..9f4ec0d 100644
--- a/activator_test.go
+++ b/activator_test.go
@@ -9,10 +9,12 @@ package main
import (
"context"
"errors"
+ "fmt"
"io"
"net"
"net/http"
"net/http/httptest"
+ "os"
"path/filepath"
"sync"
"sync/atomic"
@@ -1427,3 +1429,96 @@ func TestHibernateConfigFromEnv(t *testing.T) {
t.Fatal("bad idle duration should error, not default silently")
}
}
+
+// --- ensureLiveLink (the controld-mediated fix for mesh-replicated routes) ---
+
+// ensureLiveLinkTestEnv redirects optDir/selfPath into temp dirs, same seam
+// controld_test.go's controlTestEnv uses, so ensureSelfStaged/pointLive run
+// for real against throwaway paths.
+func ensureLiveLinkTestEnv(t *testing.T) {
+ t.Helper()
+ oldOpt, oldSelf := optDir, selfPath
+ t.Cleanup(func() { optDir, selfPath = oldOpt, oldSelf })
+ optDir = t.TempDir()
+ stub := filepath.Join(t.TempDir(), "stub")
+ if err := os.WriteFile(stub, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ selfPath = func() (string, error) { return stub, nil }
+}
+
+// Already-materialized: the common case for every existing box A instance —
+// neither the direct path nor materialize is ever invoked.
+func TestEnsureLiveLinkAlreadyMaterialized(t *testing.T) {
+ ensureLiveLinkTestEnv(t)
+ ver, err := ensureSelfStaged()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := pointLive("existing.example.com", ver); err != nil {
+ t.Fatal(err)
+ }
+ called := false
+ materialize := func(context.Context, string) error { called = true; return nil }
+ if err := ensureLiveLink(context.Background(), "existing.example.com", materialize); err != nil {
+ t.Fatalf("ensureLiveLink: %v", err)
+ }
+ if called {
+ t.Error("materialize was called for an already-materialized host")
+ }
+}
+
+// No materialize hook configured (no control token — the common case for any
+// box that hasn't opted in): falls back to the direct calls, today's
+// long-standing behavior.
+func TestEnsureLiveLinkFallsBackWithoutMaterialize(t *testing.T) {
+ ensureLiveLinkTestEnv(t)
+ if err := ensureLiveLink(context.Background(), "fresh.example.com", nil); err != nil {
+ t.Fatalf("ensureLiveLink: %v", err)
+ }
+ if _, err := os.Stat(liveLink("fresh.example.com")); err != nil {
+ t.Fatalf("live symlink was not created: %v", err)
+ }
+}
+
+// Missing symlink + materialize configured: routed through materialize, not
+// the direct (sandboxed-and-failing, in production) calls.
+func TestEnsureLiveLinkUsesMaterializeWhenConfigured(t *testing.T) {
+ ensureLiveLinkTestEnv(t)
+ var gotHost string
+ materialize := func(_ context.Context, host string) error {
+ gotHost = host
+ // The real controld handler stages + points live; a fake only needs
+ // to prove IT was the one asked, and that its result is honored.
+ ver, err := ensureSelfStaged()
+ if err != nil {
+ return err
+ }
+ return pointLive(host, ver)
+ }
+ if err := ensureLiveLink(context.Background(), "seeded.11ch.at", materialize); err != nil {
+ t.Fatalf("ensureLiveLink: %v", err)
+ }
+ if gotHost != "seeded.11ch.at" {
+ t.Fatalf("materialize called with %q, want seeded.11ch.at", gotHost)
+ }
+ if _, err := os.Stat(liveLink("seeded.11ch.at")); err != nil {
+ t.Fatalf("live symlink was not created via materialize: %v", err)
+ }
+}
+
+// A materialize failure (e.g. controld unreachable) must propagate — the
+// exact live symptom found 2026-07-13 (a 503 "instance failed to wake"),
+// never silently swallowed into "looks fine, wakes anyway."
+func TestEnsureLiveLinkPropagatesMaterializeError(t *testing.T) {
+ ensureLiveLinkTestEnv(t)
+ wantErr := fmt.Errorf("controld materialize seeded.11ch.at: HTTP 502")
+ materialize := func(context.Context, string) error { return wantErr }
+ err := ensureLiveLink(context.Background(), "seeded.11ch.at", materialize)
+ if err == nil {
+ t.Fatal("expected an error, got nil")
+ }
+ if _, statErr := os.Stat(liveLink("seeded.11ch.at")); statErr == nil {
+ t.Fatal("live symlink should not exist after a failed materialize")
+ }
+}
diff --git a/cloud.go b/cloud.go
index a011b64..d8c6f20 100644
--- a/cloud.go
+++ b/cloud.go
@@ -499,3 +499,43 @@ func controlClient(socket string) *http.Client {
},
}
}
+
+// controldMaterializeFunc builds the router's s3Backend.materialize hook
+// (activator.go): a POST to controld's privileged /instances/{host}/
+// materialize endpoint over its local socket, instead of staging the binary
+// and pointing the live symlink directly — the router runs sandboxed
+// (ProtectSystem=strict, no write access outside /data and /run/lchat) and
+// can't do that write itself; controld always runs unsandboxed as root.
+//
+// Returns nil when the control token isn't available — no systemd
+// LoadCredential=control-token:… on this unit, the case for any box that
+// doesn't run controld (self-hosters, and any box before it's specifically
+// opted into this via the drop-in — see
+// deploy/systemd/lchat-router-control-token.conf). A nil hook means
+// s3Backend.start falls back to the direct calls, today's behavior,
+// unchanged — so this is purely additive for boxes that don't opt in.
+func controldMaterializeFunc() func(ctx context.Context, host string) error {
+ token, err := readControlToken()
+ if err != nil {
+ return nil
+ }
+ socket := envOr("LCHAT_CONTROL_SOCKET", "/run/lchat/control.sock")
+ client := controlClient(socket)
+ return func(ctx context.Context, host string) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://control/instances/"+host+"/materialize", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
+ return fmt.Errorf("controld materialize %s: HTTP %d %s", host, resp.StatusCode, body)
+ }
+ return nil
+ }
+}
diff --git a/controld.go b/controld.go
index f2172ef..9062879 100644
--- a/controld.go
+++ b/controld.go
@@ -103,9 +103,55 @@ func (c *controld) handler() http.Handler {
mux.HandleFunc("DELETE /instances/{host}", c.auth(c.deleteHandler))
mux.HandleFunc("GET /instances/{host}/stats", c.auth(c.statsHandler))
mux.HandleFunc("POST /instances/{host}/quota", c.auth(c.quotaHandler))
+ mux.HandleFunc("POST /instances/{host}/materialize", c.auth(c.materializeHandler))
return mux
}
+// materializeHandler stages the running control binary and points host's live
+// symlink at it, if not already done. This is the privileged half of a route's
+// first wake on a box that never locally created it (control.go's
+// materializeInstance normally does this at add-instance time, but a
+// mesh-replicated route — meshreplicate.go — only ever inserts a registry row,
+// never a symlink, since the receiving box didn't create the space).
+// activator.go's s3Backend.start can't do this itself: the router runs
+// sandboxed (ProtectSystem=strict, no write access outside /data and
+// /run/lchat — see docs/instance-router.md § Hardening), so only this
+// always-unsandboxed root daemon can. Idempotent: safe to call even if the
+// host is already materialized (pointLive happily repoints at the same or a
+// newer version), and safe to call for a host this box didn't create — the
+// row already exists (checked below) is the only prerequisite.
+func (c *controld) materializeHandler(w http.ResponseWriter, r *http.Request) {
+ host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
+ if !validHost(host) {
+ writeControlErr(w, http.StatusBadRequest, "invalid_host", "not a valid hostname")
+ return
+ }
+ if c.reg != nil {
+ rt, err := c.reg.get(host)
+ if err != nil {
+ log.Printf("controld: materialize %s: lookup: %v", host, err)
+ writeControlErr(w, http.StatusInternalServerError, "lookup_failed", "db error")
+ return
+ }
+ if rt == nil {
+ writeControlErr(w, http.StatusNotFound, "unknown_host", "no such route")
+ return
+ }
+ }
+ ver, err := ensureSelfStaged()
+ if err != nil {
+ log.Printf("controld: materialize %s: stage: %v", host, err)
+ writeControlErr(w, http.StatusInternalServerError, "stage_failed", err.Error())
+ return
+ }
+ if err := pointLive(host, ver); err != nil {
+ log.Printf("controld: materialize %s: point live: %v", host, err)
+ writeControlErr(w, http.StatusInternalServerError, "point_live_failed", err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": ver})
+}
+
// quotaHandler applies a storage cap to one instance: upsert
// LCHAT_BLOB_QUOTA_BYTES in its env file (0 = back to the compiled default),
// mirror the billing entitlement alongside it when the caller sends one, and
diff --git a/controld_test.go b/controld_test.go
index 2ebfefb..116ff57 100644
--- a/controld_test.go
+++ b/controld_test.go
@@ -576,3 +576,86 @@ func TestControldCapCountsMaterializedOnlyWhenHibernateCreates(t *testing.T) {
t.Errorf("error = %v, want at_capacity", m["error"])
}
}
+
+// --- materialize (the controld-mediated fix for mesh-replicated routes' first wake) ---
+
+func controlMaterialize(t *testing.T, srv *httptest.Server, token, host string) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest("POST", srv.URL+"/instances/"+host+"/materialize", nil)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+func TestControldMaterializeRequiresToken(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, _ := newTestControld(t, reg, 40)
+ if resp := controlMaterialize(t, srv, "", "a.example.com"); resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("no token: status = %d, want 401", resp.StatusCode)
+ }
+}
+
+func TestControldMaterializeUnknownHost(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, token := newTestControld(t, reg, 40)
+ resp := controlMaterialize(t, srv, token, "never-registered.example.com")
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", resp.StatusCode)
+ }
+ if m := decodeBody(t, resp); m["error"] != "unknown_host" {
+ t.Errorf("error = %v, want unknown_host", m["error"])
+ }
+}
+
+// Mirrors the exact scenario found live 2026-07-13: a route with a registry
+// row but no live symlink (meshreplicate.go's meshRouteHandler only ever
+// upserts the row) must come out materialized — staged binary + live symlink
+// — after one call, ready for s3Backend.start to exec.
+func TestControldMaterializeStagesAndPointsLive(t *testing.T) {
+ reg := controlTestEnv(t)
+ if err := reg.upsert(Route{Host: "seeded.11ch.at", Kind: "instance", Socket: "/run/seeded.sock", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(liveLink("seeded.11ch.at")); err == nil {
+ t.Fatal("test setup: live symlink should not exist yet")
+ }
+
+ srv, token := newTestControld(t, reg, 40)
+ resp := controlMaterialize(t, srv, token, "seeded.11ch.at")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ m := decodeBody(t, resp)
+ if m["ok"] != true || m["version"] == "" {
+ t.Fatalf("body = %v, want ok:true + a version", m)
+ }
+
+ ver, err := liveVersion("seeded.11ch.at")
+ if err != nil || ver == "" {
+ t.Fatalf("liveVersion after materialize: %v, %v — symlink was not created", ver, err)
+ }
+ if _, err := os.Stat(versionBinPath(ver)); err != nil {
+ t.Fatalf("staged binary missing at the version path: %v", err)
+ }
+}
+
+// A second call (the retry a wake would make) must not fail just because the
+// symlink already exists — pointLive happily repoints at the same version.
+func TestControldMaterializeIdempotent(t *testing.T) {
+ reg := controlTestEnv(t)
+ if err := reg.upsert(Route{Host: "seeded2.11ch.at", Kind: "instance", Socket: "/run/seeded2.sock", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ srv, token := newTestControld(t, reg, 40)
+ for i := 0; i < 2; i++ {
+ if resp := controlMaterialize(t, srv, token, "seeded2.11ch.at"); resp.StatusCode != http.StatusOK {
+ t.Fatalf("call %d: status = %d, want 200", i, resp.StatusCode)
+ }
+ }
+}
diff --git a/deploy/systemd/lchat-router-control-token.conf b/deploy/systemd/lchat-router-control-token.conf
new file mode 100644
index 0000000..4720b73
--- /dev/null
+++ b/deploy/systemd/lchat-router-control-token.conf
@@ -0,0 +1,20 @@
+[Service]
+# Optional drop-in: apply ONLY on a box that already runs controld AND opts
+# into the activator mesh (docs/planet-scale.md, mesh.go). Grants the router
+# read-only access to the control token via systemd's LoadCredential — not a
+# filesystem write grant. Without it, a mesh-replicated route (one that only
+# ever gets a registry row on this box, never a live symlink — see
+# meshreplicate.go) fails its first wake here: staging its binary needs a
+# write to /opt/lchat, which this unit's own ProtectSystem=strict sandbox
+# refuses, and only controld's always-unsandboxed root process can do (see
+# activator.go's s3Backend.materialize, controld.go's materializeHandler).
+#
+# Apply:
+# cp deploy/systemd/lchat-router-control-token.conf \
+# /etc/systemd/system/lchat-router.service.d/control-token.conf
+# systemctl daemon-reload && systemctl restart lchat-router
+#
+# Requires /etc/lchat/control.token to already exist (controld already set
+# up first) — LoadCredential fails the WHOLE unit to start if its source path
+# is missing, so do not apply this on a box that doesn't run controld.
+LoadCredential=control-token:/etc/lchat/control.token
diff --git a/router.go b/router.go
index ba2404a..9eaebd9 100644
--- a/router.go
+++ b/router.go
@@ -248,10 +248,20 @@ func runRouter(args []string) {
log.Fatalf("router: %v", err)
}
if hc != nil {
+ // materialize (cloud.go's controldMaterializeFunc) routes a route's
+ // first-wake staging through the control daemon instead of writing
+ // /opt/lchat directly, which this sandboxed process can't do (see
+ // s3Backend.materialize's doc comment). nil — the common case, no
+ // LoadCredential=control-token:… on this unit — falls back to the
+ // direct calls, today's long-standing behavior, unchanged.
+ materialize := controldMaterializeFunc()
+ if materialize == nil {
+ log.Printf("router: no control token available — a route materialized elsewhere (e.g. a mesh-replicated route) will fail its first wake here; see deploy/systemd/lchat-router-control-token.conf")
+ }
// The registry doubles as the due-time index's store (routes.next_due),
// so scheduled deliveries survive router restarts and fire on time even
// for sleeping instances.
- act = newActivator(*hc, &s3Backend{cfg: *hc}, reg)
+ act = newActivator(*hc, &s3Backend{cfg: *hc, materialize: materialize}, reg)
if mesh != nil {
// Wired and beating before act.start(): the sweeper consults the
// mesh from its first tick, and the first request deserves a