server: the control binary self-updates from the signed release feed (#981)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+230
−6
control_selfupdate_test.go+125 −0docs/planet-scale-guide/deploys.html+2 −1docs/secrets.md+7 −5updater.go+89 −0web/status/ssh/2026-07-15-managed-ai-deploys.json+7 −0
diff --git a/control_selfupdate_test.go b/control_selfupdate_test.go| new file mode 100644 |
| index 0000000..e62ab2d |
| --- /dev/null |
| +++ b/control_selfupdate_test.go |
| @@ -0,0 +1,125 @@ |
| +package main |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// selfUpdateEnv points every seam at temp doubles: a fake control binary on |
| +// the same temp "filesystem" the download lands on, a fresh bake state, and a |
| +// recording systemctl. |
| +func selfUpdateEnv(t *testing.T, newBinary []byte) (self string, entry releaseEntry, calls *[]string) { |
| + t.Helper() |
| + oldOpt, oldSelf, oldSysctl, oldChannel := optDir, selfPath, systemctl, os.Getenv("LCHAT_CONTROL_CHANNEL") |
| + oldBuild := buildVersion |
| + buildVersion = "testold1" // the dev-build guard must not fire in these tests |
| + t.Cleanup(func() { |
| + optDir, selfPath, systemctl, buildVersion = oldOpt, oldSelf, oldSysctl, oldBuild |
| + os.Setenv("LCHAT_CONTROL_CHANNEL", oldChannel) |
| + }) |
| + optDir = t.TempDir() |
| + self = filepath.Join(optDir, "lchat") |
| + if err := os.WriteFile(self, []byte("OLD-CONTROL-BINARY"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + selfPath = func() (string, error) { return self, nil } |
| + var c []string |
| + calls = &c |
| + systemctl = func(args ...string) error { |
| + c = append(c, strings.Join(args, " ")) |
| + return nil |
| + } |
| + os.Setenv("LCHAT_CONTROL_CHANNEL", "stable") |
| + |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Write(newBinary) |
| + })) |
| + t.Cleanup(srv.Close) |
| + sum := sha256.Sum256(newBinary) |
| + entry = releaseEntry{Version: "abc1234", URL: srv.URL + "/lchat", SHA256: hex.EncodeToString(sum[:]), Size: int64(len(newBinary)), |
| + ARM64URL: srv.URL + "/lchat-arm64", ARM64SHA256: hex.EncodeToString(sum[:]), ARM64Size: int64(len(newBinary))} |
| + return self, entry, calls |
| +} |
| + |
| +// The swap end to end: verified download, atomic install over the running |
| +// path, a .prev rollback beside it, and the backend services bounced — |
| +// router deliberately untouched. |
| +func TestControlSelfUpdateSwaps(t *testing.T) { |
| + newBin := []byte("NEW-CONTROL-BINARY") |
| + self, entry, calls := selfUpdateEnv(t, newBin) |
| + m := &releaseManifest{Schema: 1, GeneratedAt: time.Now(), Channels: map[string]releaseEntry{"stable": entry}} |
| + |
| + // ignoreBake=true is the deliberate override path; the bake-hold path is |
| + // pinned separately below. |
| + controlSelfUpdate(m, loadFirstSeen(), false, true) |
| + |
| + got, err := os.ReadFile(self) |
| + if err != nil || string(got) != string(newBin) { |
| + t.Fatalf("control binary not swapped: %q %v", got, err) |
| + } |
| + prev, err := os.ReadFile(self + ".prev-" + buildVersion) |
| + if err != nil || string(prev) != "OLD-CONTROL-BINARY" { |
| + t.Fatalf("rollback copy missing/wrong: %q %v", prev, err) |
| + } |
| + joined := strings.Join(*calls, ";") |
| + if !strings.Contains(joined, "try-restart lchat-control.service") || !strings.Contains(joined, "try-restart lchat-cloud.service") { |
| + t.Fatalf("backend services not bounced: %v", *calls) |
| + } |
| + if strings.Contains(joined, "lchat-router") { |
| + t.Fatalf("the router must never restart on the timer's schedule: %v", *calls) |
| + } |
| +} |
| + |
| +// A head still inside its bake window is held — the trust plane is the LAST |
| +// thing on a box that should take a new build. |
| +func TestControlSelfUpdateHonorsBake(t *testing.T) { |
| + self, entry, calls := selfUpdateEnv(t, []byte("NEW")) |
| + m := &releaseManifest{Schema: 1, GeneratedAt: time.Now(), Channels: map[string]releaseEntry{"stable": entry}} |
| + bake := loadFirstSeen() |
| + bake.observe(m, time.Now()) // first sighting is now → inside the window |
| + |
| + controlSelfUpdate(m, bake, false, false) |
| + |
| + if got, _ := os.ReadFile(self); string(got) != "OLD-CONTROL-BINARY" { |
| + t.Fatalf("baking head must not install: %q", got) |
| + } |
| + if len(*calls) != 0 { |
| + t.Fatalf("no restarts while held: %v", *calls) |
| + } |
| +} |
| + |
| +// "off" and already-at-head are silent no-ops; a sha mismatch leaves the |
| +// binary untouched (the verified-download contract, pinned here for the |
| +// control path specifically). |
| +func TestControlSelfUpdateNoOpsAndBadSha(t *testing.T) { |
| + self, entry, _ := selfUpdateEnv(t, []byte("NEW")) |
| + |
| + os.Setenv("LCHAT_CONTROL_CHANNEL", "off") |
| + controlSelfUpdate(&releaseManifest{Channels: map[string]releaseEntry{"stable": entry}}, loadFirstSeen(), false, true) |
| + if got, _ := os.ReadFile(self); string(got) != "OLD-CONTROL-BINARY" { |
| + t.Fatal("off must be a no-op") |
| + } |
| + os.Setenv("LCHAT_CONTROL_CHANNEL", "stable") |
| + |
| + same := entry |
| + same.Version = buildVersion |
| + controlSelfUpdate(&releaseManifest{Channels: map[string]releaseEntry{"stable": same}}, loadFirstSeen(), false, true) |
| + if got, _ := os.ReadFile(self); string(got) != "OLD-CONTROL-BINARY" { |
| + t.Fatal("at-head must be a no-op") |
| + } |
| + |
| + bad := entry |
| + bad.SHA256 = strings.Repeat("00", 32) |
| + bad.ARM64SHA256 = strings.Repeat("00", 32) // whichever arch runs the test |
| + controlSelfUpdate(&releaseManifest{Channels: map[string]releaseEntry{"stable": bad}}, loadFirstSeen(), false, true) |
| + if got, _ := os.ReadFile(self); string(got) != "OLD-CONTROL-BINARY" { |
| + t.Fatal("a sha mismatch must never install") |
| + } |
| +} |
diff --git a/docs/planet-scale-guide/deploys.html b/docs/planet-scale-guide/deploys.html| index 734a614..a75cb93 100644 |
| --- a/docs/planet-scale-guide/deploys.html |
| +++ b/docs/planet-scale-guide/deploys.html |
| @@ -138,7 +138,8 @@ test.example.com stable pinned 4f0a2d19 9c21e77e</span></code></pre> |
| <p><code>lchat update run --dry-run</code> prints what a pass <em>would</em> do without doing it — useful before enabling anything new. Hibernating instances get updates too, by a quieter route: the updater reads their preferences straight from their on-disk database (read-only) and simply repoints their <code>live</code> link, so they come up on the new build at their next wake, with no health check (a bad build on a hibernator is recovered with <code>rollback</code>).</p> |
| <h2 id="sibling-deploys">Deploying the sibling services and the control binary</h2> |
| -<p>The home server and push relay are <em>not</em> versioned instances — they show <code>-</code> in the version column and deploy the plain way: build the <code>home</code> or <code>push</code> binary, copy it over <code>/opt/lchat/home</code> or <code>/opt/lchat/push</code>, and <code>systemctl restart lchat-home</code> (or <code>lchat-push</code>). The control/router binary <code>/opt/lchat/lchat</code> itself is refreshed by copying the new build over it atomically (copy to a temporary name in the same directory, then rename) and restarting <code>lchat-router</code>; the auto-updater does not manage it.</p> |
| +<p>The home server and push relay are <em>not</em> versioned instances — they show <code>-</code> in the version column and deploy the plain way: build the <code>home</code> or <code>push</code> binary, copy it over <code>/opt/lchat/home</code> or <code>/opt/lchat/push</code>, and <code>systemctl restart lchat-home</code> (or <code>lchat-push</code>).</p> |
| +<p><span class="badge live">running today</span> The control/router binary <code>/opt/lchat/lchat</code> <strong>self-updates on the same hourly pass</strong>: after the instances, the updater installs its channel's head over its own path — same signed feed, same sha check, same bake window — keeping the outgoing build beside it as <code>lchat.prev-<build></code> (the one-rename rollback). The channel is <code>LCHAT_CONTROL_CHANNEL</code> in <code>/data/update.env</code> (<code>stable</code> by default — the trust plane takes new builds last; <code>off</code> disables self-update). After a swap the pass restarts <code>lchat-control</code> and <code>lchat-cloud</code> (backend sockets, invisible to members) but <em>never</em> the router — its front door must not blip on a timer's schedule, so <code>lchat-router</code> keeps running the old build until an operator restarts it, and the pass says so in its log. This is also how root-less operator boxes stay current: the manual root <code>cp</code> this replaced stopped being possible when root SSH went bootstrap-only.</p> |
| <h2 id="verifying">Verifying what is deployed where</h2> |
| <figure class="cmd"> |
diff --git a/docs/secrets.md b/docs/secrets.md| index 028cb20..b4c4774 100644 |
| --- a/docs/secrets.md |
| +++ b/docs/secrets.md |
| @@ -84,11 +84,13 @@ private — is `docs/secrets-vault.md`. |
| ## Bots & integrations |
| -- **`ANTHROPIC_API_KEY`** — sidecar env only; bot code never runs in the |
| - server process, so a server compromise doesn't reach it. *Blast radius*: |
| - spend on the Anthropic account. *Rotate*: mint at the Anthropic console, |
| - swap sidecar envs, restart sidecars (note: a server release alone never |
| - restarts sidecars). |
| +- **`ANTHROPIC_API_KEY`** — sidecar envs, plus (since the AI opt-in, #858) |
| + `/data/control.env`: controld's attach-bot step writes it into each fresh |
| + sidecar env it stages, so the provisioning daemon must hold it — bot code |
| + still never runs in the server process, and no *instance* env ever carries |
| + it. *Blast radius*: spend on the Anthropic account. *Rotate*: mint at the |
| + Anthropic console, swap sidecar envs + control.env, restart sidecars and |
| + `lchat-control` (note: a server release alone never restarts sidecars). |
| - **Bot identity keys** — the deliberate exception: real E2EE member keys, |
| held in sidecar session state, able to decrypt exactly the threads the bot |
| was added to. *Rotate*: there is no key rotation — remove the bot member |
diff --git a/updater.go b/updater.go| index 4bf2ee7..104df14 100644 |
| --- a/updater.go |
| +++ b/updater.go |
| @@ -550,9 +550,98 @@ func updatePass(reg *Registry, unitTmpl string, dryRun, ignoreBake bool) error { |
| if !dryRun { |
| gcVersions(reg, m, pinnedVers) |
| } |
| + // Last, the box's own trust plane: the control binary rides the same |
| + // signed feed (see controlSelfUpdate) — after instances, so a build that |
| + // poisons a health check this pass never also becomes the control plane. |
| + controlSelfUpdate(m, bake, dryRun, ignoreBake) |
| return nil |
| } |
| +// controlSelfUpdate keeps the CONTROL binary — /opt/lchat/lchat, the path |
| +// every privileged operation execs through (router, controld, cloud, this |
| +// very updater) — on its own release channel, from the same signed feed the |
| +// instance pass just verified. Before the operator-allowlist hardening this |
| +// was a manual root `cp`; with root SSH gone (docs/instance-router.md § Box |
| +// access), the already-root update pass is the one place the swap can live — |
| +// and it adds no trust the box didn't have: same signature, same sha, same |
| +// bake window as every instance binary. |
| +// |
| +// LCHAT_CONTROL_CHANNEL (in the unit's /data/update.env) picks the channel; |
| +// "stable" is the default — the trust plane should be the LAST thing on a |
| +// box to take a new build — and "off" disables self-update entirely. The |
| +// swap is an atomic same-filesystem rename over the existing path (running |
| +// services keep their old inode until restarted; sudo never sees a missing |
| +// path), with the outgoing build kept beside it as .prev-<build> — the |
| +// rollback is one root rename, or a re-pin of the channel. lchat-control and |
| +// lchat-cloud are bounced to converge immediately (backend sockets, invisible |
| +// to members); the ROUTER is deliberately NOT restarted here — its front door |
| +// should never blip on a timer's schedule, so it runs the old build until an |
| +// operator restarts it (`lchat-svc restart lchat-router`), loudly noted below. |
| +func controlSelfUpdate(m *releaseManifest, bake *firstSeenState, dryRun, ignoreBake bool) { |
| + if buildVersion == "dev" { |
| + // An unstamped binary (local build, test binary) has no place in the |
| + // channel ordering — self-updating it would clobber whatever the |
| + // operator is hand-running. Only release-stamped builds converge. |
| + return |
| + } |
| + channel := envOr("LCHAT_CONTROL_CHANNEL", "stable") |
| + if channel == "off" { |
| + return |
| + } |
| + e, ok := m.Channels[channel] |
| + if !ok || e.Version == buildVersion { |
| + return |
| + } |
| + if hold := bakeHold(bake, channel, e.Version, ignoreBake, time.Now()); hold != "" { |
| + fmt.Printf("update: control binary %s -> %s: %s\n", buildVersion, e.Version, hold) |
| + return |
| + } |
| + if dryRun { |
| + fmt.Printf("update: control binary would update %s -> %s (%s; dry run)\n", buildVersion, e.Version, channel) |
| + return |
| + } |
| + self, err := selfPath() |
| + if err != nil { |
| + fmt.Printf("update: control self-update: %v\n", err) |
| + return |
| + } |
| + tmp, err := downloadReleaseBinary(e) |
| + if err != nil { |
| + fmt.Printf("update: control self-update: %v\n", err) |
| + return |
| + } |
| + defer os.Remove(tmp) |
| + if err := os.Chmod(tmp, 0o755); err != nil { |
| + fmt.Printf("update: control self-update: chmod: %v\n", err) |
| + return |
| + } |
| + // One .prev at a time: the previous previous build is already two behind |
| + // and reachable from the version store anyway. |
| + if old, _ := filepath.Glob(self + ".prev-*"); len(old) > 0 { |
| + for _, p := range old { |
| + os.Remove(p) |
| + } |
| + } |
| + prev := self + ".prev-" + buildVersion |
| + if err := copyFileAtomic(self, prev, 0o755); err != nil { |
| + fmt.Printf("update: control self-update: keep rollback copy: %v\n", err) |
| + return |
| + } |
| + // downloadReleaseBinary lands in incomingDir() — same filesystem as the |
| + // control binary, so this rename atomically replaces the path. |
| + if err := os.Rename(tmp, self); err != nil { |
| + fmt.Printf("update: control self-update: install: %v\n", err) |
| + return |
| + } |
| + fmt.Printf("update: control binary %s -> %s (%s; rollback at %s)\n", buildVersion, e.Version, channel, prev) |
| + for _, unit := range []string{"lchat-control.service", "lchat-cloud.service"} { |
| + if err := systemctl("try-restart", unit); err != nil { |
| + fmt.Printf("update: %s restart: %v (new build applies on its next start)\n", unit, err) |
| + } |
| + } |
| + fmt.Printf("update: lchat-router still runs %s — restart it when convenient to converge\n", buildVersion) |
| +} |
| + |
| // gcVersions prunes version-store dirs nothing references anymore: kept are |
| // every route's current + previous version, every channel head, the control |
| // binary's own build, every pinned version (keepExtra), and whatever any live |
diff --git a/web/status/ssh/2026-07-15-managed-ai-deploys.json b/web/status/ssh/2026-07-15-managed-ai-deploys.json| new file mode 100644 |
| index 0000000..892d3b4 |
| --- /dev/null |
| +++ b/web/status/ssh/2026-07-15-managed-ai-deploys.json |
| @@ -0,0 +1,7 @@ |
| +{ |
| + "date": "2026-07-15", |
| + "operator": "Paul Campbell", |
| + "window": "07:00–10:00 UTC (approx)", |
| + "why": "Managed-AI rollout (Claude session over the operator tunnel): verified box state after the WireGuard flip, staged the new release binary, installed the usage-pipe and AI env additions via lchat-env-install, and restarted the control/cloud services to pick them up.", |
| + "refs": ["https://github.com/elevenmessenger/messenger/pull/867", "https://github.com/elevenmessenger/messenger/pull/877"] |
| +} |