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>
This commit is contained in:
CyberMind 2026-05-22 11:49:05 +02:00 committed by GitHub
parent b6923cd051
commit 95550edc4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 2952 additions and 6 deletions

File diff suppressed because it is too large Load Diff

View File

@ -27,15 +27,21 @@ from __future__ import annotations
import os
import sys
from dataclasses import asdict
from pathlib import Path
from typing import Optional
from fastapi import Body, FastAPI, HTTPException
from fastapi import Body, Depends, FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
sys.path.insert(0, "/usr/lib/secubox/sentinelle-gsm/lib")
from sentinelle_gsm import CAPTURES_PLAINTEXT_IMSI, Mode # noqa: E402
from sentinelle_gsm.alert_sink import Alert, AlertSink # noqa: E402
from sentinelle_gsm.livemon import detect_rtlsdr_usb # noqa: E402
from sentinelle_gsm.observer import Anonymizer # noqa: E402
from sentinelle_gsm.trusted import TrustedRegistry # noqa: E402
SECRETS_DIR = Path(os.environ.get("SECUBOX_SECRETS_DIR", "/etc/secubox/secrets"))
HMAC_KEY_FILE = SECRETS_DIR / "sentinelle-gsm-hmac"
@ -44,10 +50,66 @@ MODE_FILE = Path("/var/lib/secubox/sentinelle-gsm/mode") # PROD by default
app = FastAPI(
title="SecuBox SENTINELLE-GSM",
description="Passive GSM rogue-BTS sensor (MIND layer) — RX only, off-path",
version="0.1.0",
version="0.2.0",
)
# ── v0.2: alert sink + trusted registry singletons ──────────────────────────
#
# Auth note: this package is reverse-proxied through nginx + Authelia (see
# nginx/sentinelle-gsm.conf), which terminates JWT before forwarding to the
# Unix socket. `require_jwt` is therefore a no-op dependency here; it exists
# as a hook so tests (and future host-direct callers) can override it via
# `app.dependency_overrides[require_jwt]`.
def require_jwt() -> dict:
"""No-op auth hook. Real JWT enforcement happens at nginx/Authelia."""
return {"sub": "nginx-authelia"}
_alert_sink: Optional[AlertSink] = None
_trusted_registry: Optional[TrustedRegistry] = None
ALERTS_DB_PATH = Path("/var/lib/secubox/sentinelle-gsm/alerts.db")
TRUSTED_REGISTRY_PATH = Path("/etc/secubox/sentinelle-gsm/trusted.json")
def _get_anonymizer() -> Anonymizer:
"""Load the HMAC key from disk; fall back to an ephemeral key.
The ephemeral fallback exists so the API can boot on a freshly-installed
box where postinst hasn't yet generated the key. In that case the
trusted-registry hashes are stable for the lifetime of the process but
lost on restart the operator should re-add trusted phones once the
persistent HMAC key is in place.
"""
mode = _read_mode()
if _hmac_key_present():
return Anonymizer.from_file(HMAC_KEY_FILE, mode=mode)
return Anonymizer.ephemeral(mode=mode)
def get_alert_sink() -> AlertSink:
if _alert_sink is None:
raise RuntimeError("alert_sink not initialised")
return _alert_sink
def get_trusted_registry() -> TrustedRegistry:
if _trusted_registry is None:
raise RuntimeError("trusted_registry not initialised")
return _trusted_registry
@app.on_event("startup")
def _init_v0_2_singletons() -> None:
global _alert_sink, _trusted_registry
if _alert_sink is None:
_alert_sink = AlertSink(ALERTS_DB_PATH)
if _trusted_registry is None:
_trusted_registry = TrustedRegistry(TRUSTED_REGISTRY_PATH, _get_anonymizer())
def _read_mode() -> Mode:
"""Read the current PROD/LAB mode marker (PROD if file absent)."""
try:
@ -142,10 +204,83 @@ def cells() -> dict:
return {"cells": []} # v0.2 wires the GSMTAP feed
@app.get("/alerts")
def alerts() -> dict:
"""Active + historized alerts. Empty in v0.1.0."""
return {"alerts": []}
class TrustedAddBody(BaseModel):
imsi: str
label: str
@app.get("/alerts", dependencies=[Depends(require_jwt)])
async def list_alerts(limit: int = 100, since: float = 0.0) -> dict:
"""Paginated alert history from the SQLite-backed sink."""
return {
"alerts": [
asdict(a) for a in get_alert_sink().list(limit=limit, since=since)
]
}
@app.get("/alerts/stream", dependencies=[Depends(require_jwt)])
async def stream_alerts() -> StreamingResponse:
"""Server-Sent Events live feed of anomaly alerts.
Headers disable buffering at every layer (nginx via X-Accel-Buffering,
HTTP client caches via Cache-Control). The async generator runs until
the client disconnects; FastAPI handles cancellation.
"""
sink = get_alert_sink()
headers = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
}
return StreamingResponse(
sink.stream(), media_type="text/event-stream", headers=headers
)
@app.post("/alerts/test", dependencies=[Depends(require_jwt)])
async def test_alert(body: Optional[dict] = Body(default=None)) -> dict:
"""Manual operator trigger — writes a synthetic alert end-to-end.
A privacy-guard violation (plaintext-IMSI shape detected in any field)
surfaces as a 500 so the upstream UI / curl gets a clean error rather
than a stack trace. The error message is preserved.
"""
body = body or {}
a = Alert(
cell_id=body.get("cell_id", "208-01-100-99999"),
arfcn=body.get("arfcn", 124),
score=body.get("score", 80),
reason=body.get("reason", "operator-test"),
subscriber_hash=body.get("subscriber_hash"),
trusted_label=body.get("trusted_label"),
)
try:
written = get_alert_sink().write(a)
except ValueError as e:
raise HTTPException(500, str(e))
return {"ok": True, "id": written.id}
@app.get("/trusted", dependencies=[Depends(require_jwt)])
async def list_trusted() -> dict:
return {"phones": [asdict(p) for p in get_trusted_registry().list()]}
@app.post("/trusted", dependencies=[Depends(require_jwt)])
async def add_trusted(body: TrustedAddBody) -> dict:
try:
p = get_trusted_registry().add(body.imsi, body.label)
except ValueError as e:
raise HTTPException(400, str(e))
return asdict(p)
@app.delete("/trusted/{phone_id}", dependencies=[Depends(require_jwt)])
async def delete_trusted(phone_id: str) -> dict:
if not get_trusted_registry().delete(phone_id):
raise HTTPException(404, "not found")
return {"ok": True}
@app.post("/mode")

