deploy: rescue #926 — lchat bootstrap installs its status collectors + viewer pages (#1155)
PR #926 was squash-merged five seconds AFTER its stack base #922 merged, so it landed on the already-dead fleet-hardening-posture branch and never reached main — leaving hardening.html linked-but-403 for three days (the deploy wall masked it: nobody could run the missing bootstrap --collectors-only anyway). This cherry-picks cc526ee3 onto today's main, resolves docs/status.md against the pages added since, and extends the embed to carry waterfall.js (an asset funding.html gained after #926 was written).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+341
−42
bootstrap.go+134 −8bootstrap_test.go+129 −0deploy/README.md+13 −2deploy/install.sh+5 −3docs/instance-router.md+17 −1docs/status.md+43 −28
diff --git a/bootstrap.go b/bootstrap.go| index 03bb109..a349319 100644 |
| --- a/bootstrap.go |
| +++ b/bootstrap.go |
| @@ -1,7 +1,7 @@ |
| package main |
| import ( |
| - _ "embed" |
| + "embed" |
| "flag" |
| "fmt" |
| "os" |
| @@ -85,6 +85,41 @@ var unitUpdate string |
| //go:embed deploy/systemd/lchat-update.timer |
| var unitUpdateTimer string |
| +// The public status collectors (docs/status.md): two root oneshots, each fired |
| +// by a 5-minute timer — the SSH access ledger and the fleet hardening posture. |
| +// They ride the binary (source: deploy/) for the same reason the units do: |
| +// with the fleet hardened (operator SSH over WireGuard only, root login off, |
| +// /opt/lchat + /etc/systemd/system root-owned) there's no root shell to scp |
| +// them onto a box, so the binary must place them itself. See installCollectors. |
| +// |
| +//go:embed deploy/ssh-ledger-collect.sh |
| +var scriptSSHLedger string |
| + |
| +//go:embed deploy/hardening-posture.sh |
| +var scriptHardening string |
| + |
| +//go:embed deploy/systemd/lchat-ssh-ledger.service |
| +var unitSSHLedger string |
| + |
| +//go:embed deploy/systemd/lchat-ssh-ledger.timer |
| +var unitSSHLedgerTimer string |
| + |
| +//go:embed deploy/systemd/lchat-hardening.service |
| +var unitHardening string |
| + |
| +//go:embed deploy/systemd/lchat-hardening.timer |
| +var unitHardeningTimer string |
| + |
| +// statusPagesFS carries the static status viewer pages the collectors upload — |
| +// the .html, the shared status.css, and the static page scripts (waterfall.js). |
| +// The JSON those pages fetch (fleet, carbon, ssh, hardening, contributors, …) |
| +// is collector- and operator-generated and lives mutably on the box, so it is |
| +// deliberately NOT embedded: baking it in would clobber live data with a stale |
| +// snapshot on every deploy. |
| +// |
| +//go:embed web/status/*.html web/status/status.css web/status/waterfall.js |
| +var statusPagesFS embed.FS |
| + |
| // serviceUser is the unprivileged account every unit runs as (see the hardened |
| // units in deploy/systemd). bootstrap creates it and hands it the data + runtime |
| // dirs; the router keeps CAP_NET_BIND_SERVICE to still bind :80/:443. |
| @@ -139,6 +174,74 @@ func ensureControlToken() error { |
| return nil |
| } |
| +// writeFile writes content to path at mode, dying on failure. bootstrap is a |
| +// one-shot root command, so a half-written unit/script means a half-provisioned |
| +// box: fail loud rather than limp on. mode is 0644 for units + pages, 0755 for |
| +// the executable collector scripts. |
| +func writeFile(path, content string, mode os.FileMode) { |
| + if err := os.WriteFile(path, []byte(content), mode); err != nil { |
| + die("bootstrap: write %s: %v", path, err) |
| + } |
| + fmt.Printf(" wrote %s\n", path) |
| +} |
| + |
| +// collectorTimers are the status collectors' timer units. They're oneshot + |
| +// timer (not long-running services), so `enable --now` is the right verb — it |
| +// enables the timer and fires one pass now, with no active service to leave |
| +// stale the way `--now` would for the router. |
| +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. |
| +// |
| +// 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- |
| +// pages. unitDir is the systemd unit directory. |
| +func writeCollectorFiles(appDir, unitDir 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) |
| + |
| + writeFile(filepath.Join(unitDir, "lchat-ssh-ledger.service"), unitSSHLedger, 0o644) |
| + writeFile(filepath.Join(unitDir, "lchat-ssh-ledger.timer"), unitSSHLedgerTimer, 0o644) |
| + writeFile(filepath.Join(unitDir, "lchat-hardening.service"), unitHardening, 0o644) |
| + writeFile(filepath.Join(unitDir, "lchat-hardening.timer"), unitHardeningTimer, 0o644) |
| + |
| + // The collectors upload the viewer pages from here. Only the embedded .html + |
| + // .css land; the JSON the pages fetch is generated on the box (see |
| + // statusPagesFS) and must stay untouched. |
| + pagesDir := filepath.Join(appDir, "status-pages") |
| + if err := os.MkdirAll(pagesDir, 0o755); err != nil { |
| + die("bootstrap: mkdir %s: %v", pagesDir, err) |
| + } |
| + entries, err := statusPagesFS.ReadDir("web/status") |
| + if err != nil { |
| + die("bootstrap: read embedded status pages: %v", err) |
| + } |
| + for _, e := range entries { |
| + content, err := statusPagesFS.ReadFile("web/status/" + e.Name()) |
| + if err != nil { |
| + die("bootstrap: read embedded %s: %v", e.Name(), err) |
| + } |
| + // basename only — strip the web/status/ embed prefix. |
| + writeFile(filepath.Join(pagesDir, e.Name()), string(content), 0o644) |
| + } |
| +} |
| + |
| +// enableCollectorTimers enables + fires the two collector timers. Split from |
| +// writeCollectorFiles so full bootstrap's single daemon-reload can register all |
| +// units at once; the caller must daemon-reload before calling this. |
| +func enableCollectorTimers() { |
| + for _, timer := range collectorTimers { |
| + if err := systemctl("enable", "--now", timer); err != nil { |
| + die("bootstrap: enable %s: %v", timer, err) |
| + } |
| + } |
| +} |
| + |
| // runBootstrap installs the systemd units + runtime dir and starts the router, so |
| // provisioning a fresh box is one command after the binary is copied to |
| // /opt/lchat/lchat. Idempotent — safe to re-run. See docs/instance-router.md. |
| @@ -147,8 +250,28 @@ func runBootstrap(args []string) { |
| dataDir := fs.String("data-dir", "/data", "dir for per-instance DBs, env files, the registry, and the acme cache") |
| unitDir := fs.String("unit-dir", "/etc/systemd/system", "systemd unit directory") |
| noStart := fs.Bool("no-start", false, "install units but don't enable/start the router") |
| + collectorsOnly := fs.Bool("collectors-only", false, "refresh ONLY the status collectors (scripts, units, viewer pages) + enable their timers — no service user, no control token, no router restart") |
| _ = fs.Parse(args) |
| + // --collectors-only is the no-root-shell refresh path: with the fleet |
| + // hardened there's no root scp to drop new collector scripts/pages, so an |
| + // operator runs `sudo -n /opt/lchat/lchat bootstrap --collectors-only` over |
| + // WireGuard (allowlisted by `/opt/lchat/lchat *` in deploy/sudoers-operators). |
| + // It deliberately skips everything that touches long-running services or box |
| + // identity — the service user, the control token, and the router's units / |
| + // enable / restart — so it can't cause a member-facing blip. optDir (the |
| + // install root, /opt/lchat) is where the collectors' units expect their |
| + // scripts + status-pages dir. |
| + if *collectorsOnly { |
| + writeCollectorFiles(optDir, *unitDir) |
| + if err := systemctl("daemon-reload"); err != nil { |
| + die("bootstrap: %v", err) |
| + } |
| + enableCollectorTimers() |
| + fmt.Println("bootstrap: status collectors refreshed (scripts + units + viewer pages; timers enabled)") |
| + return |
| + } |
| + |
| ensureServiceUser() |
| for _, d := range []string{*dataDir, filepath.Join(*dataDir, "acme"), "/run/lchat"} { |
| if err := os.MkdirAll(d, 0o750); err != nil { |
| @@ -170,12 +293,7 @@ func runBootstrap(args []string) { |
| chownToService(*dataDir) |
| chownToService("/run/lchat") |
| - write := func(path, content string) { |
| - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { |
| - die("bootstrap: write %s: %v", path, err) |
| - } |
| - fmt.Printf(" wrote %s\n", path) |
| - } |
| + write := func(path, content string) { writeFile(path, content, 0o644) } |
| write(filepath.Join(*unitDir, "lchat-router.service"), unitRouter) |
| write(filepath.Join(*unitDir, "lchat@.service"), unitInstance) |
| write(filepath.Join(*unitDir, "lchat-bot@.service"), unitBot) |
| @@ -190,6 +308,10 @@ func runBootstrap(args []string) { |
| write(filepath.Join(*unitDir, "lchat-update.service"), unitUpdate) |
| write(filepath.Join(*unitDir, "lchat-update.timer"), unitUpdateTimer) |
| write("/etc/tmpfiles.d/lchat.conf", tmpfilesConf) |
| + // The status collectors ride along here so the single daemon-reload below |
| + // registers their units too; --no-start still lays down the files (like every |
| + // other unit), and the started path enables their timers. |
| + writeCollectorFiles(optDir, *unitDir) |
| if err := ensureControlToken(); err != nil { |
| die("bootstrap: control token: %v", err) |
| @@ -215,6 +337,10 @@ func runBootstrap(args []string) { |
| if err := systemctl("enable", "--now", "lchat-update.timer"); err != nil { |
| die("bootstrap: enable update timer: %v", err) |
| } |
| - fmt.Println("bootstrap: router installed and started (auto-update timer enabled)") |
| + // The collector units + pages were written above and picked up by the reload; |
| + // enable their 5-minute timers (safe everywhere — a pass with no /data/status.env |
| + // creds is a near no-op). |
| + enableCollectorTimers() |
| + fmt.Println("bootstrap: router installed and started (auto-update + status-collector timers enabled)") |
| fmt.Println(" next: lchat add-instance <host>") |
| } |
diff --git a/bootstrap_test.go b/bootstrap_test.go| index 34d1521..1f0a807 100644 |
| --- a/bootstrap_test.go |
| +++ b/bootstrap_test.go |
| @@ -1,6 +1,8 @@ |
| package main |
| import ( |
| + "os" |
| + "path/filepath" |
| "strings" |
| "testing" |
| ) |
| @@ -27,3 +29,130 @@ func TestEmbeddedUnits(t *testing.T) { |
| t.Error("bot unit missing its EnvironmentFile") |
| } |
| } |
| + |
| +// TestEmbeddedCollectors guards that the status collectors ride the binary: the |
| +// scripts + their units are embedded non-empty and match their source (the box |
| +// can no longer scp them in — the whole point of this install path). |
| +func TestEmbeddedCollectors(t *testing.T) { |
| + // A sentinel line from each source script — proves we embedded the real file, |
| + // not an empty/placeholder string, and pins the /opt/lchat ExecStart path the |
| + // units depend on. |
| + if !strings.Contains(scriptSSHLedger, "Installed on the box at exactly /opt/lchat/ssh-ledger-collect.sh") { |
| + t.Error("scriptSSHLedger missing its source sentinel line") |
| + } |
| + if !strings.Contains(scriptHardening, "Public fleet hardening posture — the box-side collector") { |
| + t.Error("scriptHardening missing its source sentinel line") |
| + } |
| + // Both scripts are bash — the shebang must survive the embed so systemd can |
| + // exec them. |
| + for name, s := range map[string]string{"ssh-ledger": scriptSSHLedger, "hardening": scriptHardening} { |
| + if !strings.HasPrefix(s, "#!/usr/bin/env bash") { |
| + t.Errorf("%s script missing its shebang", name) |
| + } |
| + } |
| + // The four units + their ExecStart / timer markers. |
| + for name, u := range map[string]string{ |
| + "ssh-ledger.service": unitSSHLedger, "ssh-ledger.timer": unitSSHLedgerTimer, |
| + "hardening.service": unitHardening, "hardening.timer": unitHardeningTimer, |
| + } { |
| + if strings.TrimSpace(u) == "" { |
| + t.Errorf("unit %s embedded empty", name) |
| + } |
| + } |
| + if !strings.Contains(unitSSHLedger, "ExecStart=/opt/lchat/ssh-ledger-collect.sh") { |
| + t.Error("ssh-ledger unit missing its ExecStart line") |
| + } |
| + if !strings.Contains(unitHardening, "ExecStart=/opt/lchat/hardening-posture.sh") { |
| + t.Error("hardening unit missing its ExecStart line") |
| + } |
| +} |
| + |
| +// TestWriteCollectorFiles drives the shared install helper into temp dirs (no |
| +// systemctl, no /opt/lchat) and asserts every file lands at the right path with |
| +// the right mode: scripts executable (0755), units + pages 0644, and the viewer |
| +// pages written basename-only into <app-dir>/status-pages. |
| +func TestWriteCollectorFiles(t *testing.T) { |
| + appDir := t.TempDir() |
| + unitDir := t.TempDir() |
| + |
| + writeCollectorFiles(appDir, unitDir) |
| + |
| + // Scripts: present, executable, and byte-identical to the embedded source. |
| + for _, tc := range []struct { |
| + name string |
| + want string |
| + }{ |
| + {"ssh-ledger-collect.sh", scriptSSHLedger}, |
| + {"hardening-posture.sh", scriptHardening}, |
| + } { |
| + p := filepath.Join(appDir, tc.name) |
| + assertFileMode(t, p, 0o755) |
| + got, err := os.ReadFile(p) |
| + if err != nil { |
| + t.Fatalf("read %s: %v", tc.name, err) |
| + } |
| + if string(got) != tc.want { |
| + t.Errorf("%s content differs from embedded source", tc.name) |
| + } |
| + } |
| + |
| + // Units: present at 0644 in the unit dir. |
| + for _, u := range []string{ |
| + "lchat-ssh-ledger.service", "lchat-ssh-ledger.timer", |
| + "lchat-hardening.service", "lchat-hardening.timer", |
| + } { |
| + assertFileMode(t, filepath.Join(unitDir, u), 0o644) |
| + } |
| + |
| + // Pages: every embedded .html + status.css, basename-only, at 0644 — and |
| + // nothing else (no ssh/ dir, no JSON). |
| + pagesDir := filepath.Join(appDir, "status-pages") |
| + for _, page := range []string{ |
| + "index.html", "hardening.html", "ssh.html", "status.css", |
| + } { |
| + assertFileMode(t, filepath.Join(pagesDir, page), 0o644) |
| + } |
| + for _, unexpected := range []string{"carbon.json", "ssh-operators.json", "icon-192.png"} { |
| + if _, err := os.Stat(filepath.Join(pagesDir, unexpected)); err == nil { |
| + t.Errorf("status-pages should not contain generated/non-embedded %s", unexpected) |
| + } |
| + } |
| +} |
| + |
| +// TestEnableCollectorTimers checks the helper enables + fires both timers. |
| +func TestEnableCollectorTimers(t *testing.T) { |
| + oldSysctl := systemctl |
| + t.Cleanup(func() { systemctl = oldSysctl }) |
| + var calls []string |
| + systemctl = func(args ...string) error { |
| + calls = append(calls, strings.Join(args, " ")) |
| + return nil |
| + } |
| + |
| + enableCollectorTimers() |
| + |
| + want := []string{ |
| + "enable --now lchat-ssh-ledger.timer", |
| + "enable --now lchat-hardening.timer", |
| + } |
| + if len(calls) != len(want) { |
| + t.Fatalf("systemctl calls = %v, want %v", calls, want) |
| + } |
| + for i, w := range want { |
| + if calls[i] != w { |
| + t.Errorf("call %d = %q, want %q", i, calls[i], w) |
| + } |
| + } |
| +} |
| + |
| +// assertFileMode asserts a file exists with exactly the given permission bits. |
| +func assertFileMode(t *testing.T, path string, want os.FileMode) { |
| + t.Helper() |
| + fi, err := os.Stat(path) |
| + if err != nil { |
| + t.Fatalf("stat %s: %v", path, err) |
| + } |
| + if got := fi.Mode().Perm(); got != want { |
| + t.Errorf("%s mode = %o, want %o", path, got, want) |
| + } |
| +} |
diff --git a/deploy/README.md b/deploy/README.md| index a271d95..f912f52 100644 |
| --- a/deploy/README.md |
| +++ b/deploy/README.md |
| @@ -21,8 +21,19 @@ ssh root@<box> '/opt/lchat/lchat add-instance chat.example.com' |
| ``` |
| `lchat bootstrap` embeds the unit files (source `deploy/systemd/`) and writes |
| -them, creates `/data` + `/run/lchat`, and starts the router. To install by hand |
| -instead: `scp deploy/systemd/*.service` to `/etc/systemd/system/`, drop a |
| +them, creates `/data` + `/run/lchat`, and starts the router. It also installs |
| +the **public status collectors** — the SSH access ledger + fleet hardening |
| +posture scripts (`deploy/{ssh-ledger-collect,hardening-posture}.sh`), their four |
| +systemd units, and the static viewer pages (`web/status/*.html` + `status.css`, |
| +into `/opt/lchat/status-pages/`) — and enables their 5-minute timers. These ride |
| +the binary because a hardened box has no root shell to scp them onto (operator |
| +SSH is WireGuard-only; `/opt/lchat` + `/etc/systemd/system` are root-owned). To |
| +refresh just the collectors + pages on a live box without touching the router, |
| +run `sudo -n /opt/lchat/lchat bootstrap --collectors-only` (allowlisted by |
| +`deploy/sudoers-operators`) — no member-facing blip. The collector-generated |
| +JSON (fleet/ssh/hardening/… data) stays mutable on the box; only the `.html` + |
| +`.css` viewer assets are embedded. To install by hand instead: `scp |
| +deploy/systemd/*.service` to `/etc/systemd/system/`, drop a |
| `/etc/tmpfiles.d/lchat.conf` (`d /run/lchat 0710 root root -`), `daemon-reload`, |
| `systemctl enable --now lchat-router`. |
diff --git a/deploy/install.sh b/deploy/install.sh| index 386d563..6d7ee03 100755 |
| --- a/deploy/install.sh |
| +++ b/deploy/install.sh |
| @@ -6,9 +6,11 @@ |
| # Downloads the latest STABLE release for this machine's architecture from the |
| # public releases repo, verifies its sha256 against the published checksum, |
| # installs it at /opt/lchat/lchat, and runs `lchat bootstrap` (idempotent: it |
| -# installs/refreshes the systemd units, creates /data, and enables the router |
| -# and the hourly auto-update timer). Re-running this script updates the binary |
| -# and refreshes the units; it never touches /data, so it's safe on a live box. |
| +# installs/refreshes the systemd units, creates /data, enables the router and |
| +# the hourly auto-update timer, and installs the public status collectors + |
| +# their viewer pages + 5-minute timers). Re-running this script updates the |
| +# binary and refreshes the units; it never touches /data, so it's safe on a |
| +# live box. |
| # |
| # After install, the box keeps itself current from the stable channel — see |
| # docs/self-hosting.md in the elevenmessenger/messenger repo. |
diff --git a/docs/instance-router.md b/docs/instance-router.md| index 154b92d..d108be7 100644 |
| --- a/docs/instance-router.md |
| +++ b/docs/instance-router.md |
| @@ -412,6 +412,20 @@ That's the whole dependency story for the Go side — nothing to `apt install`, |
| version to keep patched, no runtime to manage. A bare box with *no* Go on it is |
| not a concern; it's the point. |
| +🤖 **The public status collectors ride the binary too.** `bootstrap` also lays |
| +down the SSH-access-ledger and fleet-hardening collector scripts, their four |
| +systemd units, and the static viewer pages (`web/status/*.html` + `status.css`, |
| +into `/opt/lchat/status-pages/`), and enables their 5-minute timers — all |
| +embedded, source-of-truth `deploy/`. This closes the gap the fleet hardening |
| +opened: with operator SSH on WireGuard-only and `/opt/lchat` + |
| +`/etc/systemd/system` root-owned, there's no root shell to scp collectors onto a |
| +box, so the binary must place them. To refresh just the collectors + pages on a |
| +live box — no root shell, no router restart, no member-facing blip — run |
| +`sudo -n /opt/lchat/lchat bootstrap --collectors-only` (allowlisted by |
| +`deploy/sudoers-operators`'s `/opt/lchat/lchat *`). Only the `.html` + `.css` |
| +viewer assets are embedded; the collector-generated JSON stays mutable on the |
| +box. Details: `docs/status.md`. |
| + |
| **There is no runtime dependency — not even for bots.** The sidecar is now the |
| same `lchat` binary (`lchat bot run`; see *Bots*), so a box that runs the server |
| can run its bots with nothing else installed. The Node reference (`agent/`) |
| @@ -782,7 +796,9 @@ touching anything real. |
| ## Deploy tooling (replaces deploy/miren.sh) |
| - `lchat bootstrap` — one-time box setup: write systemd units, make dirs, start |
| - the router (no toolchain needed — just the copied binary). |
| + the router, and install the status collectors + viewer pages + their timers |
| + (no toolchain needed — just the copied binary). `--collectors-only` refreshes |
| + just the collectors/pages on a live box (no root shell, no router restart). |
| - `lchat add-instance <host>` — create + start + warm cert (async; `instances` |
| shows status). Stages the running binary + points the host's `live` link first. |
| - `lchat stage --file <binary>` — register a built binary in the version store. |
diff --git a/docs/status.md b/docs/status.md| index c28ee80..0a3be8b 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -572,17 +572,25 @@ root attacker could stop or falsify the collector). |
| Deploying it (the box side; needs `jq` — `apt install jq`): |
| -1. `scp deploy/ssh-ledger-collect.sh root@<box>:/opt/lchat/` (the unit's |
| - `ExecStart` expects that exact path) and |
| - `scp deploy/systemd/lchat-ssh-ledger.{service,timer} root@<box>:/etc/systemd/system/`. |
| -2. `ssh root@<box> 'systemctl daemon-reload && systemctl enable --now lchat-ssh-ledger.timer'`. |
| -3. Stage the viewer + entries with the other pages: |
| - `scp web/status/ssh.html web/status/ssh-operators.json root@<box>:/opt/lchat/status-pages/` |
| - and `scp web/status/ssh/*.json root@<box>:/opt/lchat/status-pages/ssh/` |
| - (make the `ssh/` dir first; the README stays repo-only). Re-run the entries |
| - scp whenever a why-entry lands — a session published before its entry |
| - arrives shows "no summary recorded" until the next collect pass after the |
| - copy. |
| +1. **The collector, its units, and the viewer page ride the binary.** `lchat |
| + bootstrap` (fresh box) or `sudo -n lchat bootstrap --collectors-only` |
| + (refresh over WireGuard — no root shell) writes |
| + `/opt/lchat/ssh-ledger-collect.sh`, its `lchat-ssh-ledger.{service,timer}` |
| + units, and the viewer `ssh.html` into `/opt/lchat/status-pages/`, |
| + `daemon-reload`s, and enables the timer. There is no root scp — the hardened |
| + box has no root shell to copy files in, so the install path is the binary |
| + itself. (`--collectors-only` skips the router entirely, so a refresh causes |
| + no member-facing blip.) |
| +2. **Creds**: the collector uploads with the status-publisher creds in |
| + `/data/status.env` (same key the status publisher uses) — see *Install |
| + (router box)*. |
| +3. **The operator data JSON is NOT embedded** (it changes independently of the |
| + binary): the fingerprint map `ssh-operators.json` and the PR-reviewed |
| + why-entries (`web/status/ssh/*.json`) are collector/operator-produced and |
| + land in `/opt/lchat/status-pages/` (and `…/ssh/`) via the operator's normal |
| + status-page copy, not `bootstrap`. A session published before its entry |
| + arrives shows "no summary recorded", and an unmapped fingerprint publishes |
| + as `unknown key`, until the map/entry lands and the next collect pass runs. |
| 4. **Verify**: `systemctl list-timers lchat-ssh-ledger.timer`, |
| `journalctl -u lchat-ssh-ledger -n 20`, then load `ssh.html` (rows appear |
| only once a session has closed and gone 20 minutes quiet). |
| @@ -643,19 +651,21 @@ the collector. The bucket is versioned, so after-the-fact edits leave history. |
| Deploying it (the box side; needs `jq` — `apt install jq`): |
| -1. `scp deploy/hardening-posture.sh root@<box>:/opt/lchat/` (the unit's |
| - `ExecStart` expects that exact path) and |
| - `scp deploy/systemd/lchat-hardening.{service,timer} root@<box>:/etc/systemd/system/`. |
| -2. `ssh root@<box> 'systemctl daemon-reload && systemctl enable --now lchat-hardening.timer'`. |
| -3. Stage the viewer page with the other status pages: |
| - `scp web/status/hardening.html root@<box>:/opt/lchat/status-pages/`. The |
| - collector uploads it (and `hardening.json`) to the status bucket on its next |
| - pass; the shared `status.css` is already published by `status-publish.sh`. |
| +1. **The collector, its units, and the viewer page ride the binary.** `lchat |
| + bootstrap` (fresh box) or `sudo -n lchat bootstrap --collectors-only` |
| + (refresh over WireGuard — no root shell) writes |
| + `/opt/lchat/hardening-posture.sh`, its `lchat-hardening.{service,timer}` |
| + units, and `hardening.html` into `/opt/lchat/status-pages/`, |
| + `daemon-reload`s, and enables the timer. There is no root scp — the hardened |
| + box has no root shell to copy files in. |
| +2. **Creds**: the collector reads mesh membership and uploads with the |
| + status-publisher creds in `/data/status.env` (those need read access to the |
| + hibernation bucket — the same read the Fleet page relies on). |
| +3. `hardening.json` is **generated on the box** by the collector each pass — |
| + never embedded; only the viewer `hardening.html` rides the binary (the |
| + shared `status.css` too, published alongside). |
| 4. **Verify**: `systemctl list-timers lchat-hardening.timer`, |
| - `journalctl -u lchat-hardening -n 20`, then load `hardening.html`. The |
| - collector reads mesh membership with the same `/data/status.env` creds it |
| - uploads with, so those creds need read access to the hibernation bucket — the |
| - same read `status-publish.sh`'s Fleet page already relies on. |
| + `journalctl -u lchat-hardening -n 20`, then load `hardening.html`. |
| Test mode is documented in the script header: point every input at a fixture |
| (`HARDENING_UFW_CMD`, `HARDENING_SSHD_CMD`, `HARDENING_SYSTEMCTL_CMD`, |
| @@ -684,12 +694,17 @@ 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**: `scp web/status/{status.css,index.html,books.html,economics.html,economics.json,funding.html,funding.json,waterfall.js,carbon.html,carbon.json,contributors.html,contributors.json,security.html,security.json,prompts.html,milestones.html,icon-192.png} |
| - root@<box>:/opt/lchat/status-pages/`. `status.css` is the shared stylesheet |
| +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`, |
| + `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). |
| - Post-hardening note: 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). |
| 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; |