eleven / messenger Public

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>
Paul Campbell 910deca02fd75a0a2b878c345803b591bfc2781d parent 10c44be
6 files changed, +341 −42
  • bootstrap.go +134 −8
  • bootstrap_test.go +129 −0
  • deploy/README.md +13 −2
  • deploy/install.sh +5 −3
  • docs/instance-router.md +17 −1
  • docs/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;