View File

@ -0,0 +1,9 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
"""Path bootstrap so `sentinelle_gsm.*` resolves to packages/.../lib/sentinelle_gsm."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "lib"))

View File

@ -0,0 +1,68 @@
# packages/secubox-sentinelle-gsm/api/tests/test_alert_sink.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
import asyncio
import json
import pytest
from sentinelle_gsm.alert_sink import Alert, AlertSink
@pytest.fixture
def sink(tmp_path):
return AlertSink(tmp_path / "alerts.db")
def test_write_and_list_roundtrip(sink):
a = Alert(cell_id="208-01-100-12345", arfcn=124, score=85, reason="cipher_downgrade")
written = sink.write(a)
assert written.id > 0
history = sink.list()
assert len(history) == 1
assert history[0].cell_id == "208-01-100-12345"
assert history[0].score == 85
def test_plaintext_imsi_in_reason_is_refused(sink):
"""Privacy invariant: never accept plaintext IMSI in any field."""
a = Alert(cell_id="208-01-100-12345", arfcn=124, score=85,
reason="caught IMSI 208201234567890") # 15 digits = IMSI
with pytest.raises(ValueError, match="plaintext-IMSI"):
sink.write(a)
def test_hmac_in_subscriber_hash_is_accepted(sink):
"""Hashed identifier (typical 32-hex chars) must pass the guard."""
a = Alert(cell_id="208-01-100-12345", arfcn=124, score=85,
reason="paging targets a trusted IMSI",
subscriber_hash="a1b2c3d4e5f6deadbeef00112233445566778899aabbccddeeff0011",
trusted_label="Gerald iPhone")
written = sink.write(a)
assert written.subscriber_hash.startswith("a1b2")
@pytest.mark.asyncio
async def test_subscribe_receives_new_alerts(sink):
q = sink.subscribe()
try:
sink.write(Alert(cell_id="208-01-100-99999", arfcn=12, score=72, reason="ghost_bts"))
got = await asyncio.wait_for(q.get(), timeout=1.0)
assert got.cell_id == "208-01-100-99999"
assert got.score == 72
finally:
sink.unsubscribe(q)
@pytest.mark.asyncio
async def test_stream_emits_sse_format(sink):
"""One alert written, one SSE chunk yielded."""
async def collect_one():
gen = sink.stream()
return await asyncio.wait_for(gen.__anext__(), timeout=1.0)
task = asyncio.create_task(collect_one())
await asyncio.sleep(0.05) # let subscribe() register
sink.write(Alert(cell_id="208-01-100-77777", arfcn=88, score=91, reason="identity_request_abuse"))
chunk = await task
assert chunk.startswith("event: alert\n")
data_line = next(l for l in chunk.split("\n") if l.startswith("data: "))
payload = json.loads(data_line[len("data: "):])
assert payload["cell_id"] == "208-01-100-77777"

View File

@ -0,0 +1,86 @@
# packages/secubox-sentinelle-gsm/api/tests/test_alerts_api.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
"""API surface tests for v0.2 /alerts endpoints.
The `client` fixture wires fresh AlertSink + TrustedRegistry instances
into the live `api.main` module so test isolation is per-test (tmp_path).
JWT enforcement is bypassed via FastAPI's dependency_overrides — the real
JWT layer lives at nginx + Authelia, not inside the app.
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path):
from sentinelle_gsm.observer import Anonymizer
from sentinelle_gsm.alert_sink import AlertSink
from sentinelle_gsm.trusted import TrustedRegistry
from api import main as api_main
api_main._alert_sink = AlertSink(tmp_path / "alerts.db")
api_main._trusted_registry = TrustedRegistry(
tmp_path / "trusted.json", Anonymizer(b"x" * 32)
)
api_main.app.dependency_overrides[api_main.require_jwt] = (
lambda: {"sub": "tester"}
)
try:
yield TestClient(api_main.app)
finally:
api_main.app.dependency_overrides.clear()
api_main._alert_sink = None
api_main._trusted_registry = None
def test_post_alerts_test_writes_one(client):
r = client.post("/alerts/test")
assert r.status_code == 200
assert r.json()["ok"] is True
def test_get_alerts_returns_history(client):
client.post("/alerts/test", json={"cell_id": "208-01-100-1"})
client.post("/alerts/test", json={"cell_id": "208-01-100-2"})
r = client.get("/alerts?limit=10")
assert r.status_code == 200
cells = [a["cell_id"] for a in r.json()["alerts"]]
assert "208-01-100-1" in cells and "208-01-100-2" in cells
def test_test_alert_refuses_plaintext_imsi(client):
"""AlertSink raises ValueError on 15-digit shape → FastAPI surfaces 500."""
r = client.post(
"/alerts/test",
json={"reason": "saw IMSI 208201234567890"},
)
assert r.status_code == 500
def test_stream_endpoint_advertises_sse(client):
"""Smoke test the SSE route is registered and advertises text/event-stream.
Asserting via `app.routes` (not an HTTP call) so we don't have to
interleave a TestClient write into the async generator awaiting on
`q.get()`. The actual SSE chunk format is covered by
`test_alert_sink.py::test_stream_emits_sse_format` against the
AlertSink directly.
"""
from api import main as api_main
route_paths = {r.path: r for r in api_main.app.routes if hasattr(r, "path")}
assert "/alerts/stream" in route_paths
assert "/alerts" in route_paths
assert "/alerts/test" in route_paths
# Verify the route is wired through the StreamingResponse-returning
# handler with text/event-stream by inspecting OpenAPI: TestClient.get
# against /openapi.json is cheap and doesn't open the SSE stream.
r = client.get("/openapi.json")
assert r.status_code == 200
paths = r.json()["paths"]
assert "/alerts/stream" in paths
assert "get" in paths["/alerts/stream"]

