eleven / messenger Public

server: embed status-publish.sh in bootstrap so the box copy can't go stale (#1191)

The two newer status collectors (ssh-ledger, hardening) already ride the
binary and are written by `bootstrap --collectors-only`; the main publish
script did not, so its per-page upload set drifted stale on the box — which
is why funding stopped re-publishing (frozen 18 Jul) and roadmap.html had
no path to go live at all (no operator write to /opt/lchat under the
hardening). Verified the box copy is a strict subset of main (zero box-only
lines), so embedding is a pure catch-up.

- bootstrap.go: //go:embed deploy/status-publish.sh + write it in
  writeCollectorFiles (script only; its 5-min timer is still installed by the
  script's own `install` verb).
- docs/status.md: correct the "diverged / scp it" notes — the script now
  rides the binary; a new page = .html + upload line + a collectors-only
  bootstrap.

Deploy: a release carrying this + `bootstrap --collectors-only` refreshes
both the pages and the publish script, un-freezing funding and publishing
roadmap.html.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d7ed42b2aa479287d35980cfe1bf314fba8d9030 parent 9c13aa4
2 files changed, +39 −15
  • bootstrap.go +20 −4
  • docs/status.md +19 −11
diff --git a/bootstrap.go b/bootstrap.go
index 1b8628b..24b8eba 100644
--- a/bootstrap.go
+++ b/bootstrap.go
@@ -98,6 +98,16 @@ var scriptSSHLedger string
//go:embed deploy/hardening-posture.sh
var scriptHardening string
+// The main publish collector (fleet + books + the viewer-page uploads) rides
+// the binary too, for the same no-root-scp reason — so its per-page upload set
+// stays reconciled with main on every `bootstrap --collectors-only`, instead of
+// drifting stale on the box (which is how funding/roadmap uploads went missing).
+// Only the SCRIPT is embedded; its 5-minute timer is installed separately by the
+// script's own `install` verb (docs/status.md), which bootstrap leaves alone.
+//
+//go:embed deploy/status-publish.sh
+var scriptStatusPublish string
+
//go:embed deploy/systemd/lchat-ssh-ledger.service
var unitSSHLedger string
@@ -192,10 +202,12 @@ func writeFile(path, content string, mode os.FileMode) {
var collectorTimers = []string{"lchat-ssh-ledger.timer", "lchat-hardening.timer"}
// writeCollectorFiles lays down the box-managed status collectors: the two root
-// oneshot scripts (executable), their four systemd units, and the static viewer
-// pages the collectors upload. It writes files ONLY — the caller reloads systemd
-// and enables the timers — so full bootstrap (one daemon-reload covering every
-// unit) and the `--collectors-only` refresh share exactly this install logic.
+// oneshot scripts (executable) + their four systemd units, the main publish
+// script (executable; its timer is installed by the script's own `install`
+// verb, not here), and the static viewer pages the collectors upload. It writes
+// files ONLY — the caller reloads systemd and enables the timers — so full
+// bootstrap (one daemon-reload covering every unit) and the `--collectors-only`
+// refresh share exactly this install logic.
//
// appDir is the on-box install root (/opt/lchat): each unit's ExecStart points at
// <app-dir>/<script>, and the collectors upload the pages from <app-dir>/status-
@@ -216,6 +228,10 @@ func writeCollectorFiles(appDir, unitDir, dataDir string) {
// The scripts are executable — their units fire them via ExecStart.
writeFile(filepath.Join(appDir, "ssh-ledger-collect.sh"), scriptSSHLedger, 0o755)
writeFile(filepath.Join(appDir, "hardening-posture.sh"), scriptHardening, 0o755)
+ // The main publish script rides the binary too so its upload set (the pages
+ // it names, one line each) stays current with main; its own timer, installed
+ // by `status-publish.sh install`, keeps firing this refreshed copy.
+ writeFile(filepath.Join(appDir, "status-publish.sh"), scriptStatusPublish, 0o755)
writeFile(filepath.Join(unitDir, "lchat-ssh-ledger.service"), unitSSHLedger, 0o644)
writeFile(filepath.Join(unitDir, "lchat-ssh-ledger.timer"), unitSSHLedgerTimer, 0o644)
diff --git a/docs/status.md b/docs/status.md
index 0a3be8b..14aeadd 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -546,9 +546,9 @@ connect over the wg admin tunnel, `172.22.22.0/24`). Two halves:
`web/status/ssh-operators.json`; an unmapped fingerprint publishes as
`unknown key` — deliberately render-worthy, an unexplained key is the
signal the page exists to surface. The collector is **deliberately
- standalone** from `deploy/status-publish.sh` (the box's copy of that script
- has diverged from main and is not forced to reconcile for this): it uploads
- its own outputs with the same status-publisher creds (`/data/status.env`).
+ standalone** from `deploy/status-publish.sh` (it uploads its own outputs with
+ the same status-publisher creds, `/data/status.env`, on its own timer — so a
+ publish-script change never gates the ledger).
- **The human "why" — PR-reviewed entry files.** One JSON per entry in
`web/status/ssh/` (changelog-style — parallel PRs never conflict):
`{date, operator, window, why, refs}`. **The convention: any session,
@@ -694,20 +694,28 @@ without touching the box, S3, or the network — no `:22` probe, no upload.
completion key), and `STATUS_DNSIMPLE_TOKEN=…` + `STATUS_DNSIMPLE_ACCOUNT=…`
(a DNSimple account API token — read-only use here, never echoed — plus
the numeric account id). `chown lchat:lchat`, `chmod 0600`.
-3. **Pages on the box**: the viewer `.html` pages, the shared `status.css`, and
- the static page scripts (`waterfall.js`) ride the binary — `lchat bootstrap`
- (or `bootstrap --collectors-only`) writes them all into
- `/opt/lchat/status-pages/`, so there's no scp for them. Only the data JSON +
- icon still copy in (`economics.json`, `funding.json`, `carbon.json`,
+3. **Pages + the publish script on the box**: the viewer `.html` pages, the
+ shared `status.css`, the static page scripts (`waterfall.js`), AND the main
+ `status-publish.sh` itself all ride the binary — `lchat bootstrap` (or
+ `bootstrap --collectors-only`) writes them into `/opt/lchat/status-pages/`
+ (pages) and `/opt/lchat/status-publish.sh` (the script), so there's no scp
+ for them, and the script's per-page upload set can't drift stale on the box
+ (that drift is how funding + roadmap uploads went missing — fixed by
+ embedding the script alongside the other collectors, 2026-07-20). So a new
+ page = add the `.html` to `web/status/` + its `upload` line in
+ `deploy/status-publish.sh`, and a release carrying both + a
+ `bootstrap --collectors-only` is the whole deploy. Only the data JSON + icon
+ still copy in (`economics.json`, `funding.json`, `carbon.json`,
`contributors.json`, `security.json`, `icon-192.png`) — those are
generated/asset files, deliberately not embedded. Post-hardening note for
those copies: the dir is group-writable for `lchat-operators`, but the
*files* in it aren't — replace by scp-ing to `<name>.new` and `mv`-ing over
(directory write allows the rename). `status.css` is the shared stylesheet
every page links (uploaded before the pages by the publish script).
-4. **Install the timer**: `scp deploy/status-publish.sh root@<box>:/opt/lchat/`
- then `ssh root@<box> /opt/lchat/status-publish.sh install`. The unit runs
- as the `lchat` user (least privilege: registry + cloud env are lchat-owned;
+4. **Install the timer** (first stand-up only — the script itself now rides the
+ binary, so this is just its systemd unit): `/opt/lchat/status-publish.sh
+ install` (run once via a root path at bootstrap). The unit runs as the
+ `lchat` user (least privilege: registry + cloud env are lchat-owned;
`systemctl is-active` / `ss` are unprivileged; the journal read rides
`SupplementaryGroups=systemd-journal`).
5. **Verify**: `systemctl list-timers lchat-status.timer`,