mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
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:
parent
b6923cd051
commit
95550edc4e
File diff suppressed because it is too large
Load Diff
|
|
@ -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")
|
||||
|
|
|
|||
9
packages/secubox-sentinelle-gsm/api/tests/conftest.py
Normal file
9
packages/secubox-sentinelle-gsm/api/tests/conftest.py
Normal 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"))
|
||||
68
packages/secubox-sentinelle-gsm/api/tests/test_alert_sink.py
Normal file
68
packages/secubox-sentinelle-gsm/api/tests/test_alert_sink.py
Normal 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"
|
||||
86
packages/secubox-sentinelle-gsm/api/tests/test_alerts_api.py
Normal file
86
packages/secubox-sentinelle-gsm/api/tests/test_alerts_api.py
Normal 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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
116
packages/secubox-sentinelle-gsm/lib/sentinelle_gsm/alert_sink.py
Normal file
116
packages/secubox-sentinelle-gsm/lib/sentinelle_gsm/alert_sink.py
Normal 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)
|
||||
102
packages/secubox-sentinelle-gsm/lib/sentinelle_gsm/trusted.py
Normal file
102
packages/secubox-sentinelle-gsm/lib/sentinelle_gsm/trusted.py
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
36
packages/secubox-sentinelle-gsm/nginx/sentinelle-webui.conf
Normal file
36
packages/secubox-sentinelle-gsm/nginx/sentinelle-webui.conf
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
186
packages/secubox-sentinelle-gsm/www/sentinelle/index.html
Normal file
186
packages/secubox-sentinelle-gsm/www/sentinelle/index.html
Normal 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">14–15 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>
|
||||
500
packages/secubox-sentinelle-gsm/www/sentinelle/sentinelle.css
Normal file
500
packages/secubox-sentinelle-gsm/www/sentinelle/sentinelle.css
Normal 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: 0–39 ok · 40–69 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%; }
|
||||
}
|
||||
446
packages/secubox-sentinelle-gsm/www/sentinelle/sentinelle.js
Normal file
446
packages/secubox-sentinelle-gsm/www/sentinelle/sentinelle.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
Loading…
Reference in New Issue
Block a user