eleven / messenger Public

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>
Paul Campbell b8f2af1a2ec05d3a948c7a9fe08e9bfd4087d586 parent c649c08
5 files changed, +154 −1
  • docs/planet-scale-guide/deploys.html +6 −1
  • install_test.go +67 −0
  • main.go +3 −0
  • versions.go +71 −0
  • web/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-&lt;sha&gt;
+ssh box-1 sudo -n /opt/lchat/lchat stage --file /opt/lchat/incoming/home-&lt;sha&gt; # prints &lt;ver&gt;
+ssh box-1 sudo -n /opt/lchat/lchat install home --version &lt;ver&gt;
+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>&lt;name&gt;.prev-&lt;date&gt;</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-&lt;build&gt;</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"]
+}