View File

@ -0,0 +1,60 @@
# packages/secubox-sentinelle-gsm/api/tests/test_trusted_api.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
"""API surface tests for v0.2 /trusted CRUD endpoints."""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path):
from sentinelle_gsm.observer import Anonymizer
from sentinelle_gsm.alert_sink import AlertSink
from sentinelle_gsm.trusted import TrustedRegistry
from api import main as api_main
api_main._alert_sink = AlertSink(tmp_path / "alerts.db")
api_main._trusted_registry = TrustedRegistry(
tmp_path / "trusted.json", Anonymizer(b"x" * 32)
)
api_main.app.dependency_overrides[api_main.require_jwt] = (
lambda: {"sub": "tester"}
)
try:
yield TestClient(api_main.app)
finally:
api_main.app.dependency_overrides.clear()
api_main._alert_sink = None
api_main._trusted_registry = None
def test_add_list_delete_roundtrip(client):
r = client.post(
"/trusted", json={"imsi": "208201234567890", "label": "iPhone"}
)
assert r.status_code == 200
pid = r.json()["id"]
assert r.json()["label"] == "iPhone"
assert r.json()["imsi_hash"] != "208201234567890"
r = client.get("/trusted")
assert r.status_code == 200
assert len(r.json()["phones"]) == 1
r = client.delete(f"/trusted/{pid}")
assert r.status_code == 200
r = client.get("/trusted")
assert r.json()["phones"] == []
def test_add_invalid_imsi_returns_400(client):
r = client.post("/trusted", json={"imsi": "abc", "label": "x"})
assert r.status_code == 400
def test_delete_unknown_returns_404(client):
r = client.delete("/trusted/not-an-id")
assert r.status_code == 404

View File

@ -0,0 +1,60 @@
# packages/secubox-sentinelle-gsm/api/tests/test_trusted_registry.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
import pytest
from sentinelle_gsm.observer import Anonymizer
from sentinelle_gsm.trusted import TrustedRegistry
@pytest.fixture
def anon():
return Anonymizer(b"test-secret-32-bytes" + b"\0" * 12)
@pytest.fixture
def reg(tmp_path, anon):
return TrustedRegistry(tmp_path / "trusted.json", anon)
def test_add_hashes_and_does_not_store_plaintext(reg, tmp_path):
p = reg.add("208201234567890", "Gerald iPhone")
assert p.label == "Gerald iPhone"
assert p.imsi_hash != "208201234567890"
raw = (tmp_path / "trusted.json").read_text()
assert "208201234567890" not in raw
assert p.imsi_hash in raw
def test_lookup_by_hash_finds_added_phone(reg):
p = reg.add("208201234567890", "iPhone")
found = reg.lookup_by_hash(p.imsi_hash)
assert found is not None
assert found.label == "iPhone"
def test_lookup_by_hash_returns_none_for_unknown(reg):
reg.add("208201234567890", "iPhone")
assert reg.lookup_by_hash("0" * 32) is None
def test_delete_removes_entry(reg):
p = reg.add("208201234567890", "iPhone")
assert reg.delete(p.id) is True
assert reg.list() == []
assert reg.delete("not-an-id") is False
def test_invalid_imsi_rejected(reg):
with pytest.raises(ValueError):
reg.add("not-digits", "foo")
with pytest.raises(ValueError):
reg.add("123", "too-short")
with pytest.raises(ValueError):
reg.add("9" * 16, "too-long")
def test_persistence_across_instances(reg, tmp_path, anon):
reg.add("208201234567890", "iPhone")
reg2 = TrustedRegistry(tmp_path / "trusted.json", anon)
assert len(reg2.list()) == 1
assert reg2.list()[0].label == "iPhone"

View File

@ -1,3 +1,39 @@
secubox-sentinelle-gsm (0.2.0-1~bookworm1) bookworm; urgency=medium
* v0.2.0 — Local alerts persistence + trusted-phones whitelist +
WebUI dashboard (Hub menu integration). Closes #340.
* lib/sentinelle_gsm/alert_sink.py: SQLite-backed Alert persistence
with asyncio pub/sub. Privacy guard rejects any write where a
textual field contains the plaintext-IMSI shape (15 contiguous
digits) — defence-in-depth against accidental leak via reason
strings.
* lib/sentinelle_gsm/trusted.py: HMAC-hashed trusted-phones registry
persisted to /etc/secubox/sentinelle-gsm/trusted.json. Plaintext
IMSI accepted by add() only; hashed via the existing Anonymizer
before storage. JSON (stdlib) is used instead of TOML — python3-
tomli-w (the writer for the new tomllib reader) is not in Debian
bookworm.
* api/main.py: new REST routes
- GET /api/v1/sensor/gsm/alerts history
- POST /api/v1/sensor/gsm/alerts/test synthetic injection
- GET /api/v1/sensor/gsm/alerts/stream SSE live feed
- GET/POST/DELETE /api/v1/sensor/gsm/trusted whitelist CRUD
* www/: standalone Sentinelle GSM WebUI (vanilla JS, SSE-driven
alerts feed, trusted-phones management). Served by nginx at
/sentinelle-gsm/.
* nginx + Hub menu.d entry shipped via the .install file so the
module appears in the SecuBox Hub dashboard.
* 29/29 tests pass: 5 alert_sink + 4 alerts_api + 3 trusted_api +
6 trusted_registry + 11 privacy_invariant. The privacy suite gains
three structural assertions:
- Alert dataclass has no field named imsi/imei/tmsi/msisdn/iccid
- TrustedPhone dataclass only stores imsi_hash (never plaintext)
- AlertSink.write() raises ValueError on any 15-digit token in
a textual field — non-regression guard against the OPAD-doctrine
privacy invariant.
-- Gerald KERMA <devel@cybermind.fr> Fri, 22 May 2026 12:00:00 +0200
secubox-sentinelle-gsm (0.1.0-1~bookworm1) bookworm; urgency=medium
* Initial scaffold per docs/superpowers/specs/2026-05-20-secubox-sentinelle-gsm.md.

