eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

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