eleven / messenger Public

status: fleet hardening posture dashboard β€” hardening.html + box collector (#922)

* deploy: fleet hardening posture collector + timer

Standalone root collector (mirrors ssh-ledger-collect.sh): reads the LOCAL
box's SSH posture from OS state (ufw / sshd -T / systemd / wg0), enumerates the
mesh from the hibernation bucket, and banner-grab probes each peer's :22 from
the outside. Writes hardening.json (problems sorted first, no hostnames) +
uploads it with the status-publisher creds. Unknown is always a loud warn,
never a guessed pass. Documented test-mode seams; fixture-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* status: fleet hardening dashboard page + nav + docs

hardening.html renders each box's SSH posture: a calm βœ“ chip when not
internet-facing, a loud bordered warn when :22 is reachable, and a muted
"not reported" chip for anything unknown (state by word+weight+icon, never
colour alone; WCAG-AA, tabular-nums, pluralised+localised counts). Wires the
"Fleet hardening" nav link into all nine status pages, bumps the stale
eight-item comments in status.css, adds the docs/status.md section and a
member-facing changelog entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paul Campbell 561b5fe8156032be19278a361cbeefc49cb0ce9b parent 27ff2d7
15 files changed, +916 −3
  • changelog/2026-07-14-fleet-hardening-dashboard.md +10 −0
  • deploy/hardening-posture.sh +455 −0
  • deploy/systemd/lchat-hardening.service +39 −0
  • deploy/systemd/lchat-hardening.timer +13 −0
  • docs/status.md +72 −0
  • web/status/books.html +1 −0
  • web/status/carbon.html +1 −0
  • web/status/contributors.html +1 −0
  • web/status/economics.html +1 −0
  • web/status/hardening.html +316 −0
  • web/status/index.html +1 −0
  • web/status/prompts.html +1 −0
  • web/status/security.html +1 −0
  • web/status/ssh.html +1 −0
  • web/status/status.css +3 −3
diff --git a/changelog/2026-07-14-fleet-hardening-dashboard.md b/changelog/2026-07-14-fleet-hardening-dashboard.md
new file mode 100644
index 0000000..29ef6c6
--- /dev/null
+++ b/changelog/2026-07-14-fleet-hardening-dashboard.md
@@ -0,0 +1,10 @@
+πŸ€– The public status site now shows, for every server in the fleet, whether SSH
+is locked down β€” at status.elevenmessenger.com/hardening.html. Each machine
+reports on itself: is its firewall keeping remote logins to the private admin
+tunnel rather than the open internet, is root login off, are automatic security
+updates running. Boxes are also checked from the outside, so a server that left
+its SSH door open to the world is impossible to miss β€” it sorts straight to the
+top. Anything the check can't read is flagged loudly rather than assumed safe.
+Like the access ledger, this is transparency, not a lock: it holds the fleet to
+its own hardening bar in the open, right down at the machines your messages
+pass through.
diff --git a/deploy/hardening-posture.sh b/deploy/hardening-posture.sh
new file mode 100755
index 0000000..5cbd2c8
--- /dev/null
+++ b/deploy/hardening-posture.sh
@@ -0,0 +1,455 @@
+#!/usr/bin/env bash
+# πŸ€– Public fleet hardening posture β€” the box-side collector (docs/status.md Β§
+# The fleet hardening dashboard). Every 5 minutes (lchat-hardening.timer) this
+# publishes, per box in the mesh, whether SSH is locked down: the LOCAL box
+# reads its own OS state authoritatively (ufw / sshd / systemd / wireguard),
+# and each PEER box is probed from the outside β€” a connect-and-banner-grab :22
+# check, never a login. It writes hardening.json and uploads it + hardening.html
+# to the public status bucket the site serves at hardening.html. Installed on
+# the box at exactly /opt/lchat/hardening-posture.sh (the unit's ExecStart
+# points there).
+#
+# ============================ WHAT THIS PUBLISHES ============================
+# Everything here is PUBLIC, forever (the bucket is versioned). Two design
+# points make that safe, and one is the whole reason the page exists:
+# - NO HOSTNAMES. A box is named by its whimsical, hash-derived label
+# ("hypatia-whistling", the SAME label fleet.json shows) and a coarse region
+# ("EU Β· Finland"). A peer's real hostname is read only to point the :22
+# probe at it and dies in this process β€” it never reaches the JSON, matching
+# the status site's no-hostnames rule (deploy/status-publish.sh's header).
+# - UNKNOWN IS LOUD, NEVER A GUESSED PASS. Any signal the collector can't read
+# is emitted as "unknown" (a null ssh_internet_facing, or an "unknown"
+# sub-state) and the page renders it as a warn state β€” never silently as
+# hardened. An unreadable firewall must look unresolved, not safe.
+# - THE POINT: an unhardened box surfaces publicly. If a mesh box is serving
+# SSH to the internet, its :22 probe reads back an "SSH-…" banner and it
+# sorts to the TOP of the page. Transparency, not a security control.
+#
+# ---------------------- The peer :22 probe is VANTAGE-DEPENDENT --------------
+# An external probe sees the fleet from THIS box's viewpoint only: a peer's
+# firewall might allow this box but deny everyone else, or vice-versa. So the
+# probe is classified conservatively β€” only a returned SSH banner is a CONFIDENT
+# "exposed":
+# - reads back "SSH-…" -> exposed (true) [confident BAD]
+# - clean refusal / connect times out -> not reachable (false) [good]
+# - connects but no SSH banner, or anything that isn't a clean refusal
+# -> inconclusive (null) [UNKNOWN β€” loud warn,
+# neither a pass nor a confirmed fail]
+# The AUTHORITATIVE signal is a box self-reading its own ufw once it runs this
+# collector; the probe is a best-effort outside check that makes a blatantly
+# open box impossible to miss. The page says as much in its caveat.
+# ============================================================================
+#
+# This script is DELIBERATELY STANDALONE from deploy/status-publish.sh (the
+# box's copy of that script has diverged from main and must not be forced to
+# reconcile for this feature): it keeps its own state, writes its own outputs,
+# and uploads them itself with the same status-publisher creds. It reads the
+# mesh membership from the hibernation bucket exactly as status-publish.sh's
+# mesh_peer_boxes does, so the two never need to share code.
+#
+# Usage:
+# hardening-posture.sh # what the timer runs
+#
+# Test mode (no ufw/sshd/systemd/ip, no S3, no :22 probe, no upload): point
+# every input at a fixture and pin "now". The command seams eval a shell command
+# (so a fixture is just `cat file` printing pre-captured tool output):
+# HARDENING_UFW_CMD='cat ufw.txt' # replaces `ufw status`
+# HARDENING_SSHD_CMD='cat sshd-T.txt' # replaces `sshd -T`
+# HARDENING_SYSTEMCTL_CMD='cat au.txt' # replaces `systemctl is-active unattended-upgrades`
+# HARDENING_IP_CMD='cat wg0.txt' # replaces `ip link show wg0`
+# HARDENING_ROUTER_ENV=router.env # LCHAT_MESH_SELF / LCHAT_BOX_REGION seams
+# HARDENING_MEMBERS_FILE=members.tsv # replaces the S3 listing + stats + :22 probe
+# HARDENING_NOW=<epoch> HARDENING_NO_UPLOAD=1 \
+# HARDENING_STATE_DIR=/tmp/state HARDENING_PAGES_DIR=/tmp/pages ./hardening-posture.sh
+#
+# HARDENING_MEMBERS_FILE is a TSV, one mesh member per line, four columns:
+# <peerURL> <TAB> <label> <TAB> <region> <TAB> <probe>
+# where <probe> is the peer's :22 verdict β€” serving-ssh | not-reachable |
+# inconclusive (aliases reachable | filtered-or-closed | unknown are accepted).
+# It is consulted only for PEERS; the row whose <peerURL> equals LCHAT_MESH_SELF
+# is the local box and uses the LOCAL OS reads instead. Setting
+# HARDENING_MEMBERS_FILE also forces no-upload, so a fixture run is always inert.
+#
+# Prerequisites on the box: jq (apt install jq) + the aws CLI and
+# /data/status.env creds already there for the status publisher. The creds must
+# also be able to READ the hibernation bucket (LCHAT_HIBERNATE_BUCKET) β€” the
+# same read status-publish.sh's fleet page already relies on.
+set -euo pipefail
+
+STATE_DIR=${HARDENING_STATE_DIR:-/data/hardening}
+PAGES_DIR=${HARDENING_PAGES_DIR:-/opt/lchat/status-pages}
+# The PUBLIC status bucket β€” upload target for hardening.json + hardening.html,
+# the same bucket (and creds) status-publish.sh and ssh-ledger-collect.sh use.
+BUCKET=${STATUS_BUCKET:-eleven-status-427880590669}
+REGION=${STATUS_REGION:-eu-west-1}
+STATUS_ENV=${STATUS_ENV_FILE:-/data/status.env}
+# The router's own env β€” read (same file the router loads) only for the mesh's
+# bucket/prefix/region and this box's own identity/region. Absent
+# LCHAT_HIBERNATE_BUCKET just means the mesh isn't configured here: the page
+# then shows this one box, byte-consistent with a single-box fleet.
+ROUTER_ENV=${HARDENING_ROUTER_ENV:-/data/router.env}
+# Staleness cutoff for a membership object β€” three missed beats, mirroring
+# mesh.go's meshStaleAfter (3 Γ— meshHeartbeatInterval, 60s). A box whose
+# heartbeat has aged past this is presumed dead and dropped, so the page never
+# lists a long-gone box (its S3 membership key lingers until lifecycle prunes
+# it, but its LastModified stops advancing).
+STALE_SECONDS=${HARDENING_MESH_STALE_SECONDS:-180}
+# Probe budget: PROBE_TIMEOUT bounds the whole connect+banner-read; BANNER_READ
+# is how long we wait for the SSH banner once connected (kept comfortably under
+# PROBE_TIMEOUT so a fast connect always leaves room to read).
+PROBE_TIMEOUT=${HARDENING_PROBE_TIMEOUT:-6}
+BANNER_READ=${HARDENING_BANNER_READ:-5}
+
+OUT="$PAGES_DIR/hardening.json"
+
+command -v jq >/dev/null 2>&1 || { echo "jq missing (apt install jq)" >&2; exit 1; }
+mkdir -p "$STATE_DIR" "$PAGES_DIR"
+
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+
+NOW=${HARDENING_NOW:-$(date -u +%s)}
+
+# iso <epoch> β€” GNU date on the box; the -r fallback keeps test mode runnable on
+# a BSD/macOS dev machine.
+iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ; }
+NOW_ISO=$(iso "$NOW")
+
+# atomic_write <src> <dest> β€” temp beside the target + mv, so a reader (or a
+# mid-write upload) never sees a half-written JSON. Same-directory temp keeps
+# the rename atomic (never across filesystems).
+atomic_write() { cp "$1" "$2.tmp.$$" && mv "$2.tmp.$$" "$2"; }
+
+# env_str <file> <VAR> β€” one string value from a dotenv file (bucket names,
+# prefixes, the mesh self URL). Empty when the file or var is absent. Mirrors
+# status-publish.sh's env_str exactly so the two read router.env identically.
+env_str() { grep -E "^$2=" "$1" 2>/dev/null | head -n1 | cut -d= -f2- || true; }
+
+# b64url_decode reverses encodeHeartbeatKey (internal/activator/heartbeat.go):
+# url-safe alphabet, padding stripped β€” re-add it (len mod 4 tells how much)
+# before the standard decoder. Same helper, same reason, as status-publish.sh.
+b64url_decode() {
+ local s
+ s=$(printf '%s' "$1" | tr -- '-_' '+/')
+ case $(( ${#s} % 4 )) in
+ 2) s="${s}==" ;;
+ 3) s="${s}=" ;;
+ esac
+ printf '%s' "$s" | base64 -d 2>/dev/null
+}
+
+# The local-OS reads are behind command seams (HARDENING_*_CMD): eval a shell
+# command so a test can point each at a `cat fixture` printing pre-captured tool
+# output. Real mode runs the actual tool. These vars are operator/test set β€” the
+# same trust boundary as this root script itself.
+run_ufw() { if [ -n "${HARDENING_UFW_CMD:-}" ]; then eval "$HARDENING_UFW_CMD"; else ufw status 2>/dev/null; fi; }
+run_sshd() { if [ -n "${HARDENING_SSHD_CMD:-}" ]; then eval "$HARDENING_SSHD_CMD"; else sshd -T 2>/dev/null; fi; }
+run_autoupd(){ if [ -n "${HARDENING_SYSTEMCTL_CMD:-}" ]; then eval "$HARDENING_SYSTEMCTL_CMD"; else systemctl is-active unattended-upgrades 2>/dev/null; fi; }
+run_ip_wg() { if [ -n "${HARDENING_IP_CMD:-}" ]; then eval "$HARDENING_IP_CMD"; else ip link show wg0 2>/dev/null; fi; }
+
+# host_of <peerURL> β€” the bare host a :22 probe dials, scheme + port + any path
+# stripped. IPv6 literals ([::1]:80) are handled so a colon in the address is
+# never mistaken for the port separator.
+host_of() {
+ local u=${1#*://} # drop scheme://
+ u=${u%%/*} # drop /path
+ case "$u" in
+ \[*\]*) u=${u%%\]*}; printf '%s' "${u#\[}" ;; # [ipv6]:port -> ipv6
+ *:*) printf '%s' "${u%:*}" ;; # host:port -> host
+ *) printf '%s' "$u" ;;
+ esac
+}
+
+# box_label β€” this box's stable, whimsical, anonymous name ("hypatia-whistling"),
+# an exact port of deploy/status-publish.sh's box_label (and meshstats.go's Go
+# twin): sha256 of /etc/machine-id, first 16 hex chars split into two 8-char
+# slices indexing the names/actions lists. The lists MUST stay identical to
+# those two β€” same hash, same label, whichever side computes it β€” so a peer's
+# self-published label and this box's local one agree. Falls back to `hostname`
+# when the machine-id file is unreadable, matching bash's twin byte-for-byte.
+box_label() {
+ local names=(ada alan grace marie nikola leonardo cleopatra homer sappho
+ darwin einstein curie turing hopper tesla gandalf frodo sherlock watson
+ ahab ishmael gatsby scout atticus hermione matilda mowgli merlin arthur
+ morgana beowulf odysseus penelope achilles athena apollo freya thor loki
+ amelia boudicca cyrano quixote sancho ophelia viola rosalind puck oberon
+ titania darcy pip scrooge nemo phileas zorro heidi galileo kepler newton
+ archimedes hypatia confucius rumi frida vincent wolfgang ludwig clara)
+ local actions=(stargazing juggling whistling painting sailing gardening
+ baking skating humming doodling fishing knitting strolling yodeling
+ beekeeping fencing waltzing sketching brewing climbing cartwheeling
+ birdwatching pondering inventing exploring composing conducting
+ spelunking kayaking orienteering puppeteering tinkering)
+ local idfile=${HARDENING_MACHINE_ID_FILE:-/etc/machine-id}
+ local seed
+ seed=$( { cat "$idfile" 2>/dev/null || hostname; } | sha256sum | cut -c1-16)
+ local n=$(( 16#${seed:0:8} % ${#names[@]} ))
+ local a=$(( 16#${seed:8:8} % ${#actions[@]} ))
+ printf '%s-%s' "${names[$n]}" "${actions[$a]}"
+}
+
+# ---- 1. the LOCAL box's posture, from OS state (root reads) -------------------
+# The local box answers about itself authoritatively β€” never self-probe :22 (it
+# would hairpin through the host's own NAT and read nothing meaningful).
+
+ufw_out=$(run_ufw || true)
+first_line=$(printf '%s\n' "$ufw_out" | head -n1)
+case "$first_line" in *"Status: active"*) firewall_active=true ;; *) firewall_active=false ;; esac
+
+# firewall_ssh_scope: hardened ("wireguard-only") iff there is NO bare
+# "22/tcp ... ALLOW ... Anywhere" rule (public port 22) AND there IS a "22/tcp
+# ... on wg0 ... ALLOW" rule (SSH reachable only over the admin tunnel).
+# "internet-facing" the moment a bare 22 allow exists. Anything else β€” ufw
+# inactive/unreadable, or active but with no recognizable 22 rule at all β€” is
+# "unknown": we can't PROVE wireguard-only, and the contract says unproven reads
+# loud, never as a silent pass. Matches both `ufw status` (ALLOW) and
+# `ufw status verbose` (ALLOW IN) by keying on the "ALLOW" substring.
+bare22=$(printf '%s\n' "$ufw_out" | awk '
+ /^22(\/tcp)?[ \t]/ && /ALLOW/ && /Anywhere/ && !/wg0/ { f=1 } END { print f+0 }')
+wg22=$(printf '%s\n' "$ufw_out" | awk '
+ /^22(\/tcp)?[ \t]+on[ \t]+wg0/ && /ALLOW/ { f=1 } END { print f+0 }')
+if [ "$firewall_active" != true ] || [ -z "$ufw_out" ]; then
+ firewall_ssh_scope=unknown
+elif [ "$bare22" = 1 ]; then
+ firewall_ssh_scope=internet-facing
+elif [ "$wg22" = 1 ]; then
+ firewall_ssh_scope=wireguard-only
+else
+ firewall_ssh_scope=unknown
+fi
+
+# root_login / password_auth from sshd's EFFECTIVE config (`sshd -T`) β€” the
+# resolved values, not whatever a config file happens to say. Missing line
+# (sshd -T failed, e.g. not root) -> unknown, never assumed safe.
+sshd_out=$(run_sshd || true)
+root_login=$(printf '%s\n' "$sshd_out" | awk '$1=="permitrootlogin"{print $2; exit}')
+[ -n "$root_login" ] || root_login=unknown
+password_auth=$(printf '%s\n' "$sshd_out" | awk '$1=="passwordauthentication"{print $2; exit}')
+[ -n "$password_auth" ] || password_auth=unknown
+
+# auto_updates: unattended-upgrades active/inactive; anything else (failed,
+# absent, systemctl missing) -> unknown.
+au_out=$(printf '%s' "$(run_autoupd || true)" | tr -d '[:space:]')
+case "$au_out" in
+ active) auto_updates=active ;;
+ inactive) auto_updates=inactive ;;
+ *) auto_updates=unknown ;;
+esac
+
+# wireguard: wg0 present and up. `ip link show wg0` is empty/non-zero when the
+# device is absent; a wireguard interface reports its admin state via the UP
+# flag (often "state UNKNOWN" for L3 virtual links), so match the UP flag or a
+# literal "state UP".
+wg_out=$(run_ip_wg || true)
+if [ -z "$wg_out" ]; then
+ wireguard=absent
+elif printf '%s' "$wg_out" | grep -qE 'state UP|[<,]UP[,>]'; then
+ wireguard=up
+else
+ wireguard=down
+fi
+
+# Fold the local firewall scope into the shared verdict fields every box row
+# carries: ssh_internet_facing is a TRISTATE β€” true (exposed), false (hardened),
+# or JSON null (unknown -> the page's loud "not reported").
+case "$firewall_ssh_scope" in
+ wireguard-only) self_facing=false; self_detail="wireguard-only" ;;
+ internet-facing) self_facing=true; self_detail="port 22 open to the internet" ;;
+ *) self_facing=null; self_detail="firewall status not readable" ;;
+esac
+
+SELF=$(env_str "$ROUTER_ENV" LCHAT_MESH_SELF)
+SELF=$(printf '%s' "$SELF" | tr -d '[:space:]'); SELF=${SELF%/} # trim like meshFromEnv
+SELF_LABEL=$(box_label)
+SELF_REGION=$(env_str "$ROUTER_ENV" LCHAT_BOX_REGION)
+
+# ---- 2. enumerate the mesh + probe peers -------------------------------------
+# enumerate_members prints one TSV row per live mesh member:
+# <peerURL> <TAB> <label> <TAB> <region> <TAB> <probe-hint>
+# The probe-hint is empty in real mode (the loop runs the real :22 probe); a
+# fixture supplies it directly. A member's label/region come from its
+# self-published stats object (the same one fleet.json reads).
+enumerate_members() {
+ if [ -n "${HARDENING_MEMBERS_FILE:-}" ]; then
+ cat "$HARDENING_MEMBERS_FILE"
+ return
+ fi
+ local bucket prefix region mesh_prefix
+ bucket=$(env_str "$ROUTER_ENV" LCHAT_HIBERNATE_BUCKET)
+ prefix=$(env_str "$ROUTER_ENV" LCHAT_HIBERNATE_PREFIX)
+ [ -n "$bucket" ] && [ -n "$prefix" ] || return # mesh off -> no peers, just self
+ region=$(env_str "$ROUTER_ENV" LCHAT_HIBERNATE_REGION); region=${region:-eu-west-1}
+ # Must match hc.meshPrefix() (activator.go): membership at
+ # "<prefix>/mesh/members/<peer>", stats at "<prefix>/mesh/members-stats/<peer>".
+ mesh_prefix="${prefix}/mesh/members"
+
+ # `aws s3 ls` (high-level), never `aws s3api list-objects*` β€” the latter
+ # crashes on this fleet's aws-cli/Python combination (a botocore/argparse
+ # regression), same reason status-publish.sh gives. `s3 ls` prints
+ # "YYYY-MM-DD HH:MM:SS SIZE KEY"; the key is the b64url leaf.
+ local listing cutoff line date_part time_part key peer statsfile label reg
+ listing=$(aws s3 ls "s3://$bucket/${mesh_prefix}/" --region "$region" 2>/dev/null) || true
+ [ -n "$listing" ] || return
+ cutoff=$(date -u -d "-${STALE_SECONDS} seconds" '+%Y-%m-%d %H:%M:%S')
+
+ while IFS= read -r line; do
+ [ -n "$line" ] || continue
+ date_part=$(printf '%s' "$line" | awk '{print $1}')
+ time_part=$(printf '%s' "$line" | awk '{print $2}')
+ key=$(printf '%s' "$line" | awk '{print $4}')
+ [ -n "$key" ] || continue
+ [ "$date_part $time_part" '>' "$cutoff" ] || continue # stale -> dead box, skip
+ peer=$(b64url_decode "$key"); [ -n "$peer" ] || continue
+ # Join the sibling stats object for label + region (same values fleet.json
+ # shows). Absent/garbage stats -> empty; the loop labels it "unidentified".
+ statsfile="$TMP/stats-$(printf '%s' "$key" | tr '/+=' '___').json"
+ label=""; reg=""
+ if aws s3 cp "s3://$bucket/${mesh_prefix}-stats/$key" "$statsfile" --region "$region" >/dev/null 2>&1; then
+ label=$(jq -r '.label // empty' "$statsfile" 2>/dev/null || true)
+ reg=$(jq -r '.region // empty' "$statsfile" 2>/dev/null || true)
+ fi
+ printf '%s\t%s\t%s\t\n' "$peer" "$label" "$reg"
+ done <<< "$listing"
+}
+
+# probe_ssh22 <host> β€” connect-and-banner-grab of :22 from THIS box (an outside
+# viewpoint for a peer). Echoes "<verdict>\t<banner>". See the VANTAGE-DEPENDENT
+# note above: only a returned "SSH-…" banner is a confident "exposed"; a clean
+# refusal or a connect timeout is "not reachable" (good); a completed connection
+# with no SSH banner β€” or anything else that isn't a clean refusal β€” is
+# "inconclusive" (loud unknown, never a pass). No bytes are sent; no login is
+# attempted. Prefer bash /dev/tcp for the connect + banner read.
+probe_ssh22() {
+ local host=$1 out banner
+ [ -n "$host" ] || { printf 'inconclusive\t'; return; }
+ # Resolve first so an NXDOMAIN reads as "inconclusive" (couldn't probe),
+ # distinct from a resolvable host that cleanly refuses :22.
+ getent hosts "$host" >/dev/null 2>&1 || { printf 'inconclusive\t'; return; }
+ out=$(timeout "$PROBE_TIMEOUT" bash -c '
+ h=$1
+ exec 3<>/dev/tcp/"$h"/22 2>/dev/null || { printf "REFUSED"; exit 0; }
+ b=""
+ IFS= read -r -n 60 -t "'"$BANNER_READ"'" b <&3 2>/dev/null || true
+ exec 3>&- 2>/dev/null || true
+ if [ -n "$b" ]; then printf "BANNER\t%s" "$b"; else printf "NOBANNER"; fi
+ ' _ "$host" 2>/dev/null) || out=""
+ case "$out" in
+ BANNER*)
+ banner=${out#BANNER}; banner=${banner#$'\t'}
+ banner=$(printf '%s' "$banner" | tr -cd '[:print:]' | cut -c1-48)
+ case "$banner" in
+ SSH-*) printf 'serving-ssh\t%s' "$banner" ;;
+ *) printf 'inconclusive\t' ;; # answered, but not an SSH banner
+ esac ;;
+ NOBANNER) printf 'inconclusive\t' ;; # connected, silent β€” vantage-dependent
+ REFUSED) printf 'not-reachable\t' ;; # clean refusal β€” good
+ *) printf 'not-reachable\t' ;; # empty = connect timed out β€” good
+ esac
+}
+
+# classify_probe <verdict> [banner] -> sets FACING (true/false/null) + DETAIL.
+# Inconclusive maps to null so the page renders it loud, per the contract.
+classify_probe() {
+ case "$1" in
+ serving-ssh|reachable)
+ FACING=true
+ if [ -n "${2:-}" ]; then DETAIL="port 22 serving SSH ($2)"; else DETAIL="port 22 serving SSH"; fi ;;
+ not-reachable|filtered-or-closed)
+ FACING=false; DETAIL="port 22 not reachable" ;;
+ *)
+ FACING=null; DETAIL="port 22 filtered/inconclusive" ;;
+ esac
+}
+
+# ---- 3. build the boxes array ------------------------------------------------
+echo '[]' > "$TMP/boxes.json"
+add_box() { # $1 = a JSON object string
+ jq --argjson b "$1" '. + [$b]' "$TMP/boxes.json" > "$TMP/boxes.next" \
+ && mv "$TMP/boxes.next" "$TMP/boxes.json"
+}
+
+self_added=0
+add_self() {
+ local obj
+ obj=$(jq -n \
+ --arg label "$SELF_LABEL" --arg region "$SELF_REGION" \
+ --argjson facing "$self_facing" --arg detail "$self_detail" \
+ --arg root "$root_login" --arg pass "$password_auth" \
+ --arg auto "$auto_updates" --arg wg "$wireguard" \
+ --argjson fwactive "$firewall_active" \
+ '{label:$label, is_self:true, ssh_internet_facing:$facing, detail:$detail,
+ root_login:$root, password_auth:$pass, auto_updates:$auto, wireguard:$wg,
+ firewall_active:$fwactive, source:"local"}
+ + (if $region == "" then {} else {region:$region} end)')
+ add_box "$obj"
+ self_added=1
+}
+
+# Split each TSV row by tab MANUALLY (not `read -r a b c d`): tab is IFS
+# whitespace, so read collapses consecutive tabs and an empty column (a peer
+# with no region) would silently shift the later fields left. Parameter
+# expansion on a fixed 4-field (3-tab) row preserves every empty.
+while IFS= read -r row; do
+ [ -n "$row" ] || continue
+ peer=${row%%$'\t'*}; rest=${row#*$'\t'}
+ label=${rest%%$'\t'*}; rest=${rest#*$'\t'}
+ region=${rest%%$'\t'*}; hint=${rest#*$'\t'}
+ if [ -n "$SELF" ] && [ "$peer" = "$SELF" ]; then
+ add_self
+ continue
+ fi
+ # A peer: probe its :22 (a fixture hint wins in test mode).
+ if [ -n "$hint" ]; then
+ verdict=$hint; banner=""
+ else
+ probe_out=$(probe_ssh22 "$(host_of "$peer")")
+ verdict=${probe_out%%$'\t'*}; banner=${probe_out#*$'\t'}
+ fi
+ classify_probe "$verdict" "$banner"
+ [ -n "$label" ] || label="unidentified box"
+ obj=$(jq -n \
+ --arg label "$label" --arg region "$region" \
+ --argjson facing "$FACING" --arg detail "$DETAIL" \
+ '{label:$label, is_self:false, ssh_internet_facing:$facing, detail:$detail,
+ root_login:"unknown", auto_updates:"unknown", wireguard:"unknown",
+ source:"probe"}
+ + (if $region == "" then {} else {region:$region} end)')
+ add_box "$obj"
+done < <(enumerate_members)
+
+# Self may not have appeared in the membership listing (mesh off, or this box's
+# own heartbeat hasn't landed yet) β€” always publish the local box's own row.
+[ "$self_added" = 1 ] || add_self
+
+# Sort problems to the TOP: internet-facing (true) first, then inconclusive
+# (null), then hardened (false); ties broken by label for a stable order. The
+# loud rows a viewer needs to see never hide below the calm ones.
+jq --arg now "$NOW_ISO" \
+ '{generated: $now,
+ boxes: sort_by(
+ (if .ssh_internet_facing == true then 0
+ elif .ssh_internet_facing == null then 1
+ else 2 end),
+ (.label // ""))}' \
+ "$TMP/boxes.json" > "$TMP/hardening.json"
+atomic_write "$TMP/hardening.json" "$OUT"
+
+# ---- 4. upload ---------------------------------------------------------------
+# Standalone by design (see header): uploads its own outputs with the same
+# scoped status-publisher creds status-publish.sh uses. A fixture run (members
+# fixture) or an explicit HARDENING_NO_UPLOAD=1 never uploads. Re-uploading the
+# page every run self-heals a missed copy, same as the other publishers.
+upload=1
+[ "${HARDENING_NO_UPLOAD:-}" = "1" ] && upload=0
+[ -n "${HARDENING_MEMBERS_FILE:-}" ] && upload=0
+if [ "$upload" = 1 ]; then
+ command -v aws >/dev/null 2>&1 || { echo "aws CLI missing β€” collected, not uploaded" >&2; exit 1; }
+ if [ -z "${AWS_ACCESS_KEY_ID:-}" ] && [ -f "$STATUS_ENV" ]; then
+ set -a; . "$STATUS_ENV"; set +a
+ fi
+ 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"
+ [ -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/deploy/systemd/lchat-hardening.service b/deploy/systemd/lchat-hardening.service
new file mode 100644
index 0000000..9d2720b
--- /dev/null
+++ b/deploy/systemd/lchat-hardening.service
@@ -0,0 +1,39 @@
+[Unit]
+Description=lchat public fleet hardening posture (one collect pass)
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+# Optional overrides: STATUS_BUCKET / STATUS_REGION (mirror status-publish.sh).
+EnvironmentFile=-/data/status.env
+# "+" = outside the sandbox: both ReadWritePaths must exist before
+# ProtectSystem=strict mounts them, or the first-ever run fails to namespace.
+ExecStartPre=+/usr/bin/mkdir -p /data/hardening /opt/lchat/status-pages
+ExecStart=/opt/lchat/hardening-posture.sh
+
+# --- hardening (see docs/instance-router.md Β§ Hardening) ---
+# Runs as ROOT deliberately: the LOCAL box's own posture reads are root-only
+# (sshd -T's effective config, ufw status, ip link), and /data/hardening +
+# /opt/lchat/status-pages are its only writes β€” ProtectSystem=strict pins
+# exactly those. It is fired by lchat-hardening.timer, never routed or
+# listening, and publishes NO hostnames (see the script's header). The peer :22
+# probe is an outbound connect only.
+ProtectSystem=strict
+ReadWritePaths=/data/hardening /opt/lchat/status-pages
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectClock=yes
+ProtectHostname=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+NoNewPrivileges=yes
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+RestrictNamespaces=yes
+RestrictRealtime=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+MemoryMax=256M
+TasksMax=64
diff --git a/deploy/systemd/lchat-hardening.timer b/deploy/systemd/lchat-hardening.timer
new file mode 100644
index 0000000..6a0cee8
--- /dev/null
+++ b/deploy/systemd/lchat-hardening.timer
@@ -0,0 +1,13 @@
+[Unit]
+Description=5-minute public fleet hardening posture collect
+
+[Timer]
+# A short boot delay so the network + wg0 are settled before the first pass; the
+# random spread keeps this off the same second as the status publisher and the
+# SSH-ledger collect (45s here vs the ledger's 30s, so the two never collide).
+OnBootSec=2min
+OnUnitActiveSec=5min
+RandomizedDelaySec=45s
+
+[Install]
+WantedBy=timers.target
diff --git a/docs/status.md b/docs/status.md
index 395a73a..32e868f 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -580,6 +580,78 @@ A new operator key = add its fingerprint to `web/status/ssh-operators.json` by
PR *and* re-scp it, or their sessions publish as `unknown key`. Rotating the
box's SSH setup doesn't touch the ledger β€” it only reads the journal.
+## The fleet hardening dashboard
+
+πŸ€– Per box in the mesh, whether SSH is locked down β€” on the public record
+(`hardening.html` + `hardening.json`), the access ledger's transparency idea
+one level down at the machines themselves. Prompted by the fleet growing a
+second mesh box that could still have public SSH / root login open: an
+unhardened box must be *visible*, not buried in an operator's head. Two halves,
+mirroring the ledger's automatic-truth split:
+
+- **The local self-read (authoritative).** `deploy/hardening-posture.sh`
+ (installed at exactly `/opt/lchat/hardening-posture.sh`, run every 5 minutes
+ by `lchat-hardening.timer`, as root) reads the **local** box's own posture
+ from its OS: `ufw status` β†’ `firewall_ssh_scope` (`wireguard-only` when port
+ 22 is allowed only over `wg0` and there is no bare public rule /
+ `internet-facing` the moment a bare `22/tcp … ALLOW … Anywhere` rule exists /
+ `unknown` when ufw is inactive or unreadable), `sshd -T` β†’ `root_login` +
+ `password_auth`, `systemctl is-active unattended-upgrades` β†’ `auto_updates`,
+ `ip link show wg0` β†’ `wireguard`, and the ufw first line β†’ `firewall_active`.
+ A box is the truth about *itself*.
+- **The peer `:22` probe (a vantage-dependent hint).** The same run enumerates
+ the mesh β€” membership objects in the hibernation bucket
+ (`LCHAT_HIBERNATE_BUCKET`/`_PREFIX`/`_REGION` from `/data/router.env`, listed
+ under `<prefix>/mesh/members/`, joined to `<prefix>/mesh/members-stats/` for
+ each box's whimsical `label` + coarse `region`, exactly as
+ `status-publish.sh`'s `mesh_peer_boxes` reads them) β€” and probes each **peer**
+ box's port 22 from the outside: a connect-and-banner-grab, never a login.
+ Because an external probe only sees the fleet from the probing box's vantage
+ (a firewall may admit one box and deny another), it is read conservatively:
+ only a returned `SSH-…` banner is a confident **exposed**; a clean refusal or
+ a connect timeout is **not reachable**; a completed connection with no SSH
+ banner β€” or anything that isn't a clean refusal β€” is **inconclusive**, a warn
+ state, never a pass. The self box uses its *local* read (it never self-probes
+ `:22` β€” that would hairpin through its own NAT).
+
+`hardening.json` is `{generated, boxes:[…]}`, boxes **sorted problems-first**
+(internet-facing, then unknown, then hardened), each `{label, region, is_self,
+ssh_internet_facing, detail, …sub-states…, source}`. **No hostnames ever** β€” a
+peer's real host is read only to point the probe and dies in the process; a box
+is named by the same whimsical `label` the Fleet page shows.
+
+**The contract (enforced page-wide): unknown is loud, never a guessed pass.** A
+signal the collector can't read is `"unknown"` (or a JSON `null`
+`ssh_internet_facing`) and `hardening.html` renders it as a bordered warn chip β€”
+an unreadable firewall or an un-self-reported peer must look *unresolved*, not
+green. **The honest caveat** (in the page's policy card, the same framing as
+`ssh.html`): the box publishes its own report, so this is transparency, not
+tamper-proof forensics; the outside `:22` probe is a best-effort hint, and the
+definitive answer for any box is that box self-reading its own ufw once it runs
+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`.
+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.
+
+Test mode is documented in the script header: point every input at a fixture
+(`HARDENING_UFW_CMD`, `HARDENING_SSHD_CMD`, `HARDENING_SYSTEMCTL_CMD`,
+`HARDENING_IP_CMD`, `HARDENING_ROUTER_ENV`, `HARDENING_MEMBERS_FILE`), pin
+`HARDENING_NOW`, set `HARDENING_NO_UPLOAD=1`, and it builds `hardening.json`
+without touching the box, S3, or the network β€” no `:22` probe, no upload.
+
## Install (router box)
1. **Apply the infra** (deliberate, named β€” not yet applied):
diff --git a/web/status/books.html b/web/status/books.html
index 867caa8..bb2a7b3 100644
--- a/web/status/books.html
+++ b/web/status/books.html
@@ -51,6 +51,7 @@
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
<a href="./ssh.html">Access Ledger</a>
+ <a href="./hardening.html">Fleet hardening</a>
</nav>
<p class="lede">Eleven’s income and spending, published as they stand β€” part
of running the service in the open.</p>
diff --git a/web/status/carbon.html b/web/status/carbon.html
index 14f800f..431e990 100644
--- a/web/status/carbon.html
+++ b/web/status/carbon.html
@@ -110,6 +110,7 @@
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
<a href="./ssh.html">Access Ledger</a>
+ <a href="./hardening.html">Fleet hardening</a>
</nav>
<p class="lede">Eleven is AI-written, and the honest carbon ledger has two
halves: what it cost to <em>build</em> the app (the AI tokens spent writing
diff --git a/web/status/contributors.html b/web/status/contributors.html
index 2d630b3..cee5e1d 100644
--- a/web/status/contributors.html
+++ b/web/status/contributors.html
@@ -52,6 +52,7 @@
<span aria-current="page">Contributors</span>
<a href="./prompts.html">Prompt Ledger</a>
<a href="./ssh.html">Access Ledger</a>
+ <a href="./hardening.html">Fleet hardening</a>
</nav>
<p class="lede">Who builds Eleven β€” humans and AI models on the same metric.
This app is AI-written, so the models appear in the index exactly as the
diff --git a/web/status/economics.html b/web/status/economics.html
index 118eaaa..98165ae 100644
--- a/web/status/economics.html
+++ b/web/status/economics.html
@@ -55,6 +55,7 @@
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
<a href="./ssh.html">Access Ledger</a>
+ <a href="./hardening.html">Fleet hardening</a>
</nav>
<p class="lede">How the space-upgrade prices relate to what spaces actually
cost to run. The price is the cost plus resilience β€” and that should be a
diff --git a/web/status/hardening.html b/web/status/hardening.html
new file mode 100644
index 0000000..1b22c67
--- /dev/null
+++ b/web/status/hardening.html
@@ -0,0 +1,316 @@
+<!-- πŸ€– Public fleet hardening posture: per box in the mesh, whether SSH is
+ locked down (docs/status.md Β§ The fleet hardening dashboard). Two halves,
+ both in ./hardening.json (deploy/hardening-posture.sh): the LOCAL box reads
+ its own OS state authoritatively (ufw / sshd / systemd / wireguard); each
+ PEER is seen only from an outside :22 banner-grab probe from the mesh.
+ Fully self-contained: inline CSS/JS, no external requests except that one
+ relative JSON. Unknown renders LOUD β€” a box whose exposure can't be read
+ never shows as a silent pass. -->
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<meta name="color-scheme" content="light dark" />
+<title>Eleven β€” fleet hardening posture</title>
+<link rel="icon" type="image/png" href="./icon-192.png" />
+<link rel="stylesheet" href="./status.css" />
+<style>
+ /* Hardening-only styles β€” the per-box cards, the loud/calm/unknown exposure
+ chips, and the small sub-state chips. Everything shared (tokens, header,
+ nav, tiles, #error, footer) is in status.css. */
+ .boxes { list-style: none; padding: 0; }
+ .boxes li {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: 12px; padding: 14px 16px; margin: 10px 0;
+ }
+ .boxes .top { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; }
+ .boxes .name { font-weight: 650; }
+ .boxes .region { color: var(--ink-2); font-size: 14px; }
+ .selfmark {
+ font-size: 12px; font-weight: 600; color: var(--ink-2);
+ border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px;
+ }
+ .detail { color: var(--ink-2); font-size: 14px; margin-top: 6px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+ .probe-note { color: var(--ink-2); font-size: 13px; margin-top: 6px; }
+
+ /* The exposure chip carries the verdict in WORD + WEIGHT + ICON, never colour
+ alone. Exposed is loud (bold, bordered, --bad); unknown is a warn state
+ (bordered, --warn) so it can never read as a quiet pass; hardened is calm. */
+ .chip {
+ display: inline-block; margin-top: 10px; font-weight: 650; font-size: 14px;
+ border-radius: 8px; padding: 4px 10px; border: 1px solid var(--border);
+ }
+ .chip.exposed {
+ color: var(--bad); border: 2px solid var(--bad-mark); font-weight: 700;
+ background: var(--surface);
+ }
+ .chip.inconclusive {
+ color: var(--warn); border: 1px solid var(--warn); background: var(--redact-bg);
+ }
+ .chip.hardened { color: var(--good); border-color: var(--good); }
+
+ /* Small secondary chips for the self-reported sub-states. Same rule: the value
+ word carries the state; an unknown one reads "not reported" behind a border
+ so it is visibly not a pass. */
+ .subchips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+ .sub {
+ font-size: 12px; border-radius: 999px; padding: 1px 9px;
+ border: 1px solid var(--border); color: var(--ink-2);
+ }
+ .sub .k { color: var(--ink-2); }
+ .sub .v { font-weight: 650; }
+ .sub.good .v { color: var(--good); }
+ .sub.warn { border-color: var(--warn); }
+ .sub.warn .v { color: var(--warn); }
+ .sub.bad { border-color: var(--bad-mark); }
+ .sub.bad .v { color: var(--bad); font-weight: 700; }
+ .sub.muted .v { color: var(--ink-2); font-style: italic; }
+
+ .policy { border-left: 4px solid var(--series); background: var(--surface); border-top: 1px solid var(--border); border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); border-radius: 10px; padding: 12px 16px; margin: 14px 0; }
+ .policy summary { cursor: pointer; font-weight: 600; }
+ .policy summary:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; }
+ .policy p { margin-top: 8px; }
+ .empty { color: var(--ink-2); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
+ .tiles .value { font-variant-numeric: tabular-nums; }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <img src="./icon-192.png" alt="" />
+ <div>
+ <p class="brand">Eleven Messenger</p>
+ <h1>Fleet hardening posture</h1>
+ </div>
+ </header>
+ <nav class="status-nav" aria-label="Status">
+ <a href="./index.html">Fleet</a>
+ <a href="./books.html">Income &amp; Expenditure</a>
+ <a href="./economics.html">Economics</a>
+ <a href="./security.html">Security</a>
+ <a href="./carbon.html">Carbon use</a>
+ <a href="./contributors.html">Contributors</a>
+ <a href="./prompts.html">Prompt Ledger</a>
+ <a href="./ssh.html">Access Ledger</a>
+ <span aria-current="page">Fleet hardening</span>
+ </nav>
+
+ <p class="lede">Whether SSH is locked down on every server in the fleet, on the
+ public record β€” the same transparency-not-tamper-proof idea as the access
+ ledger, one level down at the machines themselves. Each box answers about
+ <em>itself</em>: it reads its own firewall, SSH config, auto-updates and admin
+ tunnel. Boxes it can see are also <em>probed from the outside</em> β€” a
+ connect-only check of whether port 22 answers from the mesh. That outside view
+ is vantage-dependent, so it is deliberately cautious (below): only a box that
+ actually answers with an SSH banner is called exposed. This is a mirror held
+ up to the fleet, not a security control.</p>
+
+ <p class="asof">Last collected <time id="asof" datetime="">β€”</time></p>
+
+ <p id="error" role="alert">Couldn’t load the hardening posture. It may be mid-publish β€” this page retries every minute.</p>
+
+ <details class="policy">
+ <summary>How each box is judged β€” and what the probe can’t prove</summary>
+ <p>πŸ€– <strong>The local read is authoritative; the outside probe is a hint.</strong>
+ A box reports its own posture from its own OS: <em>wireguard-only</em> when
+ port 22 is allowed only over the admin tunnel (<code>wg0</code>) and there is
+ no bare public rule, <em>internet-facing</em> the moment a public port-22
+ rule exists. Those local reads are the truth for that box.</p>
+ <p>πŸ€– <strong>The peer <code>:22</code> probe is vantage-dependent.</strong>
+ One box connecting to another’s port 22 only sees the fleet from where it
+ stands β€” a firewall might admit the probing box but deny everyone else. So
+ the probe is read conservatively: only a returned <code>SSH-…</code> banner
+ is a confident <em>exposed</em>; a clean refusal or timeout is <em>not
+ reachable</em>; anything else that isn’t a clean refusal is
+ <em>inconclusive</em> β€” shown as a warn state, never a pass. The definitive
+ answer for any box is that box self-reading its own firewall once it runs the
+ collector.</p>
+ <p>πŸ€– <strong>Unknown is loud.</strong> Any signal the collector can’t read β€”
+ an unreadable firewall, a peer that hasn’t self-reported β€” renders as a
+ bordered warn chip, never as a quiet green pass. And like the access ledger,
+ this is transparency, not tamper-proof forensics: the box publishes its own
+ report, and the bucket it publishes to is versioned so after-the-fact edits
+ leave a history. No hostnames appear here β€” a box is named by the same
+ whimsical label the fleet page shows.</p>
+ </details>
+
+ <div class="tiles" id="totals"></div>
+
+ <section aria-labelledby="h-boxes">
+ <h2 id="h-boxes">Servers, most exposed first</h2>
+ <ul class="boxes" id="boxes"></ul>
+ <p class="empty" id="empty" hidden>No boxes reported yet β€” the collector
+ publishes each box’s posture on its next pass.</p>
+ </section>
+
+ <footer>
+ <p>Collected on the box every 5 minutes by
+ <code>deploy/hardening-posture.sh</code>: each box reads its own ufw / sshd /
+ systemd / wireguard state, and peers are probed <code>:22</code> from the
+ mesh. Method and honest limits:
+ <a href="https://github.com/elevenmessenger/messenger/blob/main/docs/status.md">docs/status.md</a>.</p>
+ </footer>
+</div>
+
+<script>
+(async function () {
+ "use strict";
+ const $ = (id) => document.getElementById(id);
+
+ const fmtInt = (n) => Number(n).toLocaleString();
+ const PLURAL = new Intl.PluralRules();
+ const plural = (n, one, many) => PLURAL.select(n) === "one" ? one : (many || one + "s");
+ const fmtDay = (iso) => new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
+ const fmtTime = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
+
+ // 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.
+ const SUBS = [
+ ["root_login", "root login"],
+ ["password_auth", "password login"],
+ ["auto_updates", "auto-updates"],
+ ["wireguard", "wireguard"],
+ ["firewall_active", "firewall"],
+ ];
+ function subState(key, val) {
+ if (val === undefined || val === null || val === "unknown") return { cls: "muted", text: "not reported" };
+ switch (key) {
+ case "root_login":
+ if (val === "no") return { cls: "good", text: "no" };
+ if (val === "yes") return { cls: "bad", text: "yes" };
+ return { cls: "warn", text: String(val) }; // prohibit-password etc.
+ case "password_auth":
+ return val === "no" ? { cls: "good", text: "no" } : { cls: "bad", text: String(val) };
+ case "auto_updates":
+ return val === "active" ? { cls: "good", text: "active" } : { cls: "warn", text: String(val) };
+ case "wireguard":
+ return val === "up" ? { cls: "good", text: "up" } : { cls: "warn", text: String(val) };
+ case "firewall_active":
+ return val === true ? { cls: "good", text: "active" } : { cls: "bad", text: "inactive" };
+ }
+ return { cls: "muted", text: String(val) };
+ }
+
+ function exposureChip(box) {
+ const f = box.ssh_internet_facing;
+ const chip = document.createElement("span");
+ chip.className = "chip";
+ const glyph = document.createElement("span");
+ glyph.setAttribute("aria-hidden", "true");
+ let text;
+ if (f === true) { chip.classList.add("exposed"); glyph.textContent = "⚠ "; text = "SSH reachable from the internet"; }
+ else if (f === false) { chip.classList.add("hardened"); glyph.textContent = "βœ“ "; text = "SSH not internet-facing"; }
+ else { chip.classList.add("inconclusive"); glyph.textContent = "⚠ "; text = "SSH exposure not reported"; }
+ chip.append(glyph, document.createTextNode(text));
+ return chip;
+ }
+
+ function render(boxes) {
+ const list = $("boxes");
+ list.textContent = "";
+ $("empty").hidden = boxes.length > 0;
+
+ let exposed = 0, hardened = 0, notReported = 0;
+ for (const box of boxes) {
+ const f = box.ssh_internet_facing;
+ if (f === true) exposed++;
+ else if (f === false) hardened++;
+ else notReported++;
+
+ const li = document.createElement("li");
+
+ const top = document.createElement("div");
+ top.className = "top";
+ const name = document.createElement("span");
+ name.className = "name";
+ name.textContent = box.label || "unidentified box";
+ top.appendChild(name);
+ if (box.region) {
+ const r = document.createElement("span");
+ r.className = "region";
+ r.textContent = box.region;
+ top.appendChild(r);
+ }
+ if (box.is_self) {
+ const s = document.createElement("span");
+ s.className = "selfmark";
+ s.textContent = "this box";
+ top.appendChild(s);
+ }
+ li.appendChild(top);
+
+ li.appendChild(exposureChip(box));
+
+ if (box.detail) {
+ const d = document.createElement("div");
+ d.className = "detail";
+ d.textContent = box.detail;
+ li.appendChild(d);
+ }
+
+ const subs = document.createElement("div");
+ subs.className = "subchips";
+ for (const [key, label] of SUBS) {
+ if (!(key in box)) continue; // field this box doesn't claim
+ const st = subState(key, box[key]);
+ const chip = document.createElement("span");
+ chip.className = "sub " + st.cls;
+ const k = document.createElement("span"); k.className = "k"; k.textContent = label + ": ";
+ const v = document.createElement("span"); v.className = "v"; v.textContent = st.text;
+ chip.append(k, v);
+ subs.appendChild(chip);
+ }
+ if (subs.childNodes.length) li.appendChild(subs);
+
+ if (box.source === "probe") {
+ const note = document.createElement("p");
+ 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);
+ }
+
+ list.appendChild(li);
+ }
+
+ const total = boxes.length;
+ const tiles = [
+ [plural(total, "box", "boxes"), fmtInt(total)],
+ ["hardened", fmtInt(hardened)],
+ ["exposed", fmtInt(exposed)],
+ ["not reported", fmtInt(notReported)],
+ ];
+ const totals = $("totals");
+ totals.textContent = "";
+ for (const [label, val] of tiles) {
+ const tile = document.createElement("div");
+ tile.className = "tile";
+ const l = document.createElement("div"); l.className = "label"; l.textContent = label;
+ const v = document.createElement("div"); v.className = "value"; v.textContent = val;
+ tile.append(l, v);
+ totals.appendChild(tile);
+ }
+ }
+
+ async function load() {
+ try {
+ const r = await fetch("./hardening.json");
+ if (!r.ok) throw 0;
+ const doc = await r.json();
+ $("error").classList.remove("show");
+ const asof = $("asof");
+ asof.dateTime = doc.generated || "";
+ asof.textContent = doc.generated ? fmtDay(doc.generated) + " " + fmtTime(doc.generated) : "β€”";
+ render(Array.isArray(doc.boxes) ? doc.boxes : []);
+ } catch {
+ $("error").classList.add("show");
+ }
+ }
+ await load();
+ setInterval(load, 60000); // same retry cadence as the other dashboards
+})();
+</script>
+</body>
+</html>
diff --git a/web/status/index.html b/web/status/index.html
index fbdc52d..68d482c 100644
--- a/web/status/index.html
+++ b/web/status/index.html
@@ -69,6 +69,7 @@
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
<a href="./ssh.html">Access Ledger</a>
+ <a href="./hardening.html">Fleet hardening</a>
</nav>
<p class="lede">Live aggregate health of the Eleven fleet β€” how many spaces are running
(always-on or on-demand, and of the on-demand ones how many are awake), what
diff --git a/web/status/prompts.html b/web/status/prompts.html
index 85b25d0..4a60ca1 100644
--- a/web/status/prompts.html
+++ b/web/status/prompts.html
@@ -117,6 +117,7 @@
<a href="./contributors.html">Contributors</a>
<span aria-current="page">Prompt Ledger</span>
<a href="./ssh.html">Access Ledger</a>
+ <a href="./hardening.html">Fleet hardening</a>
</nav>
<div id="home">
diff --git a/web/status/security.html b/web/status/security.html
index c89e3ec..4ec1bb2 100644
--- a/web/status/security.html
+++ b/web/status/security.html
@@ -71,6 +71,7 @@
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
<a href="./ssh.html">Access Ledger</a>
+ <a href="./hardening.html">Fleet hardening</a>
</nav>
<p class="lede">Eleven’s security posture, published as it stands. This is
what is true by design and how the hygiene looks today β€” never a map of open
diff --git a/web/status/ssh.html b/web/status/ssh.html
index a7cb49a..310aba8 100644
--- a/web/status/ssh.html
+++ b/web/status/ssh.html
@@ -72,6 +72,7 @@
<a href="./contributors.html">Contributors</a>
<a href="./prompts.html">Prompt Ledger</a>
<span aria-current="page">Access Ledger</span>
+ <a href="./hardening.html">Fleet hardening</a>
</nav>
<p class="lede">Every operator SSH session to the server that runs the fleet,
diff --git a/web/status/status.css b/web/status/status.css
index 9a17909..ca3e643 100644
--- a/web/status/status.css
+++ b/web/status/status.css
@@ -1,8 +1,8 @@
/* πŸ€– Shared stylesheet for the public status pages (web/status/*.html): fleet,
income & expenditure, tier economics, security, carbon, contributors, the
- prompt ledger, and the SSH access ledger.
+ prompt ledger, the SSH access ledger, and the fleet hardening posture.
ONE design system β€” the design tokens, the reset, and every component the
- pages share (the brand masthead, the eight-item nav, tiles, the single-series
+ pages share (the brand masthead, the nine-item nav, tiles, the single-series
bar list, notes, the "couldn't load" alert, footer). Each page <link>s this
and keeps only a SMALL inline <style> for its own unique bits.
@@ -57,7 +57,7 @@ header img { width: 44px; height: 44px; border-radius: 10px; }
header h1 { font-size: 22px; font-weight: 650; }
header .brand { color: var(--ink-2); font-size: 14px; }
-/* The status nav β€” a horizontal stepper through the eight public dashboards,
+/* The status nav β€” a horizontal stepper through the nine public dashboards,
wrapping onto more rows on narrow widths. The current page is a non-link,
marked aria-current and bolded, so weight (not color alone) carries it. */
nav.status-nav { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 14px; margin: 8px 0 14px; }