View File

@ -18,6 +18,11 @@ override_dh_auto_install:
# nginx route
install -d debian/secubox-sentinelle-gsm/etc/nginx/secubox.d
[ -f nginx/sentinelle-gsm.conf ] && cp nginx/sentinelle-gsm.conf debian/secubox-sentinelle-gsm/etc/nginx/secubox.d/ && (install -d debian/secubox-sentinelle-gsm/etc/nginx/secubox-routes.d && cp nginx/sentinelle-gsm.conf debian/secubox-sentinelle-gsm/etc/nginx/secubox-routes.d/) || true
# nginx route for the standalone webui + SSE tuning (#340)
[ -f nginx/sentinelle-webui.conf ] && cp nginx/sentinelle-webui.conf debian/secubox-sentinelle-gsm/etc/nginx/secubox.d/ || true
# Standalone WebUI static files (#340)
install -d debian/secubox-sentinelle-gsm/usr/share/secubox/www/sentinelle
[ -d www/sentinelle ] && cp -r www/sentinelle/. debian/secubox-sentinelle-gsm/usr/share/secubox/www/sentinelle/ || true
# Config example
install -d debian/secubox-sentinelle-gsm/etc/secubox
[ -f conf/sentinelle-gsm.toml.example ] && cp conf/sentinelle-gsm.toml.example debian/secubox-sentinelle-gsm/etc/secubox/sentinelle-gsm.toml.example || true

View File

@ -0,0 +1,3 @@
www/sentinelle/ usr/share/secubox/www/
nginx/sentinelle-webui.conf etc/nginx/secubox.d/
menu.d/45-sentinelle.json usr/share/secubox/menu.d/

View File

@ -0,0 +1,116 @@
# packages/secubox-sentinelle-gsm/lib/sentinelle_gsm/alert_sink.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
"""
Alert sink: persist anomalies to SQLite and broadcast them to live
SSE subscribers.
Privacy invariant: the `subscriber_hash` field is the HMAC-truncated
IMSI/TMSI as produced by sentinelle_gsm.observer.Anonymizer NEVER
the plaintext identifier. The sink refuses to write if any field
matches the plaintext-IMSI shape (15 contiguous digits).
"""
from __future__ import annotations
import asyncio
import json
import re
import sqlite3
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import AsyncIterator, Optional
_PLAINTEXT_IMSI_RE = re.compile(r"\b\d{15}\b")
@dataclass
class Alert:
id: int = 0
ts: float = field(default_factory=time.time)
cell_id: str = "" # e.g. "208-01-100-12345"
arfcn: int = 0
score: int = 0 # 0..100
reason: str = "" # human-readable scoring reason
subscriber_hash: Optional[str] = None # HMAC-trunc, NEVER plaintext
trusted_label: Optional[str] = None # set by trusted-registry lookup
class AlertSink:
"""SQLite + asyncio pub/sub."""
def __init__(self, db_path: Path):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._db = sqlite3.connect(str(self.db_path), check_same_thread=False)
self._db.execute("""
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts REAL NOT NULL,
cell_id TEXT NOT NULL,
arfcn INTEGER NOT NULL,
score INTEGER NOT NULL,
reason TEXT NOT NULL,
subscriber_hash TEXT,
trusted_label TEXT
)
""")
self._db.execute("CREATE INDEX IF NOT EXISTS alerts_ts_idx ON alerts(ts)")
self._db.commit()
self._subscribers: list[asyncio.Queue[Alert]] = []
def write(self, alert: Alert) -> Alert:
# Privacy guard: reject anything that looks like plaintext IMSI
for value in (alert.cell_id, alert.reason,
alert.subscriber_hash or "",
alert.trusted_label or ""):
if _PLAINTEXT_IMSI_RE.search(value):
raise ValueError("alert_sink: plaintext-IMSI shape detected — refusing write")
cur = self._db.execute(
"INSERT INTO alerts(ts, cell_id, arfcn, score, reason, subscriber_hash, trusted_label) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(alert.ts, alert.cell_id, alert.arfcn, alert.score, alert.reason,
alert.subscriber_hash, alert.trusted_label),
)
self._db.commit()
alert.id = cur.lastrowid
# Fan-out to live subscribers (non-blocking; drop on full)
for q in list(self._subscribers):
try:
q.put_nowait(alert)
except asyncio.QueueFull:
pass
return alert
def list(self, limit: int = 100, since: float = 0.0) -> list[Alert]:
rows = self._db.execute(
"SELECT id, ts, cell_id, arfcn, score, reason, subscriber_hash, trusted_label "
"FROM alerts WHERE ts >= ? ORDER BY ts DESC LIMIT ?",
(since, limit),
).fetchall()
return [Alert(*r) for r in rows]
def subscribe(self) -> asyncio.Queue[Alert]:
q: asyncio.Queue[Alert] = asyncio.Queue(maxsize=64)
self._subscribers.append(q)
return q
def unsubscribe(self, q: asyncio.Queue[Alert]) -> None:
try:
self._subscribers.remove(q)
except ValueError:
pass
async def stream(self) -> AsyncIterator[str]:
"""Yield SSE-formatted text/event-stream chunks."""
q = self.subscribe()
try:
while True:
alert = await q.get()
yield "event: alert\ndata: " + json.dumps(asdict(alert)) + "\n\n"
finally:
self.unsubscribe(q)

View File

@ -0,0 +1,102 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
"""
Trusted phones registry HMAC-hashed IMSI mapping to operator-owned
device labels. Persisted to /etc/secubox/sentinelle-gsm/trusted.json.
Privacy: plaintext IMSI is accepted ONLY by `add()` and is hashed via
the existing Anonymizer before any storage call. No function ever
returns a plaintext IMSI. The on-disk JSON holds only hashes.
Storage rationale: JSON (stdlib) instead of TOML. `python3-tomli-w`
(the only writer for the new tomllib reader) is not in Debian bookworm
ships first 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.
"""
from __future__ import annotations
import json
import time
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Optional
from sentinelle_gsm.observer import Anonymizer
@dataclass
class TrustedPhone:
id: str
imsi_hash: str
label: str
added_at: float
class TrustedRegistry:
def __init__(self, path: Path, anonymizer: Anonymizer):
self.path = Path(path)
self._anon = anonymizer
self._phones: dict[str, TrustedPhone] = {}
self._load()
def _load(self) -> None:
if not self.path.exists():
return
raw = self.path.read_text()
if not raw.strip():
return
data = json.loads(raw)
for entry in data.get("phones", []):
p = TrustedPhone(
id=entry["id"],
imsi_hash=entry["imsi_hash"],
label=entry.get("label", ""),
added_at=entry.get("added_at", time.time()),
)
self._phones[p.id] = p
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"phones": [asdict(p) for p in self._phones.values()]}
self.path.write_text(json.dumps(payload, indent=2, sort_keys=True))
self.path.chmod(0o640)
def add(self, plaintext_imsi: str, label: str) -> TrustedPhone:
"""Hash the plaintext IMSI, persist the hash + label, discard plaintext."""
if not plaintext_imsi.isdigit() or not (14 <= len(plaintext_imsi) <= 15):
raise ValueError("IMSI must be 14 or 15 digits")
imsi_hash = self._anon.anonymize(plaintext_imsi)
phone = TrustedPhone(
id=str(uuid.uuid4()),
imsi_hash=imsi_hash,
label=label,
added_at=time.time(),
)
self._phones[phone.id] = phone
self._save()
# `plaintext_imsi` goes out of scope here
return phone
def list(self) -> list[TrustedPhone]:
return list(self._phones.values())
def get_by_id(self, phone_id: str) -> Optional[TrustedPhone]:
return self._phones.get(phone_id)
def lookup_by_hash(self, imsi_hash: str) -> Optional[TrustedPhone]:
"""Detector calls this when a paging request matches an IMSI hash."""
for p in self._phones.values():
if p.imsi_hash == imsi_hash:
return p
return None
def delete(self, phone_id: str) -> bool:
if phone_id not in self._phones:
return False
del self._phones[phone_id]
self._save()
return True

View File

@ -0,0 +1,9 @@
{
"id": "sentinelle",
"name": "SENTINELLE-GSM",
"path": "/sentinelle/",
"category": "mind",
"icon": "satellite",
"order": 45,
"description": "Passive IMSI-catcher / false-BTS sensor — live alerts"
}

View File

@ -0,0 +1,36 @@
# /etc/nginx/secubox.d/sentinelle-webui.conf
# Installed by secubox-sentinelle-gsm.
#
# Two location blocks:
# 1. /sentinelle/ → static WebUI (alias)
# 2. /api/v1/sensor/gsm/alerts/stream → SSE-tuned proxy override
#
# The deeper `^~` literal-prefix match on /api/v1/sensor/gsm/alerts/stream
# takes precedence over the shorter `location /api/v1/sensor/gsm/` from
# sentinelle-gsm.conf (and also wins against any later-added regex
# locations, thanks to `^~`). The common secubox-proxy snippet enforces
# proxy_read_timeout 30s by default — which would kill long-lived SSE
# connections — so we re-set the timeouts to 24h *after* the include.
# Static webui for the SENTINELLE-GSM module — admin.gk2.secubox.in/sentinelle/
location /sentinelle/ {
alias /usr/share/secubox/www/sentinelle/;
try_files $uri $uri/ /sentinelle/index.html;
add_header Cache-Control "no-cache, must-revalidate";
}
# SSE-tuned override for the alerts stream endpoint.
# The existing /api/v1/sensor/gsm/ proxy is provided by sentinelle-gsm.conf;
# this deeper, literal-prefix match wins and keeps the connection open.
location ^~ /api/v1/sensor/gsm/alerts/stream {
rewrite ^/api/v1/sensor/gsm/(.*)$ /$1 break;
proxy_pass http://unix:/run/secubox/sentinelle-gsm.sock;
include /etc/nginx/snippets/secubox-proxy.conf;
# SSE: long-lived connection, no buffering, no idle timeout.
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;
}

View File

@ -99,3 +99,47 @@ def test_anonymizer_rejects_short_keys(tmp_path):
p.write_bytes(b"x")
with pytest.raises(ValueError, match=r"32"):
Anonymizer.from_file(p)
# ---------------------------------------------------------------------------
# v0.2.0 — Alert + TrustedPhone + AlertSink privacy invariants
# ---------------------------------------------------------------------------
def test_alert_dataclass_has_no_plaintext_fields():
"""Alert may only carry the HMAC-truncated `subscriber_hash` — never a
plaintext subscriber identifier."""
from sentinelle_gsm.alert_sink import Alert
field_names = {f.name for f in fields(Alert)}
forbidden = {"imsi", "imei", "tmsi", "msisdn", "iccid", "subscriber_id"}
assert field_names.isdisjoint(forbidden), (
f"Alert has forbidden plaintext-identifier fields: "
f"{field_names & forbidden}"
)
assert "subscriber_hash" in field_names
def test_trusted_phone_dataclass_only_stores_hash():
"""TrustedPhone persists ONLY the HMAC hash, never the plaintext IMSI."""
from sentinelle_gsm.trusted import TrustedPhone
field_names = {f.name for f in fields(TrustedPhone)}
assert "imsi" not in field_names
assert "imsi_hash" in field_names
def test_alert_sink_refuses_plaintext_imsi(tmp_path):
"""AlertSink.write() must refuse any Alert whose textual fields contain
a 15-digit token (the plaintext-IMSI shape)."""
from sentinelle_gsm.alert_sink import Alert, AlertSink
sink = AlertSink(tmp_path / "alerts.db")
a = Alert(
cell_id="208-01-100-12345",
arfcn=124,
score=80,
reason="saw 208201234567890 in paging request",
)
with pytest.raises(ValueError, match="plaintext-IMSI"):
sink.write(a)

View File

@ -0,0 +1,186 @@
<!DOCTYPE html>
<!--
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 webui (MIND layer)
Standalone single-page UI. All API calls hit /api/v1/sensor/gsm/* via nginx.
-->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox — SENTINELLE-GSM</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="sentinelle.css">
</head>
<body>
<aside class="sidebar" aria-label="Sentinelle navigation">
<div class="sidebar-brand">
<div class="brand-tag">MIND</div>
<div class="brand-name">sentinelle-gsm</div>
<div class="brand-sub">passive rogue-BTS sensor</div>
</div>
<nav class="sidebar-nav">
<a href="#status" class="nav-link">Status</a>
<a href="#alerts" class="nav-link">Alerts</a>
<a href="#trusted" class="nav-link">Trusted phones</a>
<a href="#actions" class="nav-link">Actions</a>
</nav>
<div class="sidebar-foot">
<a href="/" class="back-hub">← hub</a>
</div>
</aside>
<main class="main">
<header class="page-header">
<h1><span aria-hidden="true">📡</span> SENTINELLE-GSM</h1>
<p class="subtitle">Passive GSM rogue-BTS sensor — RX only, off-path.</p>
</header>
<!-- STATUS strip ------------------------------------------------------- -->
<section id="status" class="status-strip" aria-label="Live status">
<div class="status-cell">
<div class="status-label">SSE stream</div>
<div class="status-value">
<span id="stream-dot" class="dot dot-idle" aria-hidden="true"></span>
<span id="stream-status">connecting…</span>
</div>
</div>
<div class="status-cell">
<div class="status-label">Trusted phones</div>
<div class="status-value mono"><span id="trusted-count"></span></div>
</div>
<div class="status-cell">
<div class="status-label">Last alert</div>
<div class="status-value mono"><span id="last-alert-ts"></span></div>
</div>
<div class="status-cell">
<div class="status-label">Notifications</div>
<div class="status-value">
<span id="notif-dot" class="dot dot-idle" aria-hidden="true"></span>
<span id="notif-status">unknown</span>
</div>
</div>
</section>
<!-- ALERTS panel ------------------------------------------------------- -->
<section id="alerts" class="panel">
<div class="panel-head">
<h2>Alerts <span class="badge" id="alerts-count">0</span></h2>
<div class="panel-actions">
<button class="btn" id="btn-refresh-alerts" type="button">Refresh history</button>
</div>
</div>
<div class="table-wrap" aria-live="polite" id="alerts-region">
<table class="data-table" id="alerts-table">
<thead>
<tr>
<th>Time</th>
<th>Cell ID</th>
<th>ARFCN</th>
<th>Score</th>
<th>Reason</th>
<th>Target</th>
</tr>
</thead>
<tbody id="alerts-tbody">
<tr class="empty"><td colspan="6">no alerts yet — waiting for stream…</td></tr>
</tbody>
</table>
</div>
</section>
<!-- TRUSTED panel ------------------------------------------------------ -->
<section id="trusted" class="panel">
<div class="panel-head">
<h2>Trusted phones</h2>
<div class="panel-actions">
<button class="btn primary" id="btn-add-trusted" type="button">+ Add phone</button>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Label</th>
<th>Added</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody id="trusted-tbody">
<tr class="empty"><td colspan="4">loading…</td></tr>
</tbody>
</table>
</div>
<p class="hint">
IMSI values are HMAC-hashed at registration. Plaintext IMSI is never stored.
</p>
</section>
<!-- ACTIONS panel ------------------------------------------------------ -->
<section id="actions" class="panel">
<div class="panel-head">
<h2>Actions</h2>
</div>
<div class="actions-grid">
<button class="btn primary" id="btn-test-alert" type="button">
Test alert
</button>
<button class="btn" id="btn-request-notif" type="button">
Request desktop notification permission
</button>
<button class="btn" id="btn-mute-beep" type="button" aria-pressed="false">
<span id="mute-label">Beep: on</span>
</button>
</div>
<p class="hint">
Test alert writes a synthetic event through the full pipeline (sink → SSE → UI).
Desktop notifications require an HTTPS context and a one-time user gesture.
</p>
</section>
</main>
<!-- Modal: add trusted phone ------------------------------------------- -->
<div class="modal" id="modal-add" role="dialog" aria-modal="true" aria-labelledby="modal-add-title" aria-hidden="true">
<div class="modal-content">
<h3 id="modal-add-title">Add trusted phone</h3>
<p class="modal-hint">
Enter the IMSI in clear; it's hashed (HMAC-SHA256) before storage.
The label is displayed alongside alerts whenever the IMSI is observed.
</p>
<form id="form-add-trusted" autocomplete="off">
<label class="field">
<span class="field-label">IMSI</span>
<input type="text" id="field-imsi" name="imsi" required
inputmode="numeric" pattern="\d{14,15}"
placeholder="208011000000123" autocomplete="off">
<small class="field-help">1415 digits, no spaces or dashes.</small>
</label>
<label class="field">
<span class="field-label">Label</span>
<input type="text" id="field-label" name="label" required
maxlength="64" placeholder="Owner's iPhone">
</label>
<div class="modal-actions">
<button type="button" class="btn" id="btn-cancel-add">Cancel</button>
<button type="submit" class="btn primary">Add</button>
</div>
</form>
</div>
</div>
<!-- Toast banner ------------------------------------------------------- -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script defer src="sentinelle.js"></script>
</body>
</html>

View File

@ -0,0 +1,500 @@
/*
* 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 stylesheet.
* Palette: MIND (violet) per Charte SecuBox §Six Module Color System.
*/
:root {
/* MIND palette — analyse / detection layer */
--mind-violet: #3D35A0;
--mind-violet-lt: #6E63D6;
--mind-violet-xlt: #ECEAFA;
--mind-glow: rgba(110, 99, 214, 0.35);
/* Background / surfaces (cosmos-black charter) */
--cosmos-black: #0a0a0f;
--bg: #0f0f17;
--surface: #16161f;
--surface-2: #1d1d28;
--surface-3: #252533;
--border: #2c2c3a;
--border-2: #3a3a4d;
/* Text */
--text-primary: #e8e6d9;
--text-muted: #6b6b7a;
--text-dim: #4a4a5a;
/* Semantic */
--ok: #00ff41; /* matrix-green */
--warn: #c9a84c; /* gold-hermetic */
--crit: #e63946; /* cinnabar */
--info: #00d4ff; /* cyber-cyan */
/* Reserve room for the .global-menu-bar injected by Task 5 menu.d entry
(position:fixed, top:0, height:48px, z-index:998). */
--gmb-h: 48px;
--sidebar-w: 220px;
}
/* ── reset ────────────────────────────────────────────────────────────── */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif;
background: var(--bg);
background-image:
radial-gradient(ellipse at 30% 10%, rgba(61, 53, 160, 0.18) 0%, transparent 55%),
radial-gradient(ellipse at 80% 90%, rgba(110, 99, 214, 0.10) 0%, transparent 50%);
color: var(--text-primary);
display: flex;
min-height: 100vh;
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
button, input, select { font-family: inherit; }
a { color: var(--mind-violet-lt); text-decoration: none; }
a:hover { color: var(--text-primary); }
/* ── sidebar ──────────────────────────────────────────────────────────── */
.sidebar {
position: fixed;
top: 0; left: 0;
width: var(--sidebar-w);
height: 100vh;
padding: calc(var(--gmb-h) + 1.25rem) 1.1rem 1rem;
background: linear-gradient(180deg, var(--surface) 0%, var(--cosmos-black) 100%);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
z-index: 100;
}
.sidebar-brand .brand-tag {
display: inline-block;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.18em;
padding: 2px 8px;
border-radius: 3px;
background: var(--mind-violet);
color: #fff;
margin-bottom: 0.5rem;
}
.sidebar-brand .brand-name {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.sidebar-brand .brand-sub {
font-size: 11px;
color: var(--text-muted);
margin-top: 0.15rem;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin-top: 0.75rem;
}
.nav-link {
display: block;
padding: 0.55rem 0.75rem;
border-radius: 6px;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
border-left: 2px solid transparent;
transition: all 0.15s ease;
}
.nav-link:hover {
background: var(--surface-2);
color: var(--text-primary);
border-left-color: var(--mind-violet-lt);
}
.sidebar-foot {
margin-top: auto;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.back-hub {
display: inline-block;
font-size: 12px;
color: var(--text-muted);
}
.back-hub:hover { color: var(--mind-violet-lt); }
/* ── main ─────────────────────────────────────────────────────────────── */
.main {
flex: 1;
margin-left: var(--sidebar-w);
padding: calc(var(--gmb-h) + 1.5rem) 1.75rem 2rem;
min-width: 0;
}
.page-header { margin-bottom: 1.75rem; }
.page-header h1 {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
}
.page-header h1 > span { color: var(--mind-violet-lt); }
.page-header .subtitle {
margin-top: 0.25rem;
color: var(--text-muted);
font-size: 13px;
}
/* ── status strip ─────────────────────────────────────────────────────── */
.status-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.status-cell {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.75rem 1rem;
}
.status-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.status-value {
font-size: 14px;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-value.mono,
.mono { font-family: 'JetBrains Mono', monospace; }
.dot {
display: inline-block;
width: 9px; height: 9px;
border-radius: 50%;
background: var(--text-dim);
box-shadow: 0 0 0 2px rgba(255,255,255,0.04);
}
.dot.dot-live { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.dot.dot-warn { background: var(--warn); box-shadow: 0 0 6px var(--warn); }
.dot.dot-down { background: var(--crit); box-shadow: 0 0 6px var(--crit); }
.dot.dot-idle { background: var(--text-dim); }
/* ── panels ───────────────────────────────────────────────────────────── */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 1.5rem;
overflow: hidden;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
background: linear-gradient(90deg, var(--surface-2) 0%, var(--surface) 100%);
}
.panel-head h2 {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--mind-violet-lt);
display: flex;
align-items: center;
gap: 0.6rem;
}
.panel-actions { display: flex; gap: 0.5rem; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-3);
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
}
/* ── tables ───────────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table th,
.data-table td {
padding: 0.65rem 1.25rem;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.data-table th {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-muted);
background: var(--surface-2);
}
.data-table tbody tr:hover { background: var(--surface-2); }
.data-table td.mono,
.data-table td.col-time,
.data-table td.col-cell,
.data-table td.col-arfcn,
.data-table td.col-id { font-family: 'JetBrains Mono', monospace; font-size: 12.5px; }
.data-table tbody tr.empty td {
color: var(--text-muted);
font-style: italic;
text-align: center;
padding: 1.5rem 1rem;
}
.actions-col { width: 1%; white-space: nowrap; text-align: right; }
/* score chip: 039 ok · 4069 warn · 70+ crit */
.score-chip {
display: inline-block;
min-width: 36px;
padding: 2px 8px;
border-radius: 999px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 700;
text-align: center;
border: 1px solid transparent;
}
.score-chip.low { background: rgba(0, 255, 65, 0.10); color: var(--ok); border-color: rgba(0, 255, 65, 0.30); }
.score-chip.med { background: rgba(201, 168, 76, 0.12); color: var(--warn); border-color: rgba(201, 168, 76, 0.35); }
.score-chip.high { background: rgba(230, 57, 70, 0.14); color: var(--crit); border-color: rgba(230, 57, 70, 0.45); }
.target-pill {
display: inline-block;
padding: 1px 7px;
border-radius: 999px;
background: rgba(110, 99, 214, 0.15);
color: var(--mind-violet-lt);
border: 1px solid rgba(110, 99, 214, 0.35);
font-size: 11px;
font-weight: 600;
}
/* ── buttons ──────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.95rem;
border-radius: 6px;
border: 1px solid var(--border-2);
background: var(--surface-2);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:hover {
border-color: var(--mind-violet-lt);
color: var(--mind-violet-lt);
background: var(--surface-3);
}
.btn:focus-visible {
outline: 2px solid var(--mind-violet-lt);
outline-offset: 2px;
}
.btn.primary {
background: var(--mind-violet);
border-color: var(--mind-violet);
color: #fff;
}
.btn.primary:hover {
background: var(--mind-violet-lt);
border-color: var(--mind-violet-lt);
color: #fff;
box-shadow: 0 0 14px var(--mind-glow);
}
.btn.danger {
border-color: rgba(230, 57, 70, 0.45);
color: var(--crit);
}
.btn.danger:hover {
background: rgba(230, 57, 70, 0.10);
border-color: var(--crit);
color: var(--crit);
}
.btn.row-action {
padding: 0.25rem 0.55rem;
font-size: 11px;
}
.actions-grid {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding: 1.25rem;
}
.hint {
padding: 0 1.25rem 1.25rem;
color: var(--text-muted);
font-size: 12px;
}
/* ── modal ────────────────────────────────────────────────────────────── */
.modal {
display: none;
position: fixed;
inset: 0;
background: rgba(5, 5, 10, 0.78);
backdrop-filter: blur(2px);
/* must be > .global-menu-bar (z-index 998) */
z-index: 1500;
align-items: center;
justify-content: center;
}
.modal.show { display: flex; }
.modal-content {
background: var(--surface);
border: 1px solid var(--mind-violet);
border-radius: 10px;
padding: 1.5rem;
width: 100%;
max-width: 460px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 0 40px var(--mind-glow);
}
.modal-content h3 {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--mind-violet-lt);
margin-bottom: 0.75rem;
}
.modal-hint {
color: var(--text-muted);
font-size: 12.5px;
margin-bottom: 1rem;
}
.field { display: block; margin-bottom: 1rem; }
.field-label {
display: block;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.35rem;
}
.field input[type="text"] {
width: 100%;
padding: 0.55rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border-2);
background: var(--bg);
color: var(--text-primary);
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
}
.field input[type="text"]:focus {
outline: none;
border-color: var(--mind-violet-lt);
box-shadow: 0 0 0 3px var(--mind-glow);
}
.field-help {
display: block;
margin-top: 0.3rem;
color: var(--text-muted);
font-size: 11px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.25rem;
}
/* ── toast ────────────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 1.25rem;
left: 50%;
transform: translateX(-50%) translateY(10px);
background: var(--surface);
border: 1px solid var(--border-2);
color: var(--text-primary);
padding: 0.7rem 1.1rem;
border-radius: 8px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 2000;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.toast.ok { border-color: var(--ok); color: var(--ok); }
.toast.warn { border-color: var(--warn); color: var(--warn); }
.toast.err { border-color: var(--crit); color: var(--crit); }
/* ── responsive ───────────────────────────────────────────────────────── */
@media (max-width: 900px) {
body { flex-direction: column; }
.sidebar {
position: static;
width: 100%;
height: auto;
padding: calc(var(--gmb-h) + 0.75rem) 1rem 0.75rem;
}
.main { margin-left: 0; padding: 1rem; }
.sidebar-nav { flex-direction: row; flex-wrap: wrap; }
.nav-link { padding: 0.4rem 0.7rem; border-left: 0; border-bottom: 2px solid transparent; }
.nav-link:hover { border-left: 0; border-bottom-color: var(--mind-violet-lt); }
.status-strip { grid-template-columns: 1fr 1fr; }
.sidebar-foot { display: none; }
}
@media (max-width: 520px) {
.status-strip { grid-template-columns: 1fr; }
.modal-content { max-width: 95%; }
}

View File

@ -0,0 +1,446 @@
// 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();
}
})();