| 1 | <!-- 🤖 Public fleet hardening posture: per box in the mesh, whether SSH is |
| 2 | locked down (docs/status.md § The fleet hardening dashboard). Two halves, |
| 3 | both in ./hardening.json (deploy/hardening-posture.sh): the LOCAL box reads |
| 4 | its own OS state authoritatively (ufw / sshd / systemd / wireguard); each |
| 5 | PEER is seen only from an outside :22 banner-grab probe from the mesh. |
| 6 | Fully self-contained: inline CSS/JS, no external requests except that one |
| 7 | relative JSON. Unknown renders LOUD — a box whose exposure can't be read |
| 8 | never shows as a silent pass. --> |
| 9 | <!doctype html> |
| 10 | <html lang="en"> |
| 11 | <head> |
| 12 | <meta charset="utf-8" /> |
| 13 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 14 | <meta name="color-scheme" content="light dark" /> |
| 15 | <title>Eleven — fleet hardening posture</title> |
| 16 | <link rel="icon" type="image/png" href="./icon-192.png" /> |
| 17 | <link rel="stylesheet" href="./status.css" /> |
| 18 | <style> |
| 19 | /* Hardening-only styles — the per-box cards, the loud/calm/unknown exposure |
| 20 | chips, and the small sub-state chips. Everything shared (tokens, header, |
| 21 | nav, tiles, #error, footer) is in status.css. */ |
| 22 | .boxes { list-style: none; padding: 0; } |
| 23 | .boxes li { |
| 24 | background: var(--surface); border: 1px solid var(--border); |
| 25 | border-radius: 12px; padding: 14px 16px; margin: 10px 0; |
| 26 | } |
| 27 | .boxes .top { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; } |
| 28 | .boxes .name { font-weight: 650; } |
| 29 | .boxes .region { color: var(--ink-2); font-size: 14px; } |
| 30 | .selfmark { |
| 31 | font-size: 12px; font-weight: 600; color: var(--ink-2); |
| 32 | border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; |
| 33 | } |
| 34 | .detail { color: var(--ink-2); font-size: 14px; margin-top: 6px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } |
| 35 | .probe-note { color: var(--ink-2); font-size: 13px; margin-top: 6px; } |
| 36 | |
| 37 | /* The exposure chip carries the verdict in WORD + WEIGHT + ICON, never colour |
| 38 | alone. Exposed is loud (bold, bordered, --bad); unknown is a warn state |
| 39 | (bordered, --warn) so it can never read as a quiet pass; hardened is calm. */ |
| 40 | .chip { |
| 41 | display: inline-block; margin-top: 10px; font-weight: 650; font-size: 14px; |
| 42 | border-radius: 8px; padding: 4px 10px; border: 1px solid var(--border); |
| 43 | } |
| 44 | .chip.exposed { |
| 45 | color: var(--bad); border: 2px solid var(--bad-mark); font-weight: 700; |
| 46 | background: var(--surface); |
| 47 | } |
| 48 | .chip.inconclusive { |
| 49 | color: var(--warn); border: 1px solid var(--warn); background: var(--redact-bg); |
| 50 | } |
| 51 | .chip.hardened { color: var(--good); border-color: var(--good); } |
| 52 | |
| 53 | /* Small secondary chips for the self-reported sub-states. Same rule: the value |
| 54 | word carries the state; an unknown one reads "not reported" behind a border |
| 55 | so it is visibly not a pass. */ |
| 56 | .subchips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } |
| 57 | .sub { |
| 58 | font-size: 12px; border-radius: 999px; padding: 1px 9px; |
| 59 | border: 1px solid var(--border); color: var(--ink-2); |
| 60 | } |
| 61 | .sub .k { color: var(--ink-2); } |
| 62 | .sub .v { font-weight: 650; } |
| 63 | .sub.good .v { color: var(--good); } |
| 64 | .sub.warn { border-color: var(--warn); } |
| 65 | .sub.warn .v { color: var(--warn); } |
| 66 | .sub.bad { border-color: var(--bad-mark); } |
| 67 | .sub.bad .v { color: var(--bad); font-weight: 700; } |
| 68 | .sub.muted .v { color: var(--ink-2); font-style: italic; } |
| 69 | |
| 70 | .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; } |
| 71 | .policy summary { cursor: pointer; font-weight: 600; } |
| 72 | .policy summary:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; } |
| 73 | .policy p { margin-top: 8px; } |
| 74 | .empty { color: var(--ink-2); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; } |
| 75 | .tiles .value { font-variant-numeric: tabular-nums; } |
| 76 | </style> |
| 77 | </head> |
| 78 | <body> |
| 79 | <div class="wrap"> |
| 80 | <header data-title="Fleet hardening posture"></header> |
| 81 | <nav class="status-nav" aria-label="Status"></nav> |
| 82 | <script src="./status-nav.js"></script> |
| 83 | |
| 84 | <p class="lede">Whether SSH is locked down on every server in the fleet, on the |
| 85 | public record — the same transparency-not-tamper-proof idea as the access |
| 86 | ledger, one level down at the machines themselves. Each box answers about |
| 87 | <em>itself</em>: it reads its own firewall, SSH config, auto-updates and admin |
| 88 | tunnel. Boxes it can see are also <em>probed from the outside</em> — a |
| 89 | connect-only check of whether port 22 answers from the mesh. That outside view |
| 90 | is vantage-dependent, so it is deliberately cautious (below): only a box that |
| 91 | actually answers with an SSH banner is called exposed. This is a mirror held |
| 92 | up to the fleet, not a security control.</p> |
| 93 | |
| 94 | <p class="asof">Last collected <time id="asof" datetime="">—</time></p> |
| 95 | |
| 96 | <p id="error" role="alert">Couldn’t load the hardening posture. It may be mid-publish — this page retries every minute.</p> |
| 97 | |
| 98 | <details class="policy"> |
| 99 | <summary>How each box is judged — and what the probe can’t prove</summary> |
| 100 | <p>🤖 <strong>The local read is authoritative; the outside probe is a hint.</strong> |
| 101 | A box reports its own posture from its own OS: <em>wireguard-only</em> when |
| 102 | port 22 is allowed only over the admin tunnel (<code>wg0</code>) and there is |
| 103 | no bare public rule, <em>internet-facing</em> the moment a public port-22 |
| 104 | rule exists. Those local reads are the truth for that box.</p> |
| 105 | <p>🤖 <strong>The peer <code>:22</code> probe is vantage-dependent.</strong> |
| 106 | One box connecting to another’s port 22 only sees the fleet from where it |
| 107 | stands — a firewall might admit the probing box but deny everyone else. So |
| 108 | the probe is read conservatively: only a returned <code>SSH-…</code> banner |
| 109 | is a confident <em>exposed</em>; a clean refusal or timeout is <em>not |
| 110 | reachable</em>; anything else that isn’t a clean refusal is |
| 111 | <em>inconclusive</em> — shown as a warn state, never a pass. The definitive |
| 112 | answer for any box is that box self-reading its own firewall once it runs the |
| 113 | collector.</p> |
| 114 | <p>🤖 <strong>Unknown is loud.</strong> Any signal the collector can’t read — |
| 115 | an unreadable firewall, a peer that hasn’t self-reported — renders as a |
| 116 | bordered warn chip, never as a quiet green pass. And like the access ledger, |
| 117 | this is transparency, not tamper-proof forensics: the box publishes its own |
| 118 | report, and the bucket it publishes to is versioned so after-the-fact edits |
| 119 | leave a history. No hostnames appear here — a box is named by the same |
| 120 | whimsical label the fleet page shows.</p> |
| 121 | </details> |
| 122 | |
| 123 | <div class="tiles" id="totals"></div> |
| 124 | |
| 125 | <section aria-labelledby="h-boxes"> |
| 126 | <h2 id="h-boxes">Servers, most exposed first</h2> |
| 127 | <ul class="boxes" id="boxes"></ul> |
| 128 | <p class="empty" id="empty" hidden>No boxes reported yet — the collector |
| 129 | publishes each box’s posture on its next pass.</p> |
| 130 | </section> |
| 131 | |
| 132 | <footer> |
| 133 | <p>Collected on the box every 5 minutes by |
| 134 | <code>deploy/hardening-posture.sh</code>: each box reads its own ufw / sshd / |
| 135 | systemd / wireguard state, and peers are probed <code>:22</code> from the |
| 136 | mesh. Method and honest limits: |
| 137 | <a href="https://github.com/elevenmessenger/messenger/blob/main/docs/status.md">docs/status.md</a>.</p> |
| 138 | </footer> |
| 139 | </div> |
| 140 | |
| 141 | <script> |
| 142 | (async function () { |
| 143 | "use strict"; |
| 144 | const $ = (id) => document.getElementById(id); |
| 145 | |
| 146 | const fmtInt = (n) => Number(n).toLocaleString(); |
| 147 | const PLURAL = new Intl.PluralRules(); |
| 148 | const plural = (n, one, many) => PLURAL.select(n) === "one" ? one : (many || one + "s"); |
| 149 | const fmtDay = (iso) => new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); |
| 150 | const fmtTime = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); |
| 151 | |
| 152 | // Which self-reported sub-states to show, in order, and how each value maps to |
| 153 | // a good / warn / bad / muted state. Only keys PRESENT on a box render — a |
| 154 | // probe-only peer carries just three, as "not reported"; a box that runs the |
| 155 | // collector carries all five with real values (its own row via source |
| 156 | // "local", and on every OTHER box's upload via the merged "self-reported"). |
| 157 | const SUBS = [ |
| 158 | ["root_login", "root login"], |
| 159 | ["password_auth", "password login"], |
| 160 | ["auto_updates", "auto-updates"], |
| 161 | ["wireguard", "wireguard"], |
| 162 | ["firewall_active", "firewall"], |
| 163 | ]; |
| 164 | function subState(key, val) { |
| 165 | if (val === undefined || val === null || val === "unknown") return { cls: "muted", text: "not reported" }; |
| 166 | switch (key) { |
| 167 | case "root_login": |
| 168 | if (val === "no") return { cls: "good", text: "no" }; |
| 169 | if (val === "yes") return { cls: "bad", text: "yes" }; |
| 170 | return { cls: "warn", text: String(val) }; // prohibit-password etc. |
| 171 | case "password_auth": |
| 172 | return val === "no" ? { cls: "good", text: "no" } : { cls: "bad", text: String(val) }; |
| 173 | case "auto_updates": |
| 174 | return val === "active" ? { cls: "good", text: "active" } : { cls: "warn", text: String(val) }; |
| 175 | case "wireguard": |
| 176 | return val === "up" ? { cls: "good", text: "up" } : { cls: "warn", text: String(val) }; |
| 177 | case "firewall_active": |
| 178 | return val === true ? { cls: "good", text: "active" } : { cls: "bad", text: "inactive" }; |
| 179 | } |
| 180 | return { cls: "muted", text: String(val) }; |
| 181 | } |
| 182 | |
| 183 | function exposureChip(box) { |
| 184 | const f = box.ssh_internet_facing; |
| 185 | const chip = document.createElement("span"); |
| 186 | chip.className = "chip"; |
| 187 | const glyph = document.createElement("span"); |
| 188 | glyph.setAttribute("aria-hidden", "true"); |
| 189 | let text; |
| 190 | if (f === true) { chip.classList.add("exposed"); glyph.textContent = "⚠ "; text = "SSH reachable from the internet"; } |
| 191 | else if (f === false) { chip.classList.add("hardened"); glyph.textContent = "✓ "; text = "SSH not internet-facing"; } |
| 192 | else { chip.classList.add("inconclusive"); glyph.textContent = "⚠ "; text = "SSH exposure not reported"; } |
| 193 | chip.append(glyph, document.createTextNode(text)); |
| 194 | return chip; |
| 195 | } |
| 196 | |
| 197 | function render(boxes) { |
| 198 | const list = $("boxes"); |
| 199 | list.textContent = ""; |
| 200 | $("empty").hidden = boxes.length > 0; |
| 201 | |
| 202 | let exposed = 0, hardened = 0, notReported = 0; |
| 203 | for (const box of boxes) { |
| 204 | const f = box.ssh_internet_facing; |
| 205 | if (f === true) exposed++; |
| 206 | else if (f === false) hardened++; |
| 207 | else notReported++; |
| 208 | |
| 209 | const li = document.createElement("li"); |
| 210 | |
| 211 | const top = document.createElement("div"); |
| 212 | top.className = "top"; |
| 213 | const name = document.createElement("span"); |
| 214 | name.className = "name"; |
| 215 | name.textContent = box.label || "unidentified box"; |
| 216 | top.appendChild(name); |
| 217 | if (box.region) { |
| 218 | const r = document.createElement("span"); |
| 219 | r.className = "region"; |
| 220 | r.textContent = box.region; |
| 221 | top.appendChild(r); |
| 222 | } |
| 223 | if (box.is_self) { |
| 224 | const s = document.createElement("span"); |
| 225 | s.className = "selfmark"; |
| 226 | s.textContent = "this box"; |
| 227 | top.appendChild(s); |
| 228 | } |
| 229 | li.appendChild(top); |
| 230 | |
| 231 | li.appendChild(exposureChip(box)); |
| 232 | |
| 233 | if (box.detail) { |
| 234 | const d = document.createElement("div"); |
| 235 | d.className = "detail"; |
| 236 | d.textContent = box.detail; |
| 237 | li.appendChild(d); |
| 238 | } |
| 239 | |
| 240 | const subs = document.createElement("div"); |
| 241 | subs.className = "subchips"; |
| 242 | for (const [key, label] of SUBS) { |
| 243 | if (!(key in box)) continue; // field this box doesn't claim |
| 244 | const st = subState(key, box[key]); |
| 245 | const chip = document.createElement("span"); |
| 246 | chip.className = "sub " + st.cls; |
| 247 | const k = document.createElement("span"); k.className = "k"; k.textContent = label + ": "; |
| 248 | const v = document.createElement("span"); v.className = "v"; v.textContent = st.text; |
| 249 | chip.append(k, v); |
| 250 | subs.appendChild(chip); |
| 251 | } |
| 252 | if (subs.childNodes.length) li.appendChild(subs); |
| 253 | |
| 254 | if (box.source === "probe") { |
| 255 | const note = document.createElement("p"); |
| 256 | note.className = "probe-note"; |
| 257 | note.textContent = "Seen from another box in the mesh — this box’s own report appears once it runs the collector."; |
| 258 | li.appendChild(note); |
| 259 | } else if (box.source === "self-reported") { |
| 260 | const note = document.createElement("p"); |
| 261 | note.className = "probe-note"; |
| 262 | note.textContent = "Self-reported by this box; its port 22 is also probed from the mesh outside."; |
| 263 | li.appendChild(note); |
| 264 | } |
| 265 | |
| 266 | list.appendChild(li); |
| 267 | } |
| 268 | |
| 269 | const total = boxes.length; |
| 270 | const tiles = [ |
| 271 | [plural(total, "box", "boxes"), fmtInt(total)], |
| 272 | ["hardened", fmtInt(hardened)], |
| 273 | ["exposed", fmtInt(exposed)], |
| 274 | ["not reported", fmtInt(notReported)], |
| 275 | ]; |
| 276 | const totals = $("totals"); |
| 277 | totals.textContent = ""; |
| 278 | for (const [label, val] of tiles) { |
| 279 | const tile = document.createElement("div"); |
| 280 | tile.className = "tile"; |
| 281 | const l = document.createElement("div"); l.className = "label"; l.textContent = label; |
| 282 | const v = document.createElement("div"); v.className = "value"; v.textContent = val; |
| 283 | tile.append(l, v); |
| 284 | totals.appendChild(tile); |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | async function load() { |
| 289 | try { |
| 290 | const r = await fetch("./hardening.json"); |
| 291 | if (!r.ok) throw 0; |
| 292 | const doc = await r.json(); |
| 293 | $("error").classList.remove("show"); |
| 294 | const asof = $("asof"); |
| 295 | asof.dateTime = doc.generated || ""; |
| 296 | asof.textContent = doc.generated ? fmtDay(doc.generated) + " " + fmtTime(doc.generated) : "—"; |
| 297 | render(Array.isArray(doc.boxes) ? doc.boxes : []); |
| 298 | } catch { |
| 299 | $("error").classList.add("show"); |
| 300 | } |
| 301 | } |
| 302 | await load(); |
| 303 | setInterval(load, 60000); // same retry cadence as the other dashboards |
| 304 | })(); |
| 305 | </script> |
| 306 | </body> |
| 307 | </html> |
| 308 | |