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>
2 files changed,
+39
−15
bootstrap.go+20 −4docs/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`, |