status: public SSH access ledger β ssh.html + box collector (#884)
* deploy: box-side SSH ledger collector + 5-minute systemd timer Reads new sshd journal events via a persisted cursor, groups accepted publickey connections into per-(user, fingerprint) sessions (15-min join window), and publishes only sessions quiet >= 20 min β metadata only, never commands or content. Deliberately standalone from status-publish.sh (the box's copy has diverged); uploads its own outputs with the same status-publisher creds. Documented test mode exercised against a fixture journal (grouping, join edge, emission, dedupe-on-replay, corrupt-ledger and malformed-entry guards). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * status: public SSH access ledger page, operator map, why-entries + docs ssh.html joins the collector's ssh-sessions.json with the PR-reviewed why-entries (web/status/ssh/*.json, merged to ssh-entries.json on the box); a session with no entry renders loudly as "no summary recorded". Operator handles come from the committed fingerprint map web/status/ssh-operators.json. Adds the Access Ledger nav item to all status pages, the docs/status.md section (convention + redaction bar + deploy steps), the seed wireguard-rollout entry, and the changelog note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * status: point the seed why-entry at the real PR number (#884) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
17 files changed,
+721
−8
changelog/2026-07-14-ssh-access-ledger.md+8 −0deploy/ssh-ledger-collect.sh+205 −0deploy/systemd/lchat-ssh-ledger.service+38 −0deploy/systemd/lchat-ssh-ledger.timer+12 −0docs/status.md+87 −3web/status/books.html+1 −0web/status/carbon.html+1 −0web/status/contributors.html+1 −0web/status/economics.html+1 −0web/status/index.html+1 −0web/status/prompts.html+2 −1web/status/security.html+1 −0web/status/ssh-operators.json+5 −0web/status/ssh.html+294 −0web/status/ssh/2026-07-14-wireguard-rollout.json+7 −0web/status/ssh/README.md+53 −0web/status/status.css+4 −4
diff --git a/changelog/2026-07-14-ssh-access-ledger.md b/changelog/2026-07-14-ssh-access-ledger.md| new file mode 100644 |
| index 0000000..3b1a60f |
| --- /dev/null |
| +++ b/changelog/2026-07-14-ssh-access-ledger.md |
| @@ -0,0 +1,8 @@ |
| +π€ Operator access to the server is now on the public record. Whenever one of |
| +us signs in to the machine that runs Eleven, that session appears on the |
| +public status site β who it was, when, and a plain-language reason why β |
| +at status.elevenmessenger.com/ssh.html. Only the fact of the access is |
| +published, never what was typed or seen, and a session with no reason given |
| +is flagged loudly rather than hidden. It's the same idea as the prompt |
| +ledger: if someone's hands were on the infrastructure your messages pass |
| +through, you should be able to see that they were, and why. |
diff --git a/deploy/ssh-ledger-collect.sh b/deploy/ssh-ledger-collect.sh| new file mode 100755 |
| index 0000000..f9d1631 |
| --- /dev/null |
| +++ b/deploy/ssh-ledger-collect.sh |
| @@ -0,0 +1,205 @@ |
| +#!/usr/bin/env bash |
| +# π€ Public SSH access ledger β the box-side collector (docs/status.md Β§ The |
| +# SSH access ledger). Every 5 minutes (lchat-ssh-ledger.timer) this reads NEW |
| +# sshd journal events, groups accepted connections into operator sessions, and |
| +# appends closed sessions to the public ledger the status site serves at |
| +# ssh.html. Installed on the box at exactly /opt/lchat/ssh-ledger-collect.sh |
| +# (the unit's ExecStart points there). |
| +# |
| +# ============================ PRIVACY β NON-NEGOTIABLE ======================== |
| +# Everything this script publishes is PUBLIC, forever (the bucket is |
| +# versioned). The rules: |
| +# - METADATA ONLY, NEVER CONTENT. Who (operator handle via public-key |
| +# fingerprint), when (start/end of the accepted-connection window), how |
| +# many connections, and wireguard-or-public source. No commands, no |
| +# command output, no session content β nothing here even reads it. |
| +# - PUBLISH ON CLOSE ONLY. A session row is emitted only after the group has |
| +# been quiet for >= QUIET_S β the public page must never be a live |
| +# "an operator is connected right now" beacon for an attacker to watch. |
| +# - Key FINGERPRINTS are public-key material (safe to publish); client IPs |
| +# are read only to pick the source word and never reach the JSON. |
| +# ============================================================================== |
| +# |
| +# 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. |
| +# |
| +# The human "why" half lives in the repo: web/status/ssh/*.json entry files, |
| +# PR-reviewed (review is the redaction gate), land in $PAGES_DIR/ssh/ via the |
| +# normal viewer-page scp step. This script merges them into one |
| +# ssh-entries.json so the page fetches exactly two JSON files. |
| +# |
| +# Usage: |
| +# ssh-ledger-collect.sh # what the timer runs |
| +# |
| +# Test mode (no journalctl, no upload, no box state): point every input at a |
| +# fixture β a file of journalctl -o json lines β and pin "now": |
| +# SSH_LEDGER_JOURNAL_FILE=events.jsonl SSH_LEDGER_STATE_DIR=/tmp/state \ |
| +# SSH_LEDGER_PAGES_DIR=/tmp/pages SSH_LEDGER_NOW=<epoch> ./ssh-ledger-collect.sh |
| +# |
| +# Prerequisites on the box: jq (apt install jq) + the aws CLI and |
| +# /data/status.env creds already there for the status publisher. |
| +set -euo pipefail |
| + |
| +STATE_DIR=${SSH_LEDGER_STATE_DIR:-/data/ssh-ledger} |
| +PAGES_DIR=${SSH_LEDGER_PAGES_DIR:-/opt/lchat/status-pages} |
| +SSH_UNIT=${SSH_LEDGER_UNIT:-ssh} # Ubuntu's sshd unit is ssh.service |
| +BUCKET=${STATUS_BUCKET:-eleven-status-427880590669} |
| +REGION=${STATUS_REGION:-eu-west-1} |
| +STATUS_ENV=${STATUS_ENV_FILE:-/data/status.env} |
| +WG_PREFIX="172.22.22." # the wg0 admin subnet (/24) |
| + |
| +# Grouping windows. JOIN_S: reconnects are routine while working (a dropped |
| +# terminal, a second window, scp beside a shell), so accepts within 15 minutes |
| +# of the previous one are one session, not many rows. QUIET_S: a group only |
| +# publishes after 20 quiet minutes β longer than the join window on purpose, |
| +# so a row can never appear while its session could still legally grow, and |
| +# (the publish-on-close rule) the page never advertises a live connection. |
| +JOIN_S=900 |
| +QUIET_S=1200 |
| + |
| +NOW=${SSH_LEDGER_NOW:-$(date -u +%s)} |
| +CURSOR="$STATE_DIR/cursor" |
| +PENDING="$STATE_DIR/pending.tsv" # open groups: user fp start last connections source |
| +SESSIONS="$PAGES_DIR/ssh-sessions.json" |
| +ENTRIES="$PAGES_DIR/ssh-entries.json" |
| +OPERATORS="$PAGES_DIR/ssh-operators.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 |
| + |
| +# iso <epoch> β GNU date on the box; the -r fallback keeps the 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; } |
| + |
| +# 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"; } |
| + |
| +# ---- 1. read new sshd events ------------------------------------------------- |
| +# journalctl's --cursor-file persists our read position in $STATE_DIR (not the |
| +# journal itself), so it survives journald rotation: rotation drops old |
| +# entries, never the cursor, and the next run resumes exactly where this one |
| +# stopped. First run (no cursor yet) backfills from the start of the journal. |
| +if [ -n "${SSH_LEDGER_JOURNAL_FILE:-}" ]; then |
| + cat "$SSH_LEDGER_JOURNAL_FILE" > "$TMP/journal.jsonl" |
| +else |
| + journalctl -u "$SSH_UNIT" -o json --cursor-file "$CURSOR" --no-pager > "$TMP/journal.jsonl" || true |
| +fi |
| + |
| +# One TSV line per accepted-publickey login: epoch-seconds, user, client IP, |
| +# key fingerprint. Password/other auth is off on this box, so "Accepted |
| +# publickey" is the complete accept vocabulary; anything else in the sshd |
| +# journal (disconnects, probes, scanner noise) is deliberately ignored. |
| +jq -r 'select((.MESSAGE // "") | startswith("Accepted publickey for ")) |
| + | [(.__REALTIME_TIMESTAMP | tonumber / 1000000 | floor), .MESSAGE] | @tsv' \ |
| + "$TMP/journal.jsonl" > "$TMP/accepts.tsv" |
| + |
| +# ---- 2. fold accepts into pending groups ------------------------------------- |
| +# pending.tsv holds every not-yet-published group. More than one group per |
| +# (user, fingerprint) can coexist briefly: a reconnect 16 minutes after the |
| +# last (past JOIN_S) starts a new group while the old one is still inside its |
| +# QUIET_S wait β the old row publishes a few minutes later on its own clock. |
| +[ -f "$PENDING" ] || : > "$PENDING" |
| +cp "$PENDING" "$TMP/pending.tsv" |
| + |
| +msg_re='^Accepted publickey for ([^ ]+) from ([0-9a-fA-F:.]+) port [0-9]+ ssh2: [^ ]+ (SHA256:[A-Za-z0-9+/=]+)' |
| +while IFS=$'\t' read -r ts msg; do |
| + [[ "$msg" =~ $msg_re ]] || continue |
| + user="${BASH_REMATCH[1]}" ip="${BASH_REMATCH[2]}" fp="${BASH_REMATCH[3]}" |
| + # Source word only β the IP itself dies here (see PRIVACY above). A public |
| + # accept should be rare now port 22 is wireguard-only, which is exactly why |
| + # it must publish loudly when it happens. |
| + case "$ip" in "$WG_PREFIX"*) src=wireguard ;; *) src=public ;; esac |
| + # Join the newest open group for this (user, fp) if this accept is within |
| + # JOIN_S of its last connection; otherwise open a new group. awk rewrites |
| + # the whole (tiny) pending file per event β clarity over cleverness. |
| + awk -v u="$user" -v f="$fp" -v ts="$ts" -v src="$src" -v join="$JOIN_S" ' |
| + BEGIN { OFS="\t"; joined=0 } |
| + $1==u && $2==f && !joined && ts-$4 <= join && ts >= $4 { |
| + $4=ts; $5=$5+1 |
| + if (src=="public") $6="public" # one public hop marks the whole group |
| + joined=1 |
| + } |
| + { print } |
| + END { if (!joined) print u, f, ts, ts, 1, src } |
| + ' "$TMP/pending.tsv" > "$TMP/pending.next" && mv "$TMP/pending.next" "$TMP/pending.tsv" |
| +done < "$TMP/accepts.tsv" |
| + |
| +# ---- 3. publish groups that have gone quiet ---------------------------------- |
| +# Split pending into "still open" (kept for next run) and "quiet >= QUIET_S" |
| +# (emitted below). This is the publish-on-close rule from the header. |
| +awk -v now="$NOW" -v quiet="$QUIET_S" 'now - $4 >= quiet' "$TMP/pending.tsv" > "$TMP/closed.tsv" |
| +awk -v now="$NOW" -v quiet="$QUIET_S" 'now - $4 < quiet' "$TMP/pending.tsv" > "$TMP/open.tsv" |
| + |
| +# Operator attribution by fingerprint β web/status/ssh-operators.json, staged |
| +# with the viewer pages. An unmapped fingerprint publishes as "unknown key": |
| +# render-worthy on purpose (an unexplained key on the box is itself a signal), |
| +# never silently dropped. |
| +[ -f "$OPERATORS" ] || echo '{}' > "$OPERATORS" |
| + |
| +echo '[]' > "$TMP/new.json" |
| +while IFS=$'\t' read -r user fp start last conns src; do |
| + [ -n "$user" ] || continue |
| + jq --arg id "$user+$fp+$(iso "$start")" \ |
| + --arg fp "$fp" --arg start "$(iso "$start")" --arg end "$(iso "$last")" \ |
| + --argjson conns "$conns" --arg src "$src" \ |
| + --slurpfile ops "$OPERATORS" \ |
| + '. + [{id: $id, operator: ($ops[0][$fp] // "unknown key"), |
| + fingerprint: $fp, start: $start, end: $end, |
| + connections: $conns, source: $src}]' \ |
| + "$TMP/new.json" > "$TMP/new.next" && mv "$TMP/new.next" "$TMP/new.json" |
| +done < "$TMP/closed.tsv" |
| + |
| +# Append to the cumulative ledger, deduped on id (user+fingerprint+start), so |
| +# a re-run over replayed journal lines can never double-publish a session. A |
| +# missing ledger starts fresh; a CORRUPT one aborts the run β never clobber |
| +# published history with an empty file. |
| +if [ -f "$SESSIONS" ]; then |
| + jq -e . "$SESSIONS" >/dev/null || { echo "corrupt $SESSIONS β refusing to overwrite" >&2; exit 1; } |
| +else |
| + echo '{"sessions": []}' > "$SESSIONS" |
| +fi |
| +jq --arg now "$(iso "$NOW")" --slurpfile add "$TMP/new.json" \ |
| + '{generated: $now, sessions: ((.sessions // []) + $add[0] | unique_by(.id) | sort_by(.start))}' \ |
| + "$SESSIONS" > "$TMP/sessions.json" |
| +atomic_write "$TMP/sessions.json" "$SESSIONS" |
| +atomic_write "$TMP/open.tsv" "$PENDING" |
| + |
| +# ---- 4. merge the human why-entries ------------------------------------------ |
| +# web/status/ssh/*.json (one file per entry β parallel PRs never conflict) |
| +# merge into one array so the page fetches exactly two JSONs. A malformed |
| +# entry fails the run loudly rather than silently dropping someone's "why". |
| +echo '[]' > "$TMP/entries.json" |
| +for f in "$PAGES_DIR"/ssh/*.json; do |
| + [ -e "$f" ] || continue # no entries staged yet β publish an empty array |
| + jq -e . "$f" >/dev/null 2>&1 || { echo "malformed why-entry $f β fix or revert it" >&2; exit 1; } |
| + jq --slurpfile e "$f" '. + $e' "$TMP/entries.json" > "$TMP/entries.next" && \ |
| + mv "$TMP/entries.next" "$TMP/entries.json" |
| +done |
| +jq 'sort_by(.date) | reverse' "$TMP/entries.json" > "$TMP/entries.sorted" |
| +atomic_write "$TMP/entries.sorted" "$ENTRIES" |
| + |
| +# ---- 5. upload ---------------------------------------------------------------- |
| +# Standalone by design (see header): this script uploads its own outputs with |
| +# the same scoped status-publisher creds status-publish.sh uses. Test mode |
| +# (fixture journal) never uploads. Re-uploading the page + operator map every |
| +# run self-heals a missed copy, same as the main publisher. |
| +if [ -z "${SSH_LEDGER_JOURNAL_FILE:-}" ]; 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 "$SESSIONS" ssh-sessions.json "application/json" "max-age=60" |
| + up "$ENTRIES" ssh-entries.json "application/json" "max-age=60" |
| + [ -f "$PAGES_DIR/ssh.html" ] && up "$PAGES_DIR/ssh.html" ssh.html "text/html; charset=utf-8" "max-age=300" |
| + [ -f "$OPERATORS" ] && up "$OPERATORS" ssh-operators.json "application/json" "max-age=300" |
| + echo "ssh ledger published to s3://$BUCKET ($(iso "$NOW"))" |
| +fi |
diff --git a/deploy/systemd/lchat-ssh-ledger.service b/deploy/systemd/lchat-ssh-ledger.service| new file mode 100644 |
| index 0000000..f537f57 |
| --- /dev/null |
| +++ b/deploy/systemd/lchat-ssh-ledger.service |
| @@ -0,0 +1,38 @@ |
| +[Unit] |
| +Description=lchat public SSH access ledger (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/ssh-ledger /opt/lchat/status-pages |
| +ExecStart=/opt/lchat/ssh-ledger-collect.sh |
| + |
| +# --- hardening (see docs/instance-router.md Β§ Hardening) --- |
| +# Runs as ROOT deliberately: the sshd journal is root/systemd-journal readable |
| +# and /data/ssh-ledger + /opt/lchat/status-pages are its only writes β |
| +# ProtectSystem=strict pins exactly those. It is fired by |
| +# lchat-ssh-ledger.timer, never routed or listening, and publishes METADATA |
| +# ONLY (see the script's privacy header). |
| +ProtectSystem=strict |
| +ReadWritePaths=/data/ssh-ledger /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-ssh-ledger.timer b/deploy/systemd/lchat-ssh-ledger.timer| new file mode 100644 |
| index 0000000..b6527b5 |
| --- /dev/null |
| +++ b/deploy/systemd/lchat-ssh-ledger.timer |
| @@ -0,0 +1,12 @@ |
| +[Unit] |
| +Description=5-minute public SSH access ledger collect |
| + |
| +[Timer] |
| +# A short boot delay so journald is settled before the first pass; the random |
| +# spread keeps this from landing in the same second as the status publisher. |
| +OnBootSec=2min |
| +OnUnitActiveSec=5min |
| +RandomizedDelaySec=30s |
| + |
| +[Install] |
| +WantedBy=timers.target |
diff --git a/docs/status.md b/docs/status.md| index f806a88..c536992 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -1,4 +1,4 @@ |
| -# π€ Public status dashboards β fleet, books, economics, security, carbon, contributors & prompts |
| +# π€ Public status dashboards β fleet, books, economics, security, carbon, contributors, prompts & SSH access |
| World-readable pages, published every 5 minutes from the router box: the |
| running half of #550 Part 11/12's transparency direction (the append-only |
| @@ -59,12 +59,19 @@ public *ledger* repo is still roadmap β see `docs/planet-scale.md`). |
| contributor runs `tools/prompt-registry.mjs` and uploads the corpus straight |
| to the bucket (see *The prompt registry* below). The page is a committed |
| viewer, republished with the others. |
| +- **The SSH access ledger** (`ssh.html` + `ssh-sessions.json` + |
| + `ssh-entries.json`) β every operator SSH session to the router box, on the |
| + public record with a plain-language "why". Two halves: a standalone box-side |
| + collector (`deploy/ssh-ledger-collect.sh`, its own 5-minute timer) publishes |
| + session *metadata* from sshd's journal, and small PR-reviewed entry files |
| + (`web/status/ssh/*.json`) carry the human reason. See *The SSH access |
| + ledger* below. |
| Pieces: generator `deploy/status-publish.sh` (box timer, mirrors |
| `deploy/fleet-backup.sh`), viewer pages `web/status/`, bucket + IAM |
| -`infra/prod/status.tf`, contract test `test/status-contract.test.mjs`. All seven |
| +`infra/prod/status.tf`, contract test `test/status-contract.test.mjs`. All eight |
| pages share one stylesheet, `web/status/status.css` β the design tokens, the |
| -brand masthead, the seven-item nav that steps through every dashboard, and the |
| +brand masthead, the eight-item nav that steps through every dashboard, and the |
| common components (tiles, bar lists, notes, footer). Each page keeps only a |
| small inline `<style>` for its own bits. It's same-origin in the status bucket |
| (no third-party request β the privacy line holds), and the publish script |
| @@ -179,6 +186,17 @@ aggregation boundary lives in the generator and holds by construction: |
| addresses. The full govulncheck report stays on the operator's terminal; |
| the summarizer refuses to write an output containing an id-shaped string, |
| and the contract test greps for every id grammar. |
| +- **The SSH access ledger: metadata only, published on close.** Who (operator |
| + handle via public-key fingerprint β public key material, safe to publish), |
| + when, connection count, and a wireguard-or-public source word. **Never |
| + commands, command output, or session content** β the collector doesn't even |
| + read them β and client IPs are read only to pick the source word, dying in |
| + the collector like the capacity numbers do. Rows publish only after a |
| + session has been quiet β₯20 minutes, so the page is never a live |
| + "operator connected right now" beacon. The human why-entries hold the |
| + prompt registry's redaction bar: what, not how; never credentials or |
| + emails; security-sensitive sessions publish as `(withheld: security)` |
| + rather than vanishing (see *The SSH access ledger* below). |
| - Adding a field? It must be an aggregate over the whole fleet / all payers, |
| or it doesn't ship (the prompt registry, a whole-transcript feed by design, |
| is the one standing exception). The `--dry-run` self-check and the contract |
| @@ -468,6 +486,72 @@ Accruing a machine's sessions: |
| `/opt/lchat/status-pages/` like the other pages; the 5-minute publish |
| re-uploads it. |
| +## The SSH access ledger |
| + |
| +Every operator SSH session to the router box, on the public record with a |
| +plain-language reason β the prompt registry's idea applied to hands-on-the-box |
| +access, prompted by the WireGuard hardening (public port 22 closed; operators |
| +connect over the wg admin tunnel, `172.22.22.0/24`). Two halves: |
| + |
| +- **Automatic truth β the box-side collector.** `deploy/ssh-ledger-collect.sh` |
| + (installed at exactly `/opt/lchat/ssh-ledger-collect.sh`, run every 5 minutes |
| + by `lchat-ssh-ledger.timer`) reads *new* sshd journal events via a persisted |
| + cursor (`/data/ssh-ledger/` β survives journald rotation), parses accepted |
| + publickey logins, and groups them into sessions per (user, key fingerprint): |
| + a reconnect within 15 minutes joins the session, and a session publishes |
| + only after 20 quiet minutes β **publish-on-close**, so the page never |
| + advertises a live connection. Rows append to the cumulative |
| + `ssh-sessions.json` (`{id, operator, fingerprint, start, end, connections, |
| + source}`; `source` is `wireguard` or `public`), deduped on id across |
| + re-runs. Operator handles come from the committed fingerprint map |
| + `web/status/ssh-operators.json`; an unmapped fingerprint publishes as |
| + `unknown key` β deliberately render-worthy, an unexplained key is the |
| + signal the page exists to surface. The collector is **deliberately |
| + standalone** from `deploy/status-publish.sh` (the box's copy of that script |
| + has diverged from main and is not forced to reconcile for this): it uploads |
| + its own outputs with the same status-publisher creds (`/data/status.env`). |
| +- **The human "why" β PR-reviewed entry files.** One JSON per entry in |
| + `web/status/ssh/` (changelog-style β parallel PRs never conflict): |
| + `{date, operator, window, why, refs}`. **The convention: any session, |
| + Claude or human, that SSHes to the box adds an entry in the same or an |
| + adjacent PR.** PR review is the redaction gate, holding the prompt |
| + registry's bar: summaries are *what, not how*; never credentials or emails; |
| + a security-sensitive session publishes as `"why": "(withheld: security)"` |
| + rather than vanishing. Format + full convention: `web/status/ssh/README.md`. |
| + The entries land on the box via the normal viewer-page scp step, and the |
| + collector merges them into one `ssh-entries.json` so the page fetches |
| + exactly two JSONs. |
| + |
| +`ssh.html` joins the halves client-side β same operator + same UTC date, |
| +preferring an overlapping (else nearest) time window; one entry may cover |
| +several sessions. **A session with no matching entry renders loudly as "no |
| +summary recorded"** β not an error, the enforcement: unexplained access should |
| +look unexplained. The page also carries the honest caveat in its policy card: |
| +the box publishes its own ledger, so this is transparency, not tamper-proof |
| +forensics (the versioned bucket keeps history of after-the-fact edits, but a |
| +root attacker could stop or falsify the collector). |
| + |
| +Deploying it (the box side; needs `jq` β `apt install jq`): |
| + |
| +1. `scp deploy/ssh-ledger-collect.sh root@<box>:/opt/lchat/` (the unit's |
| + `ExecStart` expects that exact path) and |
| + `scp deploy/systemd/lchat-ssh-ledger.{service,timer} root@<box>:/etc/systemd/system/`. |
| +2. `ssh root@<box> 'systemctl daemon-reload && systemctl enable --now lchat-ssh-ledger.timer'`. |
| +3. Stage the viewer + entries with the other pages: |
| + `scp web/status/ssh.html web/status/ssh-operators.json root@<box>:/opt/lchat/status-pages/` |
| + and `scp web/status/ssh/*.json root@<box>:/opt/lchat/status-pages/ssh/` |
| + (make the `ssh/` dir first; the README stays repo-only). Re-run the entries |
| + scp whenever a why-entry lands β a session published before its entry |
| + arrives shows "no summary recorded" until the next collect pass after the |
| + copy. |
| +4. **Verify**: `systemctl list-timers lchat-ssh-ledger.timer`, |
| + `journalctl -u lchat-ssh-ledger -n 20`, then load `ssh.html` (rows appear |
| + only once a session has closed and gone 20 minutes quiet). |
| + |
| +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. |
| + |
| ## 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 45e0593..867caa8 100644 |
| --- a/web/status/books.html |
| +++ b/web/status/books.html |
| @@ -50,6 +50,7 @@ |
| <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> |
| </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 3aba61a..14f800f 100644 |
| --- a/web/status/carbon.html |
| +++ b/web/status/carbon.html |
| @@ -109,6 +109,7 @@ |
| <span aria-current="page">Carbon use</span> |
| <a href="./contributors.html">Contributors</a> |
| <a href="./prompts.html">Prompt Ledger</a> |
| + <a href="./ssh.html">Access Ledger</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 46d0993..2d630b3 100644 |
| --- a/web/status/contributors.html |
| +++ b/web/status/contributors.html |
| @@ -51,6 +51,7 @@ |
| <a href="./carbon.html">Carbon use</a> |
| <span aria-current="page">Contributors</span> |
| <a href="./prompts.html">Prompt Ledger</a> |
| + <a href="./ssh.html">Access Ledger</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 c395584..118eaaa 100644 |
| --- a/web/status/economics.html |
| +++ b/web/status/economics.html |
| @@ -54,6 +54,7 @@ |
| <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> |
| </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/index.html b/web/status/index.html| index e9e97e2..fbdc52d 100644 |
| --- a/web/status/index.html |
| +++ b/web/status/index.html |
| @@ -68,6 +68,7 @@ |
| <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> |
| </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 a8f2c1c..77895ff 100644 |
| --- a/web/status/prompts.html |
| +++ b/web/status/prompts.html |
| @@ -17,7 +17,7 @@ |
| <link rel="icon" type="image/png" href="./icon-192.png" /> |
| <link rel="stylesheet" href="./status.css" /> |
| <style> |
| - /* Prompt-ledger-only styles; the tokens, reset, masthead, and the six-item |
| + /* Prompt-ledger-only styles; the tokens, reset, masthead, and the shared |
| nav live in status.css. This page is wider than the others and adds the |
| search controls, the sessions table, and the transcript viewer. */ |
| .wrap { max-width: 860px; margin: 0 auto; } |
| @@ -116,6 +116,7 @@ |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
| <span aria-current="page">Prompt Ledger</span> |
| + <a href="./ssh.html">Access Ledger</a> |
| </nav> |
| <div id="home"> |
diff --git a/web/status/security.html b/web/status/security.html| index 51042d0..c89e3ec 100644 |
| --- a/web/status/security.html |
| +++ b/web/status/security.html |
| @@ -70,6 +70,7 @@ |
| <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> |
| </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-operators.json b/web/status/ssh-operators.json| new file mode 100644 |
| index 0000000..929b5c8 |
| --- /dev/null |
| +++ b/web/status/ssh-operators.json |
| @@ -0,0 +1,5 @@ |
| +{ |
| + "SHA256:5JUe8bzNg0hBATwv4cHOJJ0ZDQzaPWUqTWAs7qDwCyI": "Paul Campbell", |
| + "SHA256:U4nJjkf9E6nFIfmMnGJyWMLZKO2K/K4vtIs/0BdxTKI": "James Adam", |
| + "SHA256:eVQy2zp7AHszs159VfsEAH4PiRj+Jc+BxXU0xL6tsVk": "James Adam" |
| +} |
diff --git a/web/status/ssh.html b/web/status/ssh.html| new file mode 100644 |
| index 0000000..a7cb49a |
| --- /dev/null |
| +++ b/web/status/ssh.html |
| @@ -0,0 +1,294 @@ |
| +<!-- π€ Public SSH access ledger: every operator SSH session to the router box, |
| + published with a plain-language "why" (docs/status.md Β§ The SSH access |
| + ledger). Two halves joined here: ./ssh-sessions.json is the automatic |
| + truth (deploy/ssh-ledger-collect.sh reads sshd's journal β metadata only, |
| + published only after a session closes) and ./ssh-entries.json is the |
| + human why-entries (web/status/ssh/*.json in the repo, PR-reviewed). |
| + Fully self-contained: inline CSS/JS, no external requests except those |
| + two JSONs (relative). A session with no matching entry renders loudly as |
| + "no summary recorded" β that state is the convention's enforcement. --> |
| +<!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 β the SSH access ledger</title> |
| +<link rel="icon" type="image/png" href="./icon-192.png" /> |
| +<link rel="stylesheet" href="./status.css" /> |
| +<style> |
| + /* Access-ledger-only styles β the session rows, source badges, and the loud |
| + no-summary state. Everything shared (tokens, header, nav, tiles, #error, |
| + footer) is in status.css. */ |
| + .sessions { list-style: none; padding: 0; } |
| + .sessions li { |
| + background: var(--surface); border: 1px solid var(--border); |
| + border-radius: 12px; padding: 14px 16px; margin: 10px 0; |
| + } |
| + .sessions .top { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; } |
| + .sessions .who { font-weight: 650; } |
| + .sessions .when { color: var(--ink-2); font-size: 14px; font-variant-numeric: tabular-nums; } |
| + .sessions .facts { color: var(--ink-2); font-size: 14px; margin-top: 4px; font-variant-numeric: tabular-nums; } |
| + .sessions .why { margin-top: 8px; } |
| + .sessions .refs { margin-top: 4px; font-size: 14px; } |
| + .fp { color: var(--ink-2); font-size: 12px; font-family: ui-monospace, monospace; overflow-wrap: anywhere; margin-top: 6px; } |
| + /* Source badge: the word carries the state (never color alone); public and |
| + unknown-key get the warn treatment because they're the signals to see. */ |
| + .badge { |
| + display: inline-block; font-size: 12px; font-weight: 650; border-radius: 999px; |
| + padding: 1px 10px; border: 1px solid var(--border); color: var(--ink-2); |
| + } |
| + .badge.public, .badge.unknown { color: var(--warn); border-color: var(--warn); } |
| + /* The loud no-summary state: bold text + border + background β readable as |
| + a warning in both themes without relying on color alone. */ |
| + .nosummary { |
| + display: inline-block; margin-top: 8px; padding: 4px 10px; font-weight: 650; |
| + background: var(--redact-bg); color: var(--redact-ink); |
| + border: 1px solid var(--warn); border-radius: 8px; |
| + } |
| + .withheld { color: var(--redact-ink); background: var(--redact-bg); border-radius: 6px; padding: 2px 8px; } |
| + .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; } |
| +</style> |
| +</head> |
| +<body> |
| +<div class="wrap"> |
| + <header> |
| + <img src="./icon-192.png" alt="" /> |
| + <div> |
| + <p class="brand">Eleven Messenger</p> |
| + <h1>The SSH access ledger</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> |
| + <span aria-current="page">Access Ledger</span> |
| + </nav> |
| + |
| + <p class="lede">Every operator SSH session to the server that runs the fleet, |
| + on the public record with a plain-language reason β like the prompt ledger, |
| + but for hands on the box. The server publishes the automatic half itself |
| + (who connected, when, how many times, over the admin tunnel or from the |
| + public internet β <em>metadata only, never commands or content</em>), and |
| + each session's "why" is a small file reviewed in the open in the |
| + <a href="https://github.com/elevenmessenger/messenger/tree/main/web/status/ssh">repo</a>. |
| + Rows appear only after a session has closed and gone quiet β this page is a |
| + record, never a live "someone is connected right now" beacon.</p> |
| + |
| + <p class="asof">Last collected <time id="asof" datetime="">β</time></p> |
| + |
| + <p id="error" role="alert">Couldnβt load the access ledger. It may be mid-publish β this page retries every minute.</p> |
| + |
| + <details class="policy"> |
| + <summary>What this page can and canβt prove</summary> |
| + <p>π€ <strong>This is transparency, not tamper-proof forensics.</strong> The |
| + box publishes its own ledger from its own logs: an attacker with root could |
| + stop the collector or rewrite what it publishes, and an operator could too. |
| + What it honestly gives you is a standing public record that legitimate |
| + operator access must keep up with β a session with no reason renders loudly |
| + below as <span class="nosummary">no summary recorded</span>, and a |
| + security-sensitive session appears as <span class="withheld">(withheld: |
| + security)</span> rather than vanishing. The bucket this publishes to is |
| + versioned, so after-the-fact edits leave a history.</p> |
| + <p>π€ Sessions are grouped from sshdβs accepted-connection log: reconnects |
| + within 15 minutes count as one session, and a session publishes only after |
| + 20 quiet minutes. Operators are named by their public key fingerprint (the |
| + committed <a href="https://github.com/elevenmessenger/messenger/blob/main/web/status/ssh-operators.json">operator |
| + map</a>); a fingerprint the map doesnβt know renders as βunknown keyβ β on |
| + purpose, because an unexplained key is exactly what this page exists to |
| + surface.</p> |
| + </details> |
| + |
| + <div class="tiles" id="totals"></div> |
| + |
| + <section aria-labelledby="h-sessions"> |
| + <h2 id="h-sessions">Sessions, newest first</h2> |
| + <ol class="sessions" id="sessions" reversed></ol> |
| + <p class="empty" id="empty" hidden>No closed sessions in the ledger yet β |
| + the collector publishes each session after it ends.</p> |
| + </section> |
| + |
| + <footer> |
| + <p>Collected on the box every 5 minutes by |
| + <code>deploy/ssh-ledger-collect.sh</code> from sshdβs journal; the why-entries |
| + are PR-reviewed files in <code>web/status/ssh/</code>. Convention and |
| + redaction bar: <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(); |
| + // "1 connection, 2 connections" β never a bare label beside a count. |
| + const PLURAL = new Intl.PluralRules(); |
| + const plural = (n, one, many) => PLURAL.select(n) === "one" ? one : (many || one + "s"); |
| + const nOf = (n, one, many) => fmtInt(n) + " " + plural(n, one, many); |
| + 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" }); |
| + // The window is an approximation (span of accepted connections), so the |
| + // duration reads as one: a single connection shows no span at all. |
| + const fmtSpan = (a, b) => { |
| + const mins = Math.round((new Date(b) - new Date(a)) / 60000); |
| + if (mins <= 0) return ""; |
| + if (mins < 60) return "about " + nOf(mins, "minute"); |
| + const h = Math.floor(mins / 60), m = mins % 60; |
| + return "about " + nOf(h, "hour") + (m ? " " + nOf(m, "minute") : ""); |
| + }; |
| + |
| + // Join an entry to a session: same operator + same UTC date, preferring an |
| + // overlapping "HH:MMβHH:MM" window, else the nearest by midpoint β forgiving |
| + // by design (windows are approximate, and one entry may cover several |
| + // sessions, so entries are never consumed). |
| + const winRe = /^(\d{2}):(\d{2})[β-](\d{2}):(\d{2})/; |
| + const utcMins = (iso) => { const d = new Date(iso); return d.getUTCHours() * 60 + d.getUTCMinutes(); }; |
| + function entryFor(s, entries) { |
| + const day = (s.start || "").slice(0, 10); |
| + const c = entries.filter((e) => e.operator === s.operator && e.date === day); |
| + if (!c.length) return null; |
| + if (c.length === 1) return c[0]; |
| + const s0 = utcMins(s.start), s1 = Math.max(utcMins(s.end), s0); |
| + let best = null, bestScore = Infinity; |
| + for (const e of c) { |
| + const m = winRe.exec(e.window || ""); |
| + if (!m) { if (!best) { best = e; bestScore = 720; } continue; } // window-less entry: weak match |
| + const e0 = Number(m[1]) * 60 + Number(m[2]), e1 = Number(m[3]) * 60 + Number(m[4]); |
| + const overlaps = s0 <= e1 && e0 <= s1; |
| + const dist = overlaps ? 0 : Math.abs((s0 + s1) / 2 - (e0 + e1) / 2); |
| + if (dist < bestScore) { best = e; bestScore = dist; } |
| + } |
| + return best; |
| + } |
| + |
| + function render(sessions, entries) { |
| + const list = $("sessions"); |
| + list.textContent = ""; |
| + const newest = sessions.slice().sort((a, b) => (b.start || "").localeCompare(a.start || "")); |
| + $("empty").hidden = newest.length > 0; |
| + |
| + let unexplained = 0, connections = 0; |
| + const operators = new Set(); |
| + for (const s of newest) { |
| + connections += s.connections || 0; |
| + operators.add(s.operator); |
| + const li = document.createElement("li"); |
| + |
| + const top = document.createElement("div"); |
| + top.className = "top"; |
| + const who = document.createElement("span"); |
| + who.className = "who"; |
| + who.textContent = s.operator; |
| + const when = document.createElement("span"); |
| + when.className = "when"; |
| + when.textContent = fmtDay(s.start) + " Β· " + fmtTime(s.start) + |
| + (s.end !== s.start ? "β" + fmtTime(s.end) : ""); |
| + const badge = document.createElement("span"); |
| + const pub = s.source !== "wireguard"; |
| + badge.className = "badge" + (pub ? " public" : "") + (s.operator === "unknown key" ? " unknown" : ""); |
| + badge.textContent = pub ? "public internet" : "wireguard"; |
| + top.append(who, when, badge); |
| + li.appendChild(top); |
| + |
| + const facts = document.createElement("div"); |
| + facts.className = "facts"; |
| + const span = fmtSpan(s.start, s.end); |
| + facts.textContent = nOf(s.connections, "connection") + (span ? " over " + span : ""); |
| + li.appendChild(facts); |
| + |
| + const entry = entryFor(s, entries); |
| + if (entry) { |
| + const why = document.createElement("p"); |
| + why.className = "why"; |
| + if (/^\(withheld:/.test(entry.why || "")) { |
| + const w = document.createElement("span"); |
| + w.className = "withheld"; |
| + w.textContent = entry.why; |
| + why.appendChild(w); |
| + } else { |
| + why.textContent = entry.why || ""; |
| + } |
| + li.appendChild(why); |
| + const refs = (entry.refs || []).filter(Boolean); |
| + if (refs.length) { |
| + const p = document.createElement("p"); |
| + p.className = "refs"; |
| + p.append("refs: "); |
| + refs.forEach((r, i) => { |
| + if (i) p.append(", "); |
| + const url = /^https?:/.test(String(r)) ? String(r) |
| + : "https://github.com/elevenmessenger/messenger/pull/" + String(r).replace(/^#/, ""); |
| + const a = document.createElement("a"); |
| + a.href = url; |
| + const m = /\/(?:pull|issues)\/(\d+)$/.exec(url); |
| + a.textContent = m ? "#" + m[1] : url; |
| + p.appendChild(a); |
| + }); |
| + li.appendChild(p); |
| + } |
| + } else { |
| + unexplained++; |
| + const p = document.createElement("p"); |
| + const mark = document.createElement("strong"); |
| + mark.className = "nosummary"; |
| + mark.textContent = "β no summary recorded"; |
| + p.appendChild(mark); |
| + li.appendChild(p); |
| + } |
| + |
| + const fp = document.createElement("div"); |
| + fp.className = "fp"; |
| + fp.textContent = s.fingerprint; |
| + li.appendChild(fp); |
| + list.appendChild(li); |
| + } |
| + |
| + const tiles = [ |
| + [plural(newest.length, "session"), fmtInt(newest.length)], |
| + [plural(connections, "connection"), fmtInt(connections)], |
| + [plural(operators.size, "operator"), fmtInt(operators.size)], |
| + ["without a summary", fmtInt(unexplained)], |
| + ]; |
| + 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 [sr, er] = await Promise.all([fetch("./ssh-sessions.json"), fetch("./ssh-entries.json")]); |
| + if (!sr.ok || !er.ok) throw 0; |
| + const sdoc = await sr.json(); |
| + const entries = await er.json(); |
| + $("error").classList.remove("show"); |
| + const asof = $("asof"); |
| + asof.dateTime = sdoc.generated || ""; |
| + asof.textContent = sdoc.generated ? fmtDay(sdoc.generated) + " " + fmtTime(sdoc.generated) : "β"; |
| + render(sdoc.sessions || [], Array.isArray(entries) ? entries : []); |
| + } 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/ssh/2026-07-14-wireguard-rollout.json b/web/status/ssh/2026-07-14-wireguard-rollout.json| new file mode 100644 |
| index 0000000..aa0b2fd |
| --- /dev/null |
| +++ b/web/status/ssh/2026-07-14-wireguard-rollout.json |
| @@ -0,0 +1,7 @@ |
| +{ |
| + "date": "2026-07-14", |
| + "operator": "Paul Campbell", |
| + "window": "09:00β13:00 UTC (approx)", |
| + "why": "WireGuard hardening rollout: brought up the wg admin tunnel, closed public port 22, added named operator accounts and this ledger's collector.", |
| + "refs": ["https://github.com/elevenmessenger/messenger/pull/884"] |
| +} |
diff --git a/web/status/ssh/README.md b/web/status/ssh/README.md| new file mode 100644 |
| index 0000000..c4dc735 |
| --- /dev/null |
| +++ b/web/status/ssh/README.md |
| @@ -0,0 +1,53 @@ |
| +# π€ SSH access ledger β the why-entries |
| + |
| +One JSON file per entry β changelog-style, so parallel PRs never conflict. |
| +These are the *human* half of the public SSH access ledger |
| +(status.elevenmessenger.com/ssh.html): the box-side collector |
| +(`deploy/ssh-ledger-collect.sh`) publishes the automatic truth (who connected, |
| +when, from where) from sshd's journal; the files here say **why**. The |
| +collector merges them into `ssh-entries.json` and the page joins entry to |
| +session by operator + date/time-window overlap. Full picture: |
| +`docs/status.md Β§ The SSH access ledger`. |
| + |
| +## The convention |
| + |
| +**Any session β Claude or human β that SSHes to the router box adds an entry |
| +file here, in the same PR as the work (or an adjacent one).** A session the |
| +collector can't match to an entry renders loudly on the public page as |
| +"no summary recorded" β that state is the enforcement, so don't let it be |
| +yours. One entry may cover several sessions in the same working window. |
| + |
| +File name: `YYYY-MM-DD-<slug>.json` (like a changelog entry's name), e.g. |
| +`2026-07-14-wireguard-rollout.json`. |
| + |
| +```json |
| +{ |
| + "date": "YYYY-MM-DD", |
| + "operator": "Full Name (as in web/status/ssh-operators.json)", |
| + "window": "HH:MMβHH:MM UTC (approx)", |
| + "why": "terse what-not-how summary", |
| + "refs": ["https://github.com/elevenmessenger/messenger/pull/NNN"] |
| +} |
| +``` |
| + |
| +- `date` + `window` are UTC and approximate β the page matches forgivingly |
| + (same operator, same date, nearest/overlapping window), so don't sweat |
| + minutes; do keep the date right. |
| +- `operator` must match a handle in `web/status/ssh-operators.json`, or the |
| + join will miss. |
| +- `refs` β PR/issue URLs or numbers the session's work landed in; may be empty. |
| + |
| +## The redaction bar (mirrors the prompt registry's) |
| + |
| +These entries are public forever. PR review is the redaction gate β reviewers |
| +hold this line: |
| + |
| +- **What, not how.** "Rotated the push relay's tokens" β never the procedure, |
| + the file paths where key material lives, or anything that maps the defences. |
| +- **Never a credential, never an email**, never a secret's value or location. |
| +- **Security-sensitive sessions don't vanish β they tombstone.** A session |
| + whose honest summary would itself weaken the box publishes as |
| + `"why": "(withheld: security)"` β the access stays on the public record, |
| + only the reason is withheld. Silence (no entry at all) is not an option: |
| + that renders as "no summary recorded", which reads as *unexplained* access, |
| + not *protected* access. |
diff --git a/web/status/status.css b/web/status/status.css| index 04f1abf..9a17909 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, and |
| - the prompt ledger. |
| + income & expenditure, tier economics, security, carbon, contributors, the |
| + prompt ledger, and the SSH access ledger. |
| ONE design system β the design tokens, the reset, and every component the |
| - pages share (the brand masthead, the seven-item nav, tiles, the single-series |
| + pages share (the brand masthead, the eight-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 seven public dashboards, |
| +/* The status nav β a horizontal stepper through the eight 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; } |