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>
15 files changed,
+916
−3
changelog/2026-07-14-fleet-hardening-dashboard.md+10 −0deploy/hardening-posture.sh+455 −0deploy/systemd/lchat-hardening.service+39 −0deploy/systemd/lchat-hardening.timer+13 −0docs/status.md+72 −0web/status/books.html+1 −0web/status/carbon.html+1 −0web/status/contributors.html+1 −0web/status/economics.html+1 −0web/status/hardening.html+316 −0web/status/index.html+1 −0web/status/prompts.html+1 −0web/status/security.html+1 −0web/status/ssh.html+1 −0web/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 & 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; } |