#!/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= ./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 โ€” 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 โ€” 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