server: lchat install — put a staged build onto a sibling service's binary path (#1026)
The hardening closed root shells, which also closed the only way to deploy the home/push/connect binaries (root-owned fixed ExecStart paths). install reopens exactly that as one audited, allowlisted verb: stage any build into the version store (stage --file already records arbitrary binaries under their own version id), then 'lchat install <home|push|connect> --version <ver>' root-copies it onto the unit's path with a <name>.prev-<date> backup beside it. Deliberately never restarts the unit — lchat-svc restart is the operator's visible act. Guide's sibling-deploys section updated; ssh-ledger why-entry for the box inspection that motivated this rides along.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+154
−1
docs/planet-scale-guide/deploys.html+6 −1install_test.go+67 −0main.go+3 −0versions.go+71 −0web/status/ssh/2026-07-15-demo-canary-feasibility.json+7 −0
diff --git a/docs/planet-scale-guide/deploys.html b/docs/planet-scale-guide/deploys.html| index a75cb93..8700629 100644 |
| --- a/docs/planet-scale-guide/deploys.html |
| +++ b/docs/planet-scale-guide/deploys.html |
| @@ -138,7 +138,12 @@ 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>).</p> |
| +<p>The home server and push relay are <em>not</em> versioned instances — they show <code>-</code> in the version column. <span class="badge live">running today</span> On a hardened box (root SSH bootstrap-only) they deploy through the same version store as everything else, via the allowlisted <code>install</code> verb:</p> |
| +<pre>scp ./home box-1:/opt/lchat/incoming/home-<sha> |
| +ssh box-1 sudo -n /opt/lchat/lchat stage --file /opt/lchat/incoming/home-<sha> # prints <ver> |
| +ssh box-1 sudo -n /opt/lchat/lchat install home --version <ver> |
| +ssh box-1 sudo -n /usr/local/sbin/lchat-svc restart lchat-home</pre> |
| +<p><code>install</code> knows the sibling set (<code>home</code>, <code>push</code>, <code>connect</code>), backs the outgoing binary up beside itself as <code><name>.prev-<date></code> (the one-copy rollback), and deliberately does <em>not</em> restart the unit — the restart is the operator's visible act, so an install can be staged ahead of a maintenance window. The version store doesn't care what a binary <em>is</em>: <code>stage --file</code> records any build under its own version id, so sibling builds sit beside messenger builds. Extending the sibling set is a reviewed PR, never a box edit.</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> |
diff --git a/install_test.go b/install_test.go| new file mode 100644 |
| index 0000000..86d23ca |
| --- /dev/null |
| +++ b/install_test.go |
| @@ -0,0 +1,67 @@ |
| +package main |
| + |
| +// `lchat install` (versions.go): staged builds land on sibling-service binary |
| +// paths with a .prev- backup, and the guardrails hold (unknown sibling, |
| +// unstaged version). |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestInstallSibling(t *testing.T) { |
| + old := optDir |
| + optDir = t.TempDir() |
| + defer func() { optDir = old }() |
| + |
| + // Stage a fake build the way stage would lay it out. |
| + ver := "abc1234" |
| + if err := os.MkdirAll(filepath.Dir(versionBinPath(ver)), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(versionBinPath(ver), []byte("new-build"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + // An existing home binary to be replaced. |
| + target := filepath.Join(optDir, "home") |
| + if err := os.WriteFile(target, []byte("old-build"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + msg, err := installSibling("home", ver) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := os.ReadFile(target); string(got) != "new-build" { |
| + t.Fatalf("target = %q, want the staged build", got) |
| + } |
| + // The previous binary survives as a dated .prev- sibling. |
| + prevs, _ := filepath.Glob(target + ".prev-*") |
| + if len(prevs) != 1 { |
| + t.Fatalf("prev backups = %v, want exactly one", prevs) |
| + } |
| + if got, _ := os.ReadFile(prevs[0]); string(got) != "old-build" { |
| + t.Fatalf("backup = %q, want the old build", got) |
| + } |
| + if !strings.Contains(msg, "lchat-svc restart lchat-home") { |
| + t.Fatalf("summary should name the restart: %q", msg) |
| + } |
| + |
| + // First install (no existing binary): no backup, no error. |
| + if _, err := installSibling("push", ver); err != nil { |
| + t.Fatal(err) |
| + } |
| + if prevs, _ := filepath.Glob(filepath.Join(optDir, "push.prev-*")); len(prevs) != 0 { |
| + t.Fatalf("fresh install made a backup: %v", prevs) |
| + } |
| + |
| + // Guardrails. |
| + if _, err := installSibling("router", ver); err == nil { |
| + t.Fatal("unknown sibling must refuse") |
| + } |
| + if _, err := installSibling("home", "def5678"); err == nil { |
| + t.Fatal("unstaged version must refuse") |
| + } |
| +} |
diff --git a/main.go b/main.go| index 151041b..84a1841 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -209,6 +209,9 @@ func main() { |
| case "stage": |
| runStage(os.Args[2:]) |
| return |
| + case "install": |
| + runInstall(os.Args[2:]) |
| + return |
| case "rollback": |
| runRollback(os.Args[2:]) |
| return |
diff --git a/versions.go b/versions.go| index 22201ef..001799e 100644 |
| --- a/versions.go |
| +++ b/versions.go |
| @@ -437,3 +437,74 @@ func unixHTTPClient(socket string) *http.Client { |
| }, |
| } |
| } |
| + |
| +// ---- sibling installs ------------------------------------------------------- |
| + |
| +// siblingBinaries names the box's non-instance service binaries that `lchat |
| +// install` may replace, each with the unit that runs it. These are the fixed |
| +// ExecStart targets of their units — the door the SSH hardening deliberately |
| +// closed for ad-hoc root shells, reopened as one audited, allowlisted verb |
| +// (operators run it via `sudo -n /opt/lchat/lchat install …`). Extending the |
| +// set is a reviewed PR, never a box edit. |
| +var siblingBinaries = map[string]string{ |
| + "home": "lchat-home", |
| + "push": "lchat-push", |
| + "connect": "lchat-connect", |
| +} |
| + |
| +// installSibling swaps one sibling service's binary to a staged version: |
| +// back up the current binary (cp to <name>.prev-<date>, the convention the |
| +// box already uses), then atomically copy the staged build over the unit's |
| +// ExecStart path. It deliberately does NOT restart the unit — the operator |
| +// does that visibly via lchat-svc, so an install can be staged ahead of a |
| +// maintenance window. Returns the human summary to print. |
| +func installSibling(name, ver string) (string, error) { |
| + unit, ok := siblingBinaries[name] |
| + if !ok { |
| + return "", fmt.Errorf("unknown sibling %q (know: home, push, connect)", name) |
| + } |
| + src := versionBinPath(ver) |
| + if _, err := os.Stat(src); err != nil { |
| + return "", fmt.Errorf("version %q is not staged — run `lchat stage --file <binary>` first", ver) |
| + } |
| + target := filepath.Join(optDir, name) |
| + prev := "" |
| + if _, err := os.Stat(target); err == nil { |
| + prev = target + ".prev-" + time.Now().Format("20060102-150405") |
| + if err := copyFileAtomic(target, prev, 0o755); err != nil { |
| + return "", fmt.Errorf("back up current binary: %v", err) |
| + } |
| + } |
| + if err := copyFileAtomic(src, target, 0o755); err != nil { |
| + return "", fmt.Errorf("install: %v", err) |
| + } |
| + msg := fmt.Sprintf("installed %s %s -> %s", name, ver, target) |
| + if prev != "" { |
| + msg += fmt.Sprintf(" (previous at %s)", prev) |
| + } |
| + msg += fmt.Sprintf("\nrestart to pick it up: sudo -n /usr/local/sbin/lchat-svc restart %s", unit) |
| + return msg, nil |
| +} |
| + |
| +// runInstall: `lchat install <name> --version <ver>` — put a staged build onto |
| +// a sibling service's fixed binary path. The staged version store doesn't care |
| +// what a binary IS (stage --file records any build under its own version), so |
| +// sibling binaries ride the same store the messenger versions do. |
| +func runInstall(args []string) { |
| + fs := flag.NewFlagSet("install", flag.ExitOnError) |
| + verFlag := fs.String("version", "", "staged version to install (see `lchat stage`)") |
| + _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| + name := strings.TrimSpace(fs.Arg(0)) |
| + ver := strings.TrimSpace(*verFlag) |
| + if name == "" || ver == "" { |
| + die("install: usage: lchat install <home|push|connect> --version <ver>") |
| + } |
| + if !validVersion(ver) { |
| + die("install: invalid version %q", ver) |
| + } |
| + msg, err := installSibling(name, ver) |
| + if err != nil { |
| + die("install: %v", err) |
| + } |
| + fmt.Println(msg) |
| +} |
diff --git a/web/status/ssh/2026-07-15-demo-canary-feasibility.json b/web/status/ssh/2026-07-15-demo-canary-feasibility.json| new file mode 100644 |
| index 0000000..1afabb1 |
| --- /dev/null |
| +++ b/web/status/ssh/2026-07-15-demo-canary-feasibility.json |
| @@ -0,0 +1,7 @@ |
| +{ |
| + "date": "2026-07-15", |
| + "operator": "Paul Campbell", |
| + "window": "14:30–15:00 UTC (approx)", |
| + "why": "Demo-spaces canary feasibility (Claude session over the operator tunnel): one read-only inspection of /opt/lchat ownership and the lchat-home unit, to determine whether operators can place sibling binaries post-hardening. They can't (root-owned fixed paths) — which motivated the `lchat install` verb shipped alongside this entry.", |
| + "refs": ["https://github.com/elevenmessenger/messenger/pull/883", "https://github.com/elevenmessenger/messenger/pull/979"] |
| +} |