eleven / messenger Public

deploy+status: mesh boxes merge each other's self-reported posture — no more "not reported" for collector-running peers (#1181)

Each collector now uploads its self row to posture/<label>.json in the status
bucket; when building peer rows, a box prefers a peer's fresh self-report over
the probe-only unknowns — except ssh_internet_facing, where an SSH banner seen
from outside vetoes any self-claim (a compromised box must not self-report its
exposure away). This is also what lets N collector boxes share one
hardening.json without flip-flopping viewpoints: every writer builds the same
merged rows. Plus the first hermetic test that actually RUNS the collector
script end to end — the class of coverage whose absence cost four consecutive
first-real-box failures today.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 22a25dfa1e3cf346d92c4dd23120e50f574d9048 parent 69c71a5
4 files changed, +195 −3
  • deploy/hardening-posture.sh +50 −0
  • package.json +1 −1
  • test/hardening-posture.test.mjs +136 −0
  • web/status/hardening.html +8 −2
diff --git a/deploy/hardening-posture.sh b/deploy/hardening-posture.sh
index 6be88ea..4bc8aab 100755
--- a/deploy/hardening-posture.sh
+++ b/deploy/hardening-posture.sh
@@ -371,6 +371,34 @@ classify_probe() {
esac
}
+# peer_posture <label> — a peer's own self-published posture row, if fresh.
+# Every box running this collector uploads its self row to
+# "posture/<label>.json" in the status bucket (see the upload section), so any
+# box can read back every other box's AUTHORITATIVE local read instead of
+# stopping at the outside :22 hint. This is also what lets N collector boxes
+# share the one hardening.json without flip-flopping viewpoints: whichever box
+# uploads last, it built the same merged rows from the same self-reports.
+# Freshness gates on the row's own `generated` stamp — a box that stopped
+# collecting (or died) falls back to the probe view within POSTURE_STALE_SECONDS.
+# Test seam: HARDENING_POSTURE_DIR serves "<label>.json" files instead of S3.
+POSTURE_STALE_SECONDS=${HARDENING_POSTURE_STALE_SECONDS:-900}
+peer_posture() {
+ local label=$1 f gen now age
+ [ -n "$label" ] || return 0
+ f="$TMP/posture-$label.json"
+ if [ -n "${HARDENING_POSTURE_DIR:-}" ]; then
+ cat "$HARDENING_POSTURE_DIR/$label.json" 2>/dev/null || true
+ return 0
+ fi
+ aws s3 cp "s3://$BUCKET/posture/$label.json" "$f" --region "$REGION" >/dev/null 2>&1 || return 0
+ gen=$(jq -r '.generated // empty' "$f" 2>/dev/null) || true
+ [ -n "$gen" ] || return 0
+ now=$(date -u +%s)
+ age=$(( now - $(date -u -d "$gen" +%s 2>/dev/null || echo 0) ))
+ [ "$age" -ge 0 ] && [ "$age" -le "$POSTURE_STALE_SECONDS" ] || return 0
+ cat "$f"
+}
+
# ---- 3. build the boxes array ------------------------------------------------
echo '[]' > "$TMP/boxes.json"
add_box() { # $1 = a JSON object string
@@ -392,6 +420,9 @@ add_self() {
firewall_active:$fwactive, source:"local"}
+ (if $region == "" then {} else {region:$region} end)')
add_box "$obj"
+ # The same row, stamped, becomes this box's self-report for PEERS to merge
+ # (posture/<label>.json — see peer_posture). Uploaded in section 4.
+ printf '%s' "$obj" | jq --arg now "$NOW_ISO" '. + {generated:$now}' > "$TMP/self-posture.json"
self_added=1
}
@@ -416,6 +447,22 @@ while IFS= read -r row; do
verdict=${probe_out%%$'\t'*}; banner=${probe_out#*$'\t'}
fi
classify_probe "$verdict" "$banner"
+ # Prefer the peer's own fresh self-report over the probe-only row — but the
+ # probe still has veto power the loud way: an SSH banner WE saw from outside
+ # overrides whatever the peer claims about itself (a compromised or
+ # misconfigured box must not be able to self-report its exposure away).
+ # Every other field (root login, wireguard, firewall…) only the box itself
+ # can know, so the self-report carries them verbatim.
+ posture=$(peer_posture "$label")
+ if [ -n "$posture" ]; then
+ obj=$(printf '%s' "$posture" | jq \
+ --argjson pfacing "$FACING" --arg pdetail "$DETAIL" \
+ 'del(.generated)
+ + {is_self:false, source:"self-reported"}
+ + (if $pfacing == true then {ssh_internet_facing:true, detail:$pdetail} else {} end)')
+ add_box "$obj"
+ continue
+ fi
[ -n "$label" ] || label="unidentified box"
obj=$(jq -n \
--arg label "$label" --arg region "$region" \
@@ -460,6 +507,9 @@ if [ "$upload" = 1 ]; then
up() { aws s3 cp "$1" "s3://$BUCKET/$2" --region "$REGION" \
--content-type "$3" --cache-control "$4" --no-progress >/dev/null; }
up "$OUT" hardening.json "application/json" "max-age=60"
+ # The self-report peers merge (peer_posture) — short cache: it's read
+ # box-to-box via s3 cp, so the cache header only matters if a human fetches it.
+ [ -f "$TMP/self-posture.json" ] && up "$TMP/self-posture.json" "posture/$SELF_LABEL.json" "application/json" "max-age=60"
[ -f "$PAGES_DIR/hardening.html" ] && up "$PAGES_DIR/hardening.html" hardening.html "text/html; charset=utf-8" "max-age=300"
echo "hardening posture published to s3://$BUCKET ($NOW_ISO)"
fi
diff --git a/package.json b/package.json
index fa3ce79..9df2d13 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs"
+ "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs"
}
}
diff --git a/test/hardening-posture.test.mjs b/test/hardening-posture.test.mjs
new file mode 100644
index 0000000..4fd261f
--- /dev/null
+++ b/test/hardening-posture.test.mjs
@@ -0,0 +1,136 @@
+// 🤖 Hermetic run of deploy/hardening-posture.sh — the box-side hardening
+// collector — through its documented fixture seams. This exists because the
+// collector shipped fixture-checked but never run, and its first FOUR real-box
+// encounters failed (lost merge #1155, ReadWritePaths namespacing #1159,
+// AF_NETLINK #1171, IAM prefix scope #1176): the parsing was always right and
+// the plumbing always wrong. This test at least pins the whole script path —
+// enumerate → probe/merge → sort → output JSON — end to end in one process.
+// Requires bash + jq (same toolchain the status-contract fixtures assume).
+// node test/hardening-posture.test.mjs
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+let failures = 0;
+function test(name, fn) {
+ try {
+ fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+const script = new URL("../deploy/hardening-posture.sh", import.meta.url).pathname;
+
+// Run the collector against fixtures; return the parsed hardening.json.
+// members: TSV rows; posture: {label: rowObject} self-reports peers published.
+// The default ufw fixture proves "wireguard-only": active, a 22-on-wg0 allow,
+// and NO bare public 22 — the shape firewall_ssh_scope requires for a hardened
+// (false) self verdict rather than the loud unknown.
+const UFW_HARDENED = [
+ "Status: active",
+ "",
+ "To Action From",
+ "-- ------ ----",
+ "80,443/tcp ALLOW Anywhere",
+ "22/tcp on wg0 ALLOW Anywhere",
+ "51820/udp ALLOW Anywhere",
+].join("\n");
+
+function collect({ members, posture = {}, ufw = UFW_HARDENED }) {
+ const dir = mkdtempSync(join(tmpdir(), "hardening-test-"));
+ const state = join(dir, "state"), pages = join(dir, "pages"), postures = join(dir, "posture");
+ mkdirSync(state); mkdirSync(pages); mkdirSync(postures);
+ writeFileSync(join(dir, "members.tsv"), members);
+ writeFileSync(join(dir, "router.env"),
+ "LCHAT_MESH_SELF=https://self.example\nLCHAT_BOX_REGION=EU · Finland\n");
+ writeFileSync(join(dir, "ufw.txt"), ufw + "\n");
+ writeFileSync(join(dir, "sshd.txt"), "permitrootlogin no\npasswordauthentication no\n");
+ writeFileSync(join(dir, "au.txt"), "active\n");
+ writeFileSync(join(dir, "wg0.txt"), "42: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP>\n");
+ for (const [label, row] of Object.entries(posture)) {
+ writeFileSync(join(postures, label + ".json"), JSON.stringify(row));
+ }
+ execFileSync("bash", [script], {
+ env: {
+ ...process.env,
+ HARDENING_UFW_CMD: `cat ${join(dir, "ufw.txt")}`,
+ HARDENING_SSHD_CMD: `cat ${join(dir, "sshd.txt")}`,
+ HARDENING_SYSTEMCTL_CMD: `cat ${join(dir, "au.txt")}`,
+ HARDENING_IP_CMD: `cat ${join(dir, "wg0.txt")}`,
+ HARDENING_ROUTER_ENV: join(dir, "router.env"),
+ HARDENING_MEMBERS_FILE: join(dir, "members.tsv"),
+ HARDENING_POSTURE_DIR: postures,
+ HARDENING_STATE_DIR: state,
+ HARDENING_PAGES_DIR: pages,
+ HARDENING_NO_UPLOAD: "1",
+ },
+ });
+ return JSON.parse(readFileSync(join(pages, "hardening.json"), "utf8"));
+}
+
+const SELF_ROW = "https://self.example\t\t\t\n";
+const kepler = (probe) => `https://198.51.100.7\tkepler-conducting\tUK · London\t${probe}\n`;
+
+test("probe-only peer renders unknowns, sorted by exposure", () => {
+ const out = collect({ members: SELF_ROW + kepler("serving-ssh") });
+ assert.equal(out.boxes.length, 2);
+ const [first, second] = out.boxes;
+ assert.equal(first.label, "kepler-conducting"); // exposed sorts first
+ assert.equal(first.ssh_internet_facing, true);
+ assert.equal(first.source, "probe");
+ assert.equal(first.root_login, "unknown");
+ assert.equal(second.is_self, true);
+ assert.equal(second.ssh_internet_facing, false); // wg up + no public 22 in ufw
+ assert.equal(second.firewall_active, true);
+});
+
+test("fresh peer self-report replaces the probe row's unknowns", () => {
+ const out = collect({
+ members: SELF_ROW + kepler("not-reachable"),
+ posture: {
+ "kepler-conducting": {
+ label: "kepler-conducting", is_self: true, source: "local",
+ ssh_internet_facing: false, detail: "wireguard-only",
+ root_login: "no", password_auth: "no", auto_updates: "active",
+ wireguard: "up", firewall_active: true, region: "UK · London",
+ generated: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
+ },
+ },
+ });
+ const peer = out.boxes.find((b) => b.label === "kepler-conducting");
+ assert.equal(peer.source, "self-reported");
+ assert.equal(peer.is_self, false); // reader's perspective wins
+ assert.equal(peer.root_login, "no");
+ assert.equal(peer.wireguard, "up");
+ assert.equal(peer.firewall_active, true);
+ assert.equal(peer.ssh_internet_facing, false);
+ assert.equal(peer.generated, undefined); // stamp stays in posture/, not the page
+});
+
+test("an outside SSH banner vetoes a self-report claiming closed", () => {
+ const out = collect({
+ members: SELF_ROW + kepler("serving-ssh"),
+ posture: {
+ "kepler-conducting": {
+ label: "kepler-conducting", is_self: true, source: "local",
+ ssh_internet_facing: false, detail: "wireguard-only",
+ root_login: "no", password_auth: "no", auto_updates: "active",
+ wireguard: "up", firewall_active: true,
+ generated: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
+ },
+ },
+ });
+ const peer = out.boxes.find((b) => b.label === "kepler-conducting");
+ assert.equal(peer.source, "self-reported");
+ assert.equal(peer.ssh_internet_facing, true); // the banner we saw wins
+ assert.match(peer.detail, /serving SSH/);
+ assert.equal(peer.root_login, "no"); // the rest stays self-reported
+ assert.equal(out.boxes[0].label, "kepler-conducting"); // and it still sorts as exposed
+});
+
+process.exit(failures ? 1 : 0);
diff --git a/web/status/hardening.html b/web/status/hardening.html
index 2d58625..b4d3247 100644
--- a/web/status/hardening.html
+++ b/web/status/hardening.html
@@ -167,8 +167,9 @@
// Which self-reported sub-states to show, in order, and how each value maps to
// a good / warn / bad / muted state. Only keys PRESENT on a box render — a
- // peer (outside probe) carries just the three it might one day self-report, as
- // "not reported"; the local box carries all five with real values.
+ // probe-only peer carries just three, as "not reported"; a box that runs the
+ // collector carries all five with real values (its own row via source
+ // "local", and on every OTHER box's upload via the merged "self-reported").
const SUBS = [
["root_login", "root login"],
["password_auth", "password login"],
@@ -271,6 +272,11 @@
note.className = "probe-note";
note.textContent = "Seen from another box in the mesh — this box’s own report appears once it runs the collector.";
li.appendChild(note);
+ } else if (box.source === "self-reported") {
+ const note = document.createElement("p");
+ note.className = "probe-note";
+ note.textContent = "Self-reported by this box; its port 22 is also probed from the mesh outside.";
+ li.appendChild(note);
}
list.appendChild(li);