| 1 | <!-- 🤖 Public SSH access ledger: every operator SSH session to the router box, |
| 2 | published with a plain-language "why" (docs/status.md § The SSH access |
| 3 | ledger). Two halves joined here: ./ssh-sessions.json is the automatic |
| 4 | truth (deploy/ssh-ledger-collect.sh reads sshd's journal — metadata only, |
| 5 | published only after a session closes) and ./ssh-entries.json is the |
| 6 | human why-entries (web/status/ssh/*.json in the repo, PR-reviewed). |
| 7 | Fully self-contained: inline CSS/JS, no external requests except those |
| 8 | two JSONs (relative). A session with no matching entry renders loudly as |
| 9 | "no summary recorded" — that state is the convention's enforcement. --> |
| 10 | <!doctype html> |
| 11 | <html lang="en"> |
| 12 | <head> |
| 13 | <meta charset="utf-8" /> |
| 14 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 15 | <meta name="color-scheme" content="light dark" /> |
| 16 | <title>Eleven — the SSH access ledger</title> |
| 17 | <link rel="icon" type="image/png" href="./icon-192.png" /> |
| 18 | <link rel="stylesheet" href="./status.css" /> |
| 19 | <style> |
| 20 | /* Access-ledger-only styles — the session rows, source badges, and the loud |
| 21 | no-summary state. Everything shared (tokens, header, nav, tiles, #error, |
| 22 | footer) is in status.css. */ |
| 23 | .sessions { list-style: none; padding: 0; } |
| 24 | .sessions li { |
| 25 | background: var(--surface); border: 1px solid var(--border); |
| 26 | border-radius: 12px; padding: 14px 16px; margin: 10px 0; |
| 27 | } |
| 28 | .sessions .top { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; } |
| 29 | .sessions .who { font-weight: 650; } |
| 30 | .sessions .when { color: var(--ink-2); font-size: 14px; font-variant-numeric: tabular-nums; } |
| 31 | .sessions .facts { color: var(--ink-2); font-size: 14px; margin-top: 4px; font-variant-numeric: tabular-nums; } |
| 32 | .sessions .why { margin-top: 8px; } |
| 33 | .sessions .refs { margin-top: 4px; font-size: 14px; } |
| 34 | .fp { color: var(--ink-2); font-size: 12px; font-family: ui-monospace, monospace; overflow-wrap: anywhere; margin-top: 6px; } |
| 35 | /* Source badge: the word carries the state (never color alone); public and |
| 36 | unknown-key get the warn treatment because they're the signals to see. */ |
| 37 | .badge { |
| 38 | display: inline-block; font-size: 12px; font-weight: 650; border-radius: 999px; |
| 39 | padding: 1px 10px; border: 1px solid var(--border); color: var(--ink-2); |
| 40 | } |
| 41 | .badge.public, .badge.unknown { color: var(--warn); border-color: var(--warn); } |
| 42 | /* The loud no-summary state: bold text + border + background — readable as |
| 43 | a warning in both themes without relying on color alone. */ |
| 44 | .nosummary { |
| 45 | display: inline-block; margin-top: 8px; padding: 4px 10px; font-weight: 650; |
| 46 | background: var(--redact-bg); color: var(--redact-ink); |
| 47 | border: 1px solid var(--warn); border-radius: 8px; |
| 48 | } |
| 49 | .withheld { color: var(--redact-ink); background: var(--redact-bg); border-radius: 6px; padding: 2px 8px; } |
| 50 | .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; } |
| 51 | .policy summary { cursor: pointer; font-weight: 600; } |
| 52 | .policy summary:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; } |
| 53 | .policy p { margin-top: 8px; } |
| 54 | .empty { color: var(--ink-2); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; } |
| 55 | </style> |
| 56 | </head> |
| 57 | <body> |
| 58 | <div class="wrap"> |
| 59 | <header data-title="The SSH access ledger"></header> |
| 60 | <nav class="status-nav" aria-label="Status"></nav> |
| 61 | <script src="./status-nav.js"></script> |
| 62 | |
| 63 | <p class="lede">Every operator SSH session to the server that runs the fleet, |
| 64 | on the public record with a plain-language reason — like the prompt ledger, |
| 65 | but for hands on the box. The server publishes the automatic half itself |
| 66 | (who connected, when, how many times, over the admin tunnel or from the |
| 67 | public internet — <em>metadata only, never commands or content</em>), and |
| 68 | each session's "why" is a small file reviewed in the open in the |
| 69 | <a href="https://github.com/elevenmessenger/messenger/tree/main/web/status/ssh">repo</a>. |
| 70 | Rows appear only after a session has closed and gone quiet — this page is a |
| 71 | record, never a live "someone is connected right now" beacon.</p> |
| 72 | |
| 73 | <p class="asof">Last collected <time id="asof" datetime="">—</time></p> |
| 74 | |
| 75 | <p id="error" role="alert">Couldn’t load the access ledger. It may be mid-publish — this page retries every minute.</p> |
| 76 | |
| 77 | <details class="policy"> |
| 78 | <summary>What this page can and can’t prove</summary> |
| 79 | <p>🤖 <strong>This is transparency, not tamper-proof forensics.</strong> The |
| 80 | box publishes its own ledger from its own logs: an attacker with root could |
| 81 | stop the collector or rewrite what it publishes, and an operator could too. |
| 82 | What it honestly gives you is a standing public record that legitimate |
| 83 | operator access must keep up with — a session with no reason renders loudly |
| 84 | below as <span class="nosummary">no summary recorded</span>, and a |
| 85 | security-sensitive session appears as <span class="withheld">(withheld: |
| 86 | security)</span> rather than vanishing. The bucket this publishes to is |
| 87 | versioned, so after-the-fact edits leave a history.</p> |
| 88 | <p>🤖 Sessions are grouped from sshd’s accepted-connection log: reconnects |
| 89 | within 15 minutes count as one session, and a session publishes only after |
| 90 | 20 quiet minutes. Operators are named by their public key fingerprint (the |
| 91 | committed <a href="https://github.com/elevenmessenger/messenger/blob/main/web/status/ssh-operators.json">operator |
| 92 | map</a>); a fingerprint the map doesn’t know renders as “unknown key” — on |
| 93 | purpose, because an unexplained key is exactly what this page exists to |
| 94 | surface.</p> |
| 95 | </details> |
| 96 | |
| 97 | <div class="tiles" id="totals"></div> |
| 98 | |
| 99 | <section aria-labelledby="h-sessions"> |
| 100 | <h2 id="h-sessions">Sessions, newest first</h2> |
| 101 | <ol class="sessions" id="sessions" reversed></ol> |
| 102 | <p class="empty" id="empty" hidden>No closed sessions in the ledger yet — |
| 103 | the collector publishes each session after it ends.</p> |
| 104 | </section> |
| 105 | |
| 106 | <footer> |
| 107 | <p>Collected on the box every 5 minutes by |
| 108 | <code>deploy/ssh-ledger-collect.sh</code> from sshd’s journal; the why-entries |
| 109 | are PR-reviewed files in <code>web/status/ssh/</code>. Convention and |
| 110 | redaction bar: <a href="https://github.com/elevenmessenger/messenger/blob/main/docs/status.md">docs/status.md</a>.</p> |
| 111 | </footer> |
| 112 | </div> |
| 113 | |
| 114 | <script> |
| 115 | (async function () { |
| 116 | "use strict"; |
| 117 | const $ = (id) => document.getElementById(id); |
| 118 | |
| 119 | const fmtInt = (n) => Number(n).toLocaleString(); |
| 120 | // "1 connection, 2 connections" — never a bare label beside a count. |
| 121 | const PLURAL = new Intl.PluralRules(); |
| 122 | const plural = (n, one, many) => PLURAL.select(n) === "one" ? one : (many || one + "s"); |
| 123 | const nOf = (n, one, many) => fmtInt(n) + " " + plural(n, one, many); |
| 124 | const fmtDay = (iso) => new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); |
| 125 | const fmtTime = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); |
| 126 | // The window is an approximation (span of accepted connections), so the |
| 127 | // duration reads as one: a single connection shows no span at all. |
| 128 | const fmtSpan = (a, b) => { |
| 129 | const mins = Math.round((new Date(b) - new Date(a)) / 60000); |
| 130 | if (mins <= 0) return ""; |
| 131 | if (mins < 60) return "about " + nOf(mins, "minute"); |
| 132 | const h = Math.floor(mins / 60), m = mins % 60; |
| 133 | return "about " + nOf(h, "hour") + (m ? " " + nOf(m, "minute") : ""); |
| 134 | }; |
| 135 | |
| 136 | // Join an entry to a session: same operator + same UTC date, preferring an |
| 137 | // overlapping "HH:MM–HH:MM" window, else the nearest by midpoint — forgiving |
| 138 | // by design (windows are approximate, and one entry may cover several |
| 139 | // sessions, so entries are never consumed). |
| 140 | const winRe = /^(\d{2}):(\d{2})[–-](\d{2}):(\d{2})/; |
| 141 | const utcMins = (iso) => { const d = new Date(iso); return d.getUTCHours() * 60 + d.getUTCMinutes(); }; |
| 142 | function entryFor(s, entries) { |
| 143 | const day = (s.start || "").slice(0, 10); |
| 144 | const c = entries.filter((e) => e.operator === s.operator && e.date === day); |
| 145 | if (!c.length) return null; |
| 146 | if (c.length === 1) return c[0]; |
| 147 | const s0 = utcMins(s.start), s1 = Math.max(utcMins(s.end), s0); |
| 148 | let best = null, bestScore = Infinity; |
| 149 | for (const e of c) { |
| 150 | const m = winRe.exec(e.window || ""); |
| 151 | if (!m) { if (!best) { best = e; bestScore = 720; } continue; } // window-less entry: weak match |
| 152 | const e0 = Number(m[1]) * 60 + Number(m[2]), e1 = Number(m[3]) * 60 + Number(m[4]); |
| 153 | const overlaps = s0 <= e1 && e0 <= s1; |
| 154 | const dist = overlaps ? 0 : Math.abs((s0 + s1) / 2 - (e0 + e1) / 2); |
| 155 | if (dist < bestScore) { best = e; bestScore = dist; } |
| 156 | } |
| 157 | return best; |
| 158 | } |
| 159 | |
| 160 | function render(sessions, entries) { |
| 161 | const list = $("sessions"); |
| 162 | list.textContent = ""; |
| 163 | const newest = sessions.slice().sort((a, b) => (b.start || "").localeCompare(a.start || "")); |
| 164 | $("empty").hidden = newest.length > 0; |
| 165 | |
| 166 | let unexplained = 0, connections = 0; |
| 167 | const operators = new Set(); |
| 168 | for (const s of newest) { |
| 169 | connections += s.connections || 0; |
| 170 | operators.add(s.operator); |
| 171 | const li = document.createElement("li"); |
| 172 | |
| 173 | const top = document.createElement("div"); |
| 174 | top.className = "top"; |
| 175 | const who = document.createElement("span"); |
| 176 | who.className = "who"; |
| 177 | who.textContent = s.operator; |
| 178 | const when = document.createElement("span"); |
| 179 | when.className = "when"; |
| 180 | when.textContent = fmtDay(s.start) + " · " + fmtTime(s.start) + |
| 181 | (s.end !== s.start ? "–" + fmtTime(s.end) : ""); |
| 182 | const badge = document.createElement("span"); |
| 183 | const pub = s.source !== "wireguard"; |
| 184 | badge.className = "badge" + (pub ? " public" : "") + (s.operator === "unknown key" ? " unknown" : ""); |
| 185 | badge.textContent = pub ? "public internet" : "wireguard"; |
| 186 | top.append(who, when, badge); |
| 187 | li.appendChild(top); |
| 188 | |
| 189 | const facts = document.createElement("div"); |
| 190 | facts.className = "facts"; |
| 191 | const span = fmtSpan(s.start, s.end); |
| 192 | facts.textContent = nOf(s.connections, "connection") + (span ? " over " + span : ""); |
| 193 | li.appendChild(facts); |
| 194 | |
| 195 | const entry = entryFor(s, entries); |
| 196 | if (entry) { |
| 197 | const why = document.createElement("p"); |
| 198 | why.className = "why"; |
| 199 | if (/^\(withheld:/.test(entry.why || "")) { |
| 200 | const w = document.createElement("span"); |
| 201 | w.className = "withheld"; |
| 202 | w.textContent = entry.why; |
| 203 | why.appendChild(w); |
| 204 | } else { |
| 205 | why.textContent = entry.why || ""; |
| 206 | } |
| 207 | li.appendChild(why); |
| 208 | const refs = (entry.refs || []).filter(Boolean); |
| 209 | if (refs.length) { |
| 210 | const p = document.createElement("p"); |
| 211 | p.className = "refs"; |
| 212 | p.append("refs: "); |
| 213 | refs.forEach((r, i) => { |
| 214 | if (i) p.append(", "); |
| 215 | const url = /^https?:/.test(String(r)) ? String(r) |
| 216 | : "https://github.com/elevenmessenger/messenger/pull/" + String(r).replace(/^#/, ""); |
| 217 | const a = document.createElement("a"); |
| 218 | a.href = url; |
| 219 | const m = /\/(?:pull|issues)\/(\d+)$/.exec(url); |
| 220 | a.textContent = m ? "#" + m[1] : url; |
| 221 | p.appendChild(a); |
| 222 | }); |
| 223 | li.appendChild(p); |
| 224 | } |
| 225 | } else { |
| 226 | unexplained++; |
| 227 | const p = document.createElement("p"); |
| 228 | const mark = document.createElement("strong"); |
| 229 | mark.className = "nosummary"; |
| 230 | mark.textContent = "⚠ no summary recorded"; |
| 231 | p.appendChild(mark); |
| 232 | li.appendChild(p); |
| 233 | } |
| 234 | |
| 235 | const fp = document.createElement("div"); |
| 236 | fp.className = "fp"; |
| 237 | fp.textContent = s.fingerprint; |
| 238 | li.appendChild(fp); |
| 239 | list.appendChild(li); |
| 240 | } |
| 241 | |
| 242 | const tiles = [ |
| 243 | [plural(newest.length, "session"), fmtInt(newest.length)], |
| 244 | [plural(connections, "connection"), fmtInt(connections)], |
| 245 | [plural(operators.size, "operator"), fmtInt(operators.size)], |
| 246 | ["without a summary", fmtInt(unexplained)], |
| 247 | ]; |
| 248 | const totals = $("totals"); |
| 249 | totals.textContent = ""; |
| 250 | for (const [label, val] of tiles) { |
| 251 | const tile = document.createElement("div"); |
| 252 | tile.className = "tile"; |
| 253 | const l = document.createElement("div"); l.className = "label"; l.textContent = label; |
| 254 | const v = document.createElement("div"); v.className = "value"; v.textContent = val; |
| 255 | tile.append(l, v); |
| 256 | totals.appendChild(tile); |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | async function load() { |
| 261 | try { |
| 262 | const [sr, er] = await Promise.all([fetch("./ssh-sessions.json"), fetch("./ssh-entries.json")]); |
| 263 | if (!sr.ok || !er.ok) throw 0; |
| 264 | const sdoc = await sr.json(); |
| 265 | const entries = await er.json(); |
| 266 | $("error").classList.remove("show"); |
| 267 | const asof = $("asof"); |
| 268 | asof.dateTime = sdoc.generated || ""; |
| 269 | asof.textContent = sdoc.generated ? fmtDay(sdoc.generated) + " " + fmtTime(sdoc.generated) : "—"; |
| 270 | render(sdoc.sessions || [], Array.isArray(entries) ? entries : []); |
| 271 | } catch { |
| 272 | $("error").classList.add("show"); |
| 273 | } |
| 274 | } |
| 275 | await load(); |
| 276 | setInterval(load, 60000); // same retry cadence as the other dashboards |
| 277 | })(); |
| 278 | </script> |
| 279 | </body> |
| 280 | </html> |
| 281 | |