secubox-deb/packages/secubox-sentinelle-gsm/www/sentinelle/sentinelle.js
CyberMind 95550edc4e
feat(secubox-sentinelle-gsm): v0.2.0 — standalone WebUI + local alerts (SSE + browser notification) (closes #340) (#341)
* docs(plan): #340 secubox-sentinelle-gsm v0.2 standalone webui + local alerts (ref #340)

* feat(sentinelle-gsm): alert_sink — SQLite history + SSE broadcast hub (ref #340)

Introduces lib/sentinelle_gsm/alert_sink.py: a SQLite-backed alert
history with an in-memory asyncio pub/sub hub that fans new alerts
out to live Server-Sent Events subscribers.

The Alert dataclass carries only privacy-safe fields — `subscriber_hash`
is HMAC-truncated (Anonymizer), never plaintext. AlertSink.write()
enforces this with a load-bearing privacy guard: any string field
matching `\b\d{15}\b` (plaintext IMSI shape) triggers ValueError and
the row is refused. SQLite is opened with check_same_thread=False so
the FastAPI app can read/write from multiple coroutines safely.

Adds api/tests/test_alert_sink.py with 5 tests covering write/list
roundtrip, the plaintext-IMSI privacy guard, HMAC acceptance, live
subscriber fan-out, and SSE chunk format. 5 passed locally.

* feat(sentinelle-gsm): trusted phones registry — HMAC-hashed IMSI + label (ref #340)

Privacy contract:
- Plaintext IMSI is accepted by add() ONCE, immediately HMAC-hashed via
  the existing sentinelle_gsm.observer.Anonymizer.anonymize() helper.
- The plaintext never persisted, never logged, never returned by any
  method. After add() returns, plaintext_imsi is out of scope.
- The on-disk store contains only {id, imsi_hash, label, added_at}.

Storage format: JSON via the stdlib `json` module instead of TOML.
`python3-tomli-w` (the only writer for the new tomllib reader) is not
in Debian bookworm — it first ships in trixie. python3-toml (legacy)
writes TOML but is deprecated. JSON is stdlib, atomically renderable,
and human-grep-able, which is what a trusted-phones registry needs.

Anonymizer integration: uses .anonymize() (HMAC-SHA256, 16 hex chars
truncated). The plan referenced .hash_subscriber_id() — that method
does not exist on Anonymizer in observer.py; .anonymize() is the
canonical hashing entry point.

Tests: 6/6 passing
- test_add_hashes_and_does_not_store_plaintext (privacy invariant
  enforced: plaintext IMSI never appears on disk)
- test_lookup_by_hash_finds_added_phone
- test_lookup_by_hash_returns_none_for_unknown
- test_delete_removes_entry
- test_invalid_imsi_rejected
- test_persistence_across_instances

* feat(sentinelle-gsm): API — /alerts (history + SSE + test) + /trusted CRUD (ref #340)

Adds 6 endpoints under the existing /api/v1/sensor/gsm/ prefix
(nginx-added; mounted at root of the FastAPI app):

  GET    /alerts          — paginated SQLite-backed history
                            (query: ?limit=100&since=<epoch>)
  GET    /alerts/stream   — SSE live feed; Cache-Control: no-cache,
                            X-Accel-Buffering: no, Connection: keep-alive
                            so nginx and HTTP caches don't buffer it
  POST   /alerts/test     — operator manual trigger; ValueError from
                            the AlertSink privacy guard (plaintext-IMSI
                            shape) surfaces as HTTP 500
  GET    /trusted         — list trusted phones (HMAC + label, no plaintext)
  POST   /trusted         — body {imsi, label}; ValueError → 400
  DELETE /trusted/{id}    — unknown id → 404

Wiring:
  * AlertSink + TrustedRegistry are module-level singletons initialised
    on FastAPI startup so tests can override via monkeypatch.
  * `require_jwt` is a no-op dependency stub — real JWT enforcement is
    done by nginx + Authelia in front of the Unix socket. The stub
    exists so test code (and future host-direct callers) can swap auth
    via `app.dependency_overrides[require_jwt]`.
  * Anonymizer is loaded via a new `_get_anonymizer()` helper that
    reads /etc/secubox/secrets/sentinelle-gsm-hmac when present and
    falls back to an ephemeral key otherwise.
  * trusted.json path is /etc/secubox/sentinelle-gsm/trusted.json
    (the Task-2 deviation from the plan's TOML; bookworm lacks
    python3-tomli-w).
  * version bumped 0.1.0 → 0.2.0 in the FastAPI metadata only;
    the debian/changelog bump lands in Task 6.

Tests (7 new in api/tests/):
  * test_alerts_api.py — POST /alerts/test happy path, GET history
    roundtrip, plaintext-IMSI guard → 500, SSE route registration
    (via app.routes + /openapi.json, not by opening the stream — the
    async generator awaits forever on q.get(), incompatible with sync
    TestClient iteration; SSE chunk format is covered by
    test_alert_sink.py::test_stream_emits_sse_format).
  * test_trusted_api.py — add/list/delete roundtrip with hash-not-
    plaintext invariant, invalid IMSI → 400, unknown id → 404.

Run: pytest api/tests/test_alerts_api.py api/tests/test_trusted_api.py
Result: 7 passed.

* feat(sentinelle-gsm): standalone webui — live alerts + browser Notification API (ref #340)

Adds the Task 4 standalone WebUI for sentinelle-gsm at
packages/secubox-sentinelle-gsm/www/sentinelle/:

- index.html — canonical SecuBox scaffold (body flex, aside.sidebar 220px
  fixed with section anchors, main reserves the global menu bar with
  padding-top: calc(48px + 1.5rem)). Four panels: status strip, alerts
  table (aria-live=polite), trusted phones table, actions.
- sentinelle.css — MIND palette (--mind-violet: #3D35A0) per Charte
  SecuBox, Space Grotesk titles + JetBrains Mono data/code, modal with
  z-index 1500 (above the global-menu-bar z-index 998), toast banner,
  score chip colour grading (low/med/high), responsive breakpoints.
- sentinelle.js — vanilla single-IIFE module, no framework, no CDN.
  EventSource consumer of /api/v1/sensor/gsm/alerts/stream (auto-reconnect
  surface), CRUD wrappers for /alerts /alerts/test /trusted /trusted/{id},
  Browser Notification API (requested from a user-gesture handler), beep
  synthesised on-the-fly via Web Audio API (no asset), localStorage mute
  toggle, modal-based add flow, ESC/backdrop close, accessible aria-live
  region for the alerts list.

All API calls hit root-relative /api/v1/sensor/gsm/* — Task 5 will mount
the nginx route. Local smoke test booted uvicorn against the API and
verified the HTML/CSS/JS serve and all four CRUD/test routes respond as
the UI expects.

* feat(sentinelle-gsm): nginx route /sentinelle/ + MIND menu entry + .install (ref #340)

* nginx/sentinelle-webui.conf: alias for /sentinelle/ (static webui) +
  SSE-tuned override for /api/v1/sensor/gsm/alerts/stream. Uses `^~`
  literal-prefix match so it wins precedence over the shorter
  /api/v1/sensor/gsm/ prefix in sentinelle-gsm.conf and against any
  future regex locations in the same server scope. Re-sets
  proxy_read_timeout / proxy_send_timeout to 24h after the common
  secubox-proxy include (which forces 30s) so long-lived SSE
  connections survive.
* menu.d/45-sentinelle.json: MIND-category entry (icon: satellite,
  order: 45). Category emitted as lowercase `mind` to match the
  canonical schema used by every other package menu.d entry.
* debian/secubox-sentinelle-gsm.install: ship the new files. The
  package uses a hand-rolled override_dh_auto_install in debian/rules,
  so the install file is documentation; debian/rules is extended
  with explicit cp steps for nginx/sentinelle-webui.conf and
  www/sentinelle/. menu.d already had a wildcard copy.

* chore(sentinelle-gsm): bump 0.2.0 + extend privacy invariant tests (closes #340)

- Append 3 new privacy invariant tests:
  * Alert dataclass has no plaintext IMSI/IMEI/TMSI/MSISDN/ICCID fields
  * TrustedPhone dataclass only stores imsi_hash (never plaintext)
  * AlertSink.write() raises ValueError on any 15-digit token in a
    textual field — non-regression guard for the OPAD-doctrine privacy
    invariant.
- Bump debian/changelog to 0.2.0-1~bookworm1 with the full Task 1-6
  feature summary: alert_sink, trusted registry, REST routes, WebUI,
  nginx + Hub menu integration. Notes the JSON-over-TOML deviation
  (python3-tomli-w not in bookworm).
- 29/29 tests pass: 5 alert_sink + 4 alerts_api + 3 trusted_api +
  6 trusted_registry + 11 privacy_invariant (8 prior + 3 new).

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-22 11:49:05 +02:00

447 lines
17 KiB
JavaScript

// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
// Source-Disclosed License — All rights reserved except as expressly granted.
// See LICENCE-CMSD-1.0.md for terms.
/**
* SecuBox-Deb :: SENTINELLE-GSM :: standalone webui logic.
*
* - Live alerts via SSE (text/event-stream, "alert" events).
* - Browser Notification API for desktop popups (requires user gesture).
* - Web Audio API synthesised beep (no static asset).
* - CRUD against /api/v1/sensor/gsm/* (root-relative; nginx adds the prefix).
*
* No framework, no CDN — pure vanilla, consistent with other SecuBox webuis.
*/
(function () {
"use strict";
const API = "/api/v1/sensor/gsm";
const MAX_ROWS = 200;
const BEEP_MUTE_KEY = "sgsm.beep.muted";
// ── DOM refs ────────────────────────────────────────────────────────
const els = {
streamDot: document.getElementById("stream-dot"),
streamStatus: document.getElementById("stream-status"),
trustedCount: document.getElementById("trusted-count"),
lastAlertTs: document.getElementById("last-alert-ts"),
notifDot: document.getElementById("notif-dot"),
notifStatus: document.getElementById("notif-status"),
alertsTbody: document.getElementById("alerts-tbody"),
alertsCount: document.getElementById("alerts-count"),
trustedTbody: document.getElementById("trusted-tbody"),
btnRefresh: document.getElementById("btn-refresh-alerts"),
btnAdd: document.getElementById("btn-add-trusted"),
btnTest: document.getElementById("btn-test-alert"),
btnNotif: document.getElementById("btn-request-notif"),
btnMute: document.getElementById("btn-mute-beep"),
muteLabel: document.getElementById("mute-label"),
modal: document.getElementById("modal-add"),
modalForm: document.getElementById("form-add-trusted"),
fieldImsi: document.getElementById("field-imsi"),
fieldLabel: document.getElementById("field-label"),
btnCancelAdd: document.getElementById("btn-cancel-add"),
toast: document.getElementById("toast"),
};
// ── state ───────────────────────────────────────────────────────────
let alertCount = 0;
let beepMuted = (localStorage.getItem(BEEP_MUTE_KEY) === "1");
let _audioCtx = null;
let _streamErrorTimer = null;
// ── utils ───────────────────────────────────────────────────────────
function fmtTime(epochSec) {
if (!epochSec) return "—";
const d = new Date(epochSec * 1000);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
function fmtDateTime(epochSec) {
if (!epochSec) return "—";
const d = new Date(epochSec * 1000);
return d.toLocaleString("sv-SE"); // ISO-ish, locale-stable
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function shortId(id) {
const s = String(id || "");
return s.length > 8 ? s.slice(0, 8) : s;
}
function scoreClass(score) {
const n = Number(score) || 0;
if (n >= 70) return "high";
if (n >= 40) return "med";
return "low";
}
function toast(msg, kind) {
els.toast.textContent = msg;
els.toast.className = "toast show " + (kind || "");
clearTimeout(toast._t);
toast._t = setTimeout(() => {
els.toast.classList.remove("show");
}, 3000);
}
async function apiFetch(path, opts) {
const res = await fetch(API + path, opts || {});
let body = null;
try { body = await res.json(); } catch (_) { /* ignore */ }
if (!res.ok) {
const msg = (body && body.detail) || `HTTP ${res.status}`;
const err = new Error(msg);
err.status = res.status;
throw err;
}
return body;
}
// ── notifications ───────────────────────────────────────────────────
function refreshNotifStatus() {
if (!("Notification" in window)) {
els.notifStatus.textContent = "unsupported";
els.notifDot.className = "dot dot-down";
els.btnNotif.disabled = true;
return;
}
const p = Notification.permission;
els.notifStatus.textContent = p;
els.notifDot.className =
(p === "granted") ? "dot dot-live" :
(p === "denied") ? "dot dot-down" :
"dot dot-warn";
if (p === "granted") {
els.btnNotif.textContent = "Desktop notifications enabled";
els.btnNotif.disabled = true;
}
}
async function ensureNotificationPermission() {
// Must be called from a user gesture handler — modern browsers reject
// permission requests originating from page-load JS.
if (!("Notification" in window)) return false;
if (Notification.permission === "granted") return true;
if (Notification.permission === "denied") return false;
try {
const p = await Notification.requestPermission();
refreshNotifStatus();
return p === "granted";
} catch (_) {
return false;
}
}
function showDesktopAlert(alert) {
if (!("Notification" in window)) return;
if (Notification.permission !== "granted") return;
const title = `SENTINELLE-GSM — score ${alert.score}`;
let body = `${alert.reason}\ncell ${alert.cell_id} arfcn ${alert.arfcn}`;
if (alert.trusted_label) body += `\ntargets: ${alert.trusted_label}`;
try {
const n = new Notification(title, {
body: body,
tag: `sgsm-${alert.id}`,
// icon path is best-effort; nginx may 404 it, that's fine.
icon: "/shared/secubox-mind.png",
});
n.onclick = () => { window.focus(); n.close(); };
} catch (e) {
// Some Linux desktops without a notification daemon throw; swallow.
console.warn("Notification failed:", e);
}
}
// ── beep (Web Audio synthesised, no asset) ──────────────────────────
function playBeep() {
if (beepMuted) return;
try {
if (!_audioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
_audioCtx = new AC();
}
if (_audioCtx.state === "suspended") _audioCtx.resume();
const ctx = _audioCtx;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(880, ctx.currentTime);
// Short envelope: 0 → 0.18 → 0 over 200ms (avoids click)
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.20);
osc.connect(gain).connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.22);
} catch (e) {
console.warn("Beep failed:", e);
}
}
function refreshMuteUi() {
els.btnMute.setAttribute("aria-pressed", beepMuted ? "true" : "false");
els.muteLabel.textContent = beepMuted ? "Beep: off" : "Beep: on";
}
function toggleMute() {
beepMuted = !beepMuted;
localStorage.setItem(BEEP_MUTE_KEY, beepMuted ? "1" : "0");
refreshMuteUi();
}
// ── alerts table ────────────────────────────────────────────────────
function clearAlertsPlaceholder() {
const empty = els.alertsTbody.querySelector("tr.empty");
if (empty) empty.remove();
}
function buildAlertRow(alert) {
const tr = document.createElement("tr");
tr.dataset.alertId = alert.id;
const targetCell = alert.trusted_label
? `<span class="target-pill">${escapeHtml(alert.trusted_label)}</span>`
: '<span style="color:var(--text-dim)">—</span>';
tr.innerHTML =
`<td class="col-time">${escapeHtml(fmtTime(alert.ts))}</td>` +
`<td class="col-cell">${escapeHtml(alert.cell_id)}</td>` +
`<td class="col-arfcn">${escapeHtml(alert.arfcn)}</td>` +
`<td><span class="score-chip ${scoreClass(alert.score)}">${escapeHtml(alert.score)}</span></td>` +
`<td>${escapeHtml(alert.reason)}</td>` +
`<td>${targetCell}</td>`;
return tr;
}
function prependToAlertList(alert) {
clearAlertsPlaceholder();
const row = buildAlertRow(alert);
els.alertsTbody.insertBefore(row, els.alertsTbody.firstChild);
while (els.alertsTbody.children.length > MAX_ROWS) {
els.alertsTbody.removeChild(els.alertsTbody.lastChild);
}
alertCount += 1;
els.alertsCount.textContent = String(alertCount);
els.lastAlertTs.textContent = fmtDateTime(alert.ts);
}
async function loadAlertHistory() {
try {
const data = await apiFetch("/alerts?limit=100");
const alerts = (data && data.alerts) || [];
els.alertsTbody.innerHTML = "";
if (alerts.length === 0) {
els.alertsTbody.innerHTML =
'<tr class="empty"><td colspan="6">no alerts in history — waiting for stream…</td></tr>';
alertCount = 0;
els.alertsCount.textContent = "0";
els.lastAlertTs.textContent = "—";
return;
}
// newest first
alerts.sort((a, b) => (b.ts || 0) - (a.ts || 0));
alerts.forEach((a) => els.alertsTbody.appendChild(buildAlertRow(a)));
alertCount = alerts.length;
els.alertsCount.textContent = String(alertCount);
els.lastAlertTs.textContent = fmtDateTime(alerts[0].ts);
} catch (e) {
toast("Failed to load alert history: " + e.message, "err");
}
}
// ── SSE stream ──────────────────────────────────────────────────────
function setStreamStatus(state, label) {
els.streamStatus.textContent = label;
els.streamDot.className = "dot " + state;
}
function startAlertStream() {
setStreamStatus("dot-warn", "connecting…");
const es = new EventSource(API + "/alerts/stream");
es.addEventListener("open", () => {
setStreamStatus("dot-live", "live");
});
es.addEventListener("alert", (e) => {
let alert;
try { alert = JSON.parse(e.data); }
catch (err) { console.warn("bad SSE payload:", err); return; }
prependToAlertList(alert);
showDesktopAlert(alert);
playBeep();
});
es.onerror = () => {
// EventSource auto-reconnects; just surface the state.
setStreamStatus("dot-warn", "reconnecting…");
clearTimeout(_streamErrorTimer);
_streamErrorTimer = setTimeout(() => {
// If the readyState is OPEN by then, restore "live".
if (es.readyState === EventSource.OPEN) {
setStreamStatus("dot-live", "live");
} else if (es.readyState === EventSource.CLOSED) {
setStreamStatus("dot-down", "closed");
}
}, 2500);
};
return es;
}
// ── trusted phones ──────────────────────────────────────────────────
function buildTrustedRow(phone) {
const tr = document.createElement("tr");
tr.dataset.phoneId = phone.id;
tr.innerHTML =
`<td class="col-id" title="${escapeHtml(phone.id)}">${escapeHtml(shortId(phone.id))}</td>` +
`<td>${escapeHtml(phone.label || "")}</td>` +
`<td class="mono">${escapeHtml(fmtDateTime(phone.added_at))}</td>` +
`<td class="actions-col">` +
`<button class="btn danger row-action" type="button" data-action="delete">Delete</button>` +
`</td>`;
return tr;
}
async function loadTrusted() {
try {
const data = await apiFetch("/trusted");
const phones = (data && data.phones) || [];
els.trustedTbody.innerHTML = "";
if (phones.length === 0) {
els.trustedTbody.innerHTML =
'<tr class="empty"><td colspan="4">no trusted phones — click "+ Add phone" to register one</td></tr>';
} else {
phones.sort((a, b) => (b.added_at || 0) - (a.added_at || 0));
phones.forEach((p) => els.trustedTbody.appendChild(buildTrustedRow(p)));
}
els.trustedCount.textContent = String(phones.length);
} catch (e) {
toast("Failed to load trusted phones: " + e.message, "err");
els.trustedCount.textContent = "?";
}
}
async function addTrusted(imsi, label) {
return apiFetch("/trusted", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imsi: imsi, label: label }),
});
}
async function deleteTrusted(id) {
return apiFetch("/trusted/" + encodeURIComponent(id), { method: "DELETE" });
}
async function fireTestAlert() {
return apiFetch("/alerts/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
}
// ── modal ───────────────────────────────────────────────────────────
function openModal() {
els.modal.classList.add("show");
els.modal.setAttribute("aria-hidden", "false");
setTimeout(() => els.fieldImsi.focus(), 50);
}
function closeModal() {
els.modal.classList.remove("show");
els.modal.setAttribute("aria-hidden", "true");
els.modalForm.reset();
}
// ── event wiring ────────────────────────────────────────────────────
function wireEvents() {
els.btnRefresh.addEventListener("click", loadAlertHistory);
els.btnAdd.addEventListener("click", openModal);
els.btnCancelAdd.addEventListener("click", closeModal);
els.modal.addEventListener("click", (e) => {
if (e.target === els.modal) closeModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && els.modal.classList.contains("show")) closeModal();
});
els.modalForm.addEventListener("submit", async (e) => {
e.preventDefault();
const imsi = els.fieldImsi.value.trim();
const label = els.fieldLabel.value.trim();
if (!imsi || !label) return;
try {
await addTrusted(imsi, label);
toast("Trusted phone added", "ok");
closeModal();
await loadTrusted();
} catch (err) {
toast("Add failed: " + err.message, "err");
}
});
els.trustedTbody.addEventListener("click", async (e) => {
const btn = e.target.closest('button[data-action="delete"]');
if (!btn) return;
const tr = btn.closest("tr");
const id = tr && tr.dataset.phoneId;
if (!id) return;
if (!confirm("Delete trusted phone (id " + shortId(id) + ")?")) return;
try {
await deleteTrusted(id);
toast("Trusted phone removed", "ok");
await loadTrusted();
} catch (err) {
toast("Delete failed: " + err.message, "err");
}
});
els.btnTest.addEventListener("click", async () => {
// First click also doubles as the user gesture that unlocks AudioContext
// on browsers that require it.
if (_audioCtx && _audioCtx.state === "suspended") _audioCtx.resume();
try {
const r = await fireTestAlert();
toast("Test alert fired (id " + (r && r.id) + ")", "ok");
} catch (err) {
toast("Test failed: " + err.message, "err");
}
});
els.btnNotif.addEventListener("click", async () => {
const ok = await ensureNotificationPermission();
toast(ok ? "Notifications enabled" : "Notifications not granted",
ok ? "ok" : "warn");
});
els.btnMute.addEventListener("click", toggleMute);
}
// ── init ────────────────────────────────────────────────────────────
function init() {
refreshNotifStatus();
refreshMuteUi();
wireEvents();
loadAlertHistory();
loadTrusted();
startAlertStream();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();