eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env bash
2# πŸ€– Public SSH access ledger β€” the box-side collector (docs/status.md Β§ The
3# SSH access ledger). Every 5 minutes (lchat-ssh-ledger.timer) this reads NEW
4# sshd journal events, groups accepted connections into operator sessions, and
5# appends closed sessions to the public ledger the status site serves at
6# ssh.html. Installed on the box at exactly /opt/lchat/ssh-ledger-collect.sh
7# (the unit's ExecStart points there).
8#
9# ============================ PRIVACY β€” NON-NEGOTIABLE ========================
10# Everything this script publishes is PUBLIC, forever (the bucket is
11# versioned). The rules:
12# - METADATA ONLY, NEVER CONTENT. Who (operator handle via public-key
13# fingerprint), when (start/end of the accepted-connection window), how
14# many connections, and wireguard-or-public source. No commands, no
15# command output, no session content β€” nothing here even reads it.
16# - PUBLISH ON CLOSE ONLY. A session row is emitted only after the group has
17# been quiet for >= QUIET_S β€” the public page must never be a live
18# "an operator is connected right now" beacon for an attacker to watch.
19# - Key FINGERPRINTS are public-key material (safe to publish); client IPs
20# are read only to pick the source word and never reach the JSON.
21# ==============================================================================
22#
23# This script is DELIBERATELY STANDALONE from deploy/status-publish.sh (the
24# box's copy of that script has diverged from main and must not be forced to
25# reconcile for this feature): it keeps its own state, writes its own outputs,
26# and uploads them itself with the same status-publisher creds.
27#
28# The human "why" half lives in the repo: web/status/ssh/*.json entry files,
29# PR-reviewed (review is the redaction gate), land in $PAGES_DIR/ssh/ via the
30# normal viewer-page scp step. This script merges them into one
31# ssh-entries.json so the page fetches exactly two JSON files.
32#
33# Usage:
34# ssh-ledger-collect.sh # what the timer runs
35#
36# Test mode (no journalctl, no upload, no box state): point every input at a
37# fixture β€” a file of journalctl -o json lines β€” and pin "now":
38# SSH_LEDGER_JOURNAL_FILE=events.jsonl SSH_LEDGER_STATE_DIR=/tmp/state \
39# SSH_LEDGER_PAGES_DIR=/tmp/pages SSH_LEDGER_NOW=<epoch> ./ssh-ledger-collect.sh
40#
41# Prerequisites on the box: jq (apt install jq) + the aws CLI and
42# /data/status.env creds already there for the status publisher.
43set -euo pipefail
44
45STATE_DIR=${SSH_LEDGER_STATE_DIR:-/data/ssh-ledger}
46PAGES_DIR=${SSH_LEDGER_PAGES_DIR:-/opt/lchat/status-pages}
47SSH_UNIT=${SSH_LEDGER_UNIT:-ssh} # Ubuntu's sshd unit is ssh.service
48BUCKET=${STATUS_BUCKET:-eleven-status-427880590669}
49REGION=${STATUS_REGION:-eu-west-1}
50STATUS_ENV=${STATUS_ENV_FILE:-/data/status.env}
51WG_PREFIX="172.22.22." # the wg0 admin subnet (/24)
52
53# Grouping windows. JOIN_S: reconnects are routine while working (a dropped
54# terminal, a second window, scp beside a shell), so accepts within 15 minutes
55# of the previous one are one session, not many rows. QUIET_S: a group only
56# publishes after 20 quiet minutes β€” longer than the join window on purpose,
57# so a row can never appear while its session could still legally grow, and
58# (the publish-on-close rule) the page never advertises a live connection.
59JOIN_S=900
60QUIET_S=1200
61
62NOW=${SSH_LEDGER_NOW:-$(date -u +%s)}
63CURSOR="$STATE_DIR/cursor"
64PENDING="$STATE_DIR/pending.tsv" # open groups: user fp start last connections source
65SESSIONS="$PAGES_DIR/ssh-sessions.json"
66ENTRIES="$PAGES_DIR/ssh-entries.json"
67OPERATORS="$PAGES_DIR/ssh-operators.json"
68
69command -v jq >/dev/null 2>&1 || { echo "jq missing (apt install jq)" >&2; exit 1; }
70mkdir -p "$STATE_DIR" "$PAGES_DIR"
71
72TMP=$(mktemp -d)
73trap 'rm -rf "$TMP"' EXIT
74
75# iso <epoch> β€” GNU date on the box; the -r fallback keeps the test mode
76# runnable on a BSD/macOS dev machine.
77iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ; }
78
79# atomic_write <src> <dest> β€” temp beside the target + mv, so a reader (or a
80# mid-write upload) never sees a half-written JSON. Same-directory temp keeps
81# the rename atomic (never across filesystems).
82atomic_write() { cp "$1" "$2.tmp.$$" && mv "$2.tmp.$$" "$2"; }
83
84# ---- 1. read new sshd events -------------------------------------------------
85# journalctl's --cursor-file persists our read position in $STATE_DIR (not the
86# journal itself), so it survives journald rotation: rotation drops old
87# entries, never the cursor, and the next run resumes exactly where this one
88# stopped. First run (no cursor yet) backfills from the start of the journal.
89if [ -n "${SSH_LEDGER_JOURNAL_FILE:-}" ]; then
90 cat "$SSH_LEDGER_JOURNAL_FILE" > "$TMP/journal.jsonl"
91else
92 journalctl -u "$SSH_UNIT" -o json --cursor-file "$CURSOR" --no-pager > "$TMP/journal.jsonl" || true
93fi
94
95# One TSV line per accepted-publickey login: epoch-seconds, user, client IP,
96# key fingerprint. Password/other auth is off on this box, so "Accepted
97# publickey" is the complete accept vocabulary; anything else in the sshd
98# journal (disconnects, probes, scanner noise) is deliberately ignored.
99jq -r 'select((.MESSAGE // "") | startswith("Accepted publickey for "))
100 | [(.__REALTIME_TIMESTAMP | tonumber / 1000000 | floor), .MESSAGE] | @tsv' \
101 "$TMP/journal.jsonl" > "$TMP/accepts.tsv"
102
103# ---- 2. fold accepts into pending groups -------------------------------------
104# pending.tsv holds every not-yet-published group. More than one group per
105# (user, fingerprint) can coexist briefly: a reconnect 16 minutes after the
106# last (past JOIN_S) starts a new group while the old one is still inside its
107# QUIET_S wait β€” the old row publishes a few minutes later on its own clock.
108[ -f "$PENDING" ] || : > "$PENDING"
109cp "$PENDING" "$TMP/pending.tsv"
110
111msg_re='^Accepted publickey for ([^ ]+) from ([0-9a-fA-F:.]+) port [0-9]+ ssh2: [^ ]+ (SHA256:[A-Za-z0-9+/=]+)'
112while IFS=$'\t' read -r ts msg; do
113 [[ "$msg" =~ $msg_re ]] || continue
114 user="${BASH_REMATCH[1]}" ip="${BASH_REMATCH[2]}" fp="${BASH_REMATCH[3]}"
115 # Source word only β€” the IP itself dies here (see PRIVACY above). A public
116 # accept should be rare now port 22 is wireguard-only, which is exactly why
117 # it must publish loudly when it happens.
118 case "$ip" in "$WG_PREFIX"*) src=wireguard ;; *) src=public ;; esac
119 # Join the newest open group for this (user, fp) if this accept is within
120 # JOIN_S of its last connection; otherwise open a new group. awk rewrites
121 # the whole (tiny) pending file per event β€” clarity over cleverness.
122 awk -v u="$user" -v f="$fp" -v ts="$ts" -v src="$src" -v join="$JOIN_S" '
123 BEGIN { OFS="\t"; joined=0 }
124 $1==u && $2==f && !joined && ts-$4 <= join && ts >= $4 {
125 $4=ts; $5=$5+1
126 if (src=="public") $6="public" # one public hop marks the whole group
127 joined=1
128 }
129 { print }
130 END { if (!joined) print u, f, ts, ts, 1, src }
131 ' "$TMP/pending.tsv" > "$TMP/pending.next" && mv "$TMP/pending.next" "$TMP/pending.tsv"
132done < "$TMP/accepts.tsv"
133
134# ---- 3. publish groups that have gone quiet ----------------------------------
135# Split pending into "still open" (kept for next run) and "quiet >= QUIET_S"
136# (emitted below). This is the publish-on-close rule from the header.
137awk -v now="$NOW" -v quiet="$QUIET_S" 'now - $4 >= quiet' "$TMP/pending.tsv" > "$TMP/closed.tsv"
138awk -v now="$NOW" -v quiet="$QUIET_S" 'now - $4 < quiet' "$TMP/pending.tsv" > "$TMP/open.tsv"
139
140# Operator attribution by fingerprint β€” web/status/ssh-operators.json, staged
141# with the viewer pages. An unmapped fingerprint publishes as "unknown key":
142# render-worthy on purpose (an unexplained key on the box is itself a signal),
143# never silently dropped.
144[ -f "$OPERATORS" ] || echo '{}' > "$OPERATORS"
145
146echo '[]' > "$TMP/new.json"
147while IFS=$'\t' read -r user fp start last conns src; do
148 [ -n "$user" ] || continue
149 jq --arg id "$user+$fp+$(iso "$start")" \
150 --arg fp "$fp" --arg start "$(iso "$start")" --arg end "$(iso "$last")" \
151 --argjson conns "$conns" --arg src "$src" \
152 --slurpfile ops "$OPERATORS" \
153 '. + [{id: $id, operator: ($ops[0][$fp] // "unknown key"),
154 fingerprint: $fp, start: $start, end: $end,
155 connections: $conns, source: $src}]' \
156 "$TMP/new.json" > "$TMP/new.next" && mv "$TMP/new.next" "$TMP/new.json"
157done < "$TMP/closed.tsv"
158
159# Append to the cumulative ledger, deduped on id (user+fingerprint+start), so
160# a re-run over replayed journal lines can never double-publish a session. A
161# missing ledger starts fresh; a CORRUPT one aborts the run β€” never clobber
162# published history with an empty file.
163if [ -f "$SESSIONS" ]; then
164 jq -e . "$SESSIONS" >/dev/null || { echo "corrupt $SESSIONS β€” refusing to overwrite" >&2; exit 1; }
165else
166 echo '{"sessions": []}' > "$SESSIONS"
167fi
168jq --arg now "$(iso "$NOW")" --slurpfile add "$TMP/new.json" \
169 '{generated: $now, sessions: ((.sessions // []) + $add[0] | unique_by(.id) | sort_by(.start))}' \
170 "$SESSIONS" > "$TMP/sessions.json"
171atomic_write "$TMP/sessions.json" "$SESSIONS"
172atomic_write "$TMP/open.tsv" "$PENDING"
173
174# ---- 4. merge the human why-entries ------------------------------------------
175# web/status/ssh/*.json (one file per entry β€” parallel PRs never conflict)
176# merge into one array so the page fetches exactly two JSONs. A malformed
177# entry fails the run loudly rather than silently dropping someone's "why".
178echo '[]' > "$TMP/entries.json"
179for f in "$PAGES_DIR"/ssh/*.json; do
180 [ -e "$f" ] || continue # no entries staged yet β€” publish an empty array
181 jq -e . "$f" >/dev/null 2>&1 || { echo "malformed why-entry $f β€” fix or revert it" >&2; exit 1; }
182 jq --slurpfile e "$f" '. + $e' "$TMP/entries.json" > "$TMP/entries.next" && \
183 mv "$TMP/entries.next" "$TMP/entries.json"
184done
185jq 'sort_by(.date) | reverse' "$TMP/entries.json" > "$TMP/entries.sorted"
186atomic_write "$TMP/entries.sorted" "$ENTRIES"
187
188# ---- 5. upload ----------------------------------------------------------------
189# Standalone by design (see header): this script uploads its own outputs with
190# the same scoped status-publisher creds status-publish.sh uses. Test mode
191# (fixture journal) never uploads. Re-uploading the page + operator map every
192# run self-heals a missed copy, same as the main publisher.
193if [ -z "${SSH_LEDGER_JOURNAL_FILE:-}" ]; then
194 command -v aws >/dev/null 2>&1 || { echo "aws CLI missing β€” collected, not uploaded" >&2; exit 1; }
195 if [ -z "${AWS_ACCESS_KEY_ID:-}" ] && [ -f "$STATUS_ENV" ]; then
196 set -a; . "$STATUS_ENV"; set +a
197 fi
198 up() { aws s3 cp "$1" "s3://$BUCKET/$2" --region "$REGION" \
199 --content-type "$3" --cache-control "$4" --no-progress >/dev/null; }
200 up "$SESSIONS" ssh-sessions.json "application/json" "max-age=60"
201 up "$ENTRIES" ssh-entries.json "application/json" "max-age=60"
202 [ -f "$PAGES_DIR/ssh.html" ] && up "$PAGES_DIR/ssh.html" ssh.html "text/html; charset=utf-8" "max-age=300"
203 [ -f "$OPERATORS" ] && up "$OPERATORS" ssh-operators.json "application/json" "max-age=300"
204 echo "ssh ledger published to s3://$BUCKET ($(iso "$NOW"))"
205fi
206