* docs(plan): #349 sentinelle-gsm v0.3.1 L3 + scoring + baseline (ref #349)
* feat(sentinelle-gsm): GsmtapListener exposes raw_l3 payload bytes (ref #349)
v0.3.0's Observation carried only header metadata (arfcn, frame_nr,
channel, sub_type), which was enough to prove the GSMTAP pipe but not
to drive L3-aware scoring. v0.3.1 needs the post-header payload to
decode BCCH System Information (LAC/CI/MCC/MNC) and CCCH paging
requests (IMSI/TMSI for the privacy-hashed subscriber digest).
Slice the L3 payload from the datagram via data[hdr["hdr_len"]:] in
_on_datagram() and surface it as a new Observation field
`raw_l3: bytes = b""`. Default empty bytes keeps every existing
caller backward compatible — only downstream decoders that opt in
will consume it (wired in Task 5).
Test: existing test_listener_receives_a_datagram now appends a known
18-byte payload and asserts obs.raw_l3 matches verbatim.
* feat(sentinelle-gsm): l3_decode — BCCH SI + CCCH paging request parsing (ref #349)
Pure-Python TLV decoder for the minimal L3 subset needed by the
IMSI-catcher scoring engine: BCCH System Information Types 3/4/6 and
CCCH Paging Request Types 1/2/3. No scapy dependency — the parser
operates directly on the post-GSMTAP-header bytes exposed by
Observation.raw_l3 (Task 1).
Privacy invariant : the public dataclasses (CellInfo, PagedIdentity,
ParsedPagingRequest, ParsedFrame) NEVER carry plaintext IMSI/TMSI/IMEI.
The internal _try_extract_mobile_id() returns plaintext bytes;
_parse_paging() immediately hands them to Anonymizer.anonymize() and
only the HMAC-truncated subscriber_hash escapes into PagedIdentity.
The plaintext goes out of scope at function return.
Permissive parsing : every helper returns an empty CellInfo() / None on
unexpected layouts rather than raising — real GSMTAP frames have
surprising structural variants and exceptions in the consume loop would
crash livemon ingestion.
Tests : 9 cases (6 from the plan + 3 added for coverage). BCD
encoder/decoder round-trip helpers build synthetic frames
programmatically rather than hand-crafting bytes, which also validates
the TS 24.008 §10.5.1.3/4 nibble ordering.
* feat(sentinelle-gsm): baseline.py — operator-baseline learning + cell_baseline table (ref #349)
Adds CellBaseline wrapper + cell_baseline table colocated in observations.db.
Cells graduate to baseline once observed >= LEARN_THRESHOLD (default 3) times.
Key design choices:
* LEARN_THRESHOLD = 3 by default — three sightings before a cell is
considered part of the operator's legitimate baseline.
* set_learn_mode(seconds) skips the count entirely — every cell observed
inside that explicit-learn window is INSERTed with learn_count = 3 and
graduates on first sighting (used by the operator's calibration sweep).
* CellBaseline accepts a raw sqlite3.Connection (NOT an ObservationsDB
instance) so it stays decoupled from observations.py's public surface
while still sharing the same .db file.
* cell_baseline table is created idempotently (IF NOT EXISTS) in
ObservationsDB.__init__, so pre-existing observations.db files keep
working after upgrade.
Privacy invariant: BaselineCell + cell_baseline have NO subscriber-id
columns — paged identities never enter the baseline path.
Tests (5/5 passing):
- test_first_consider_starts_at_1_not_baseline_yet
- test_three_considers_graduate_to_baseline
- test_learn_mode_graduates_first_consider
- test_consider_updates_metadata_via_coalesce
- test_list_orders_by_last_learned_desc
Full pkg sweep: 62 passing (57 prior + 5 new).
* feat(sentinelle-gsm): scoring_engine — 8 heuristics + thresholds (ref #349)
Replaces the v0.1 shape-only scoring.py stub with the full v0.3.1 detector
brain. ScoringEngine wires against CellBaseline + L3Decode (Tasks 2-3)
and runs every parsed frame through 8 heuristics, summing the triggered
contributions into a CellScore clamped to [0, 100].
Heuristics + default scores (sum-clamped to 100):
1. cipher_downgrade 40 A5/X observed < baseline.cipher_a5
2. ghost_bts 35 cell unknown to baseline + paging seen
3. identity_mismatch 30 observed (mcc, mnc) != baseline pair
4. relocalization_storm 25 > 3 distinct LACs in 60s on cell
5. identity_request_abuse 35 > 2 paging reqs in 300s per hash
6. anomalous_neighbours 15 neighbour list went non-empty -> empty
7. t3212_out_of_band 15 T3212 outside [1, 60] min
8. orphan_arfcn 20 FR carrier announces ARFCN outside plan
Rolling-window state (in-memory only, no DB persistence in v0.3.1):
_lac_window cell_id -> deque[(ts, lac)]
_idreq_window subscriber_hash -> deque[ts]
_neighbour_baseline cell_id -> set[int] (cumulative ARFCNs)
Thresholds:
* DEFAULT_THRESHOLDS class constant carries per-heuristic config dicts.
* update_thresholds() does a per-heuristic deep-merge so callers can
pass partial updates like {"cipher_downgrade": {"score": 55}} without
clobbering "enabled" or any other unspecified field.
* thresholds() returns a deep-copy so callers can't mutate engine state.
Privacy invariant kept: identity_request_abuse keys windows on
subscriber_hash strings (HMAC-trunc, supplied by l3_decode.Anonymizer),
never plaintext IMSI/TMSI. Reasons truncate the hash to 8 chars defensively.
France GSM-900 ARFCN plan is approximate and permissive: unknown
(mcc, mnc) pairs return triggered=False to avoid false-positives.
Dropped lib/sentinelle_gsm/scoring.py — the v0.1.0 shape-only stub
(empty Baseline + score()=0) is replaced. No other module imported it.
Tests:
* api/tests/test_scoring_engine.py — 11 tests, one per heuristic
(plus permissive-unknown-carrier guard), one for evaluate()
aggregation/clamping, one for update_thresholds() deep-merge.
* Full sweep: 73 passed (was 62; +11 new).
* feat(sentinelle-gsm): consume loop wires L3 + scoring + trusted match → Alert (ref #349)
- Extracted `_process_observation(obs)` helper for testability — the consume
coroutine now delegates per-frame work so tests can drive synthesized
Observations through the full pipeline without binding UDP.
- New endpoints: GET /baseline, POST /baseline/learn (sweep window),
GET /scoring/thresholds, POST /scoring/thresholds (deep-merge).
- Audit log entry on POST /scoring/thresholds via the existing
`secubox.sentinelle-gsm.api` logger — surfaces in /journal/stream.
- Alert emission decision tree:
score < threshold → drop (no write)
score >= threshold + match → one labeled alert per matched trusted phone
score >= threshold + no match → single anomaly-only alert
- ALERT_THRESHOLD configurable via SENTINELLE_ALERT_THRESHOLD env (default 60).
- TrustedRegistry now hashes the IMSI as `imsi.encode("ascii").hex()` so
the persisted token matches the canonical form produced by the L3
decoder's paging-request path. Existing trusted tests don't pin the
hash value, so behaviour is unchanged for read-back / lookup-by-id.
- 5 new integration tests at api/tests/test_alert_emission.py cover the
below-threshold drop, anomaly-only emission, trusted-match labeling,
paging-event hash persistence, and audit-log emission on threshold
update. Full sweep 78/78 passing.
* feat(sentinelle-gsm): webui baseline + scoring panels + trusted_label chip (ref #349)
Adds two new webui panels and a trusted-label chip on the alerts row:
- #baseline panel: Cell ID / MCC / MNC / LAC / ARFCN / cipher / learn count
/ last learned table backed by GET /api/v1/sensor/gsm/baseline. "Start
learn (5 min)" button arms POST /baseline/learn?sweep_seconds=300 and
re-polls the table every 15s for the 5-minute window so cells appear as
they graduate.
- #scoring panel: per-heuristic enable + score input (0..100, clamped
client-side) backed by GET/POST /scoring/thresholds. Form values are
cached in _thresholdState so any backend-only fields (heuristic-specific
knobs) survive the POST round trip.
- Alerts table: trusted_label is now rendered as a violet pill via the
new .trusted-chip class (replaces the older .target-pill markup for
the same data path).
Local navigation gets two new anchors (Baseline, Scoring) between
Observations and Actions. The 10s scan poll now also refreshes baseline
while a scan is running.
Wires 4 new event listeners (refresh-baseline, baseline-learn,
refresh-scoring, save-scoring). No backend changes; all endpoints already
exist (Tasks 1..5).
Tests: 78/78 passing.
* chore(sentinelle-gsm): bump 0.3.1 + extend privacy invariant tests (closes#349)
Append 4 new structural privacy tests under tests/test_privacy_invariant.py
to lock the v0.3.1 invariants in place:
* test_l3_decode_returns_no_plaintext_fields — CellInfo, PagedIdentity,
and ParsedPagingRequest dataclasses must not carry any plaintext
subscriber-id field (imsi/tmsi/imei/msisdn/iccid/subscriber_id).
* test_paging_request_hashes_paged_identities — feeds a synthetic
Paging Request Type 1 with a known TMSI through L3Decode and asserts
the plaintext TMSI bytes (as hex) NEVER appear in the resulting
PagedIdentity.subscriber_hash.
* test_cell_baseline_has_no_subscriber_fields — the BaselineCell
dataclass must remain operator-side metadata only: no subscriber_hash,
no plaintext identifier.
* test_scoring_engine_reasons_contain_no_plaintext_imsi — the free-text
reason strings produced by ScoringEngine.evaluate() must never echo
a 15-digit token (plaintext-IMSI shape) for typical observations.
These four tests pass against the v0.3.1 modules (Tasks 1-6) with no
code change — the modules were designed for these invariants from the
start, this commit just locks them in regression-test form.
Full sweep: 78 -> 82 passing.
Changelog: 0.3.0 -> 0.3.1 with the v0.3.1 entry summarising the new
l3_decode, baseline, scoring_engine modules, the consume-loop wiring
that emits qualified Alerts with trusted_label, the webui panels, and
the updated test breakdown (l3_decode x9, baseline x5, scoring_engine
x11, alert_emission x5, privacy x4 = 34 new, full sweep 82/82).
---------
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
* docs(plan): #347 sentinelle-gsm v0.3.0 observation pipeline (ref #344#347)
* feat(sentinelle-gsm): livemon_runner — managed grgsm subprocess (closes#344 ref #347)
Introduces LivemonRunner, the asyncio subprocess manager that owns the
single grgsm_livemon_headless child for v0.3.0.
Device-claim invariant (the load-bearing point that closes#344): the
parent service NEVER opens the RTL-SDR itself. The RTL-SDR is claimed
ONLY by the spawned grgsm_livemon_headless child, and ONLY for the
lifetime of an active scan. start() spawns with `--args=rtl=0 -f <freq>`
so gr-osmosdr binds RTL-SDR instead of its UHD default; stop() sends
SIGTERM and falls back to SIGKILL after a 5 s timeout, releasing the
device cleanly.
Concretely this means ad-hoc tooling (rtl_test, rtl_adsb, kalibrate)
keeps working alongside an idle secubox-sentinelle-gsm service — the
race the v0.1 livemon.py USB-detect stub created is gone, because the
parent has no reason to touch the dongle outside an explicit scan.
Stderr from the child is drained into a 16 KiB-capped ring buffer so
status() can surface the last 2 KiB without unbounded memory growth,
and double-start() raises RuntimeError instead of silently leaking
processes.
Tests cover: initial-state, RTL args propagation to create_subprocess_exec,
double-start refusal, SIGTERM-on-stop + state clear, and stop-as-noop
when nothing is running. asyncio.create_subprocess_exec is fully mocked
— no real grgsm process is spawned by the test suite.
Test sweep: 5 new + 29 existing = 34 passing.
* feat(sentinelle-gsm): gsmtap_listener — async UDP 4729 + GSMTAP v2 parse (ref #347)
Task 2 of v0.3.0 observation pipeline.
- `lib/sentinelle_gsm/gsmtap_listener.py`: asyncio DatagramProtocol bound
to 127.0.0.1:4729 (configurable). Tiny manual 16-byte GSMTAP v2 header
parser — no scapy runtime dep on the hot path. scapy stays reserved
for the deeper L3 decode (BCCH/CCCH paging) landing in v0.3.1.
- `Observation` dataclass exposes ts/arfcn/frame_nr/channel/sub_type plus
optional lac/ci/mcc/mnc/cell_id/subscriber_hash. No plaintext-id field;
`subscriber_hash` is HMAC-only (populated when L3 decode lands).
- Bounded queue (maxsize=2048); on QueueFull we drop silently —
observations are stochastic, backpressure to the radio would be worse.
- 4 tests: minimum-valid parse, too-short reject, wrong-version reject,
end-to-end UDP roundtrip on port 47291 (avoids colliding with a
running grgsm on 4729).
All tests pass: 4/4 new, 38/38 sweep.
* feat(sentinelle-gsm): observations.py — SQLite sightings + paging_events (ref #347)
Adds ObservationsDB persistence layer for the v0.3 observation pipeline.
* Sighting (cell_id PK, mcc/mnc/lac/ci/arfcn, first_seen/last_seen,
sighting_count) — UPSERT semantics: INSERT on new cell_id, else bump
sighting_count + refresh last_seen with COALESCE on optional fields so
partial updates don't clobber previously-observed values.
* PagingEvent (ts, cell_id, subscriber_hash, request_type) — seeds the
paging history table that v0.3.1 will hash-match against trusted_phones.
Schema deliberately omits plaintext IMSI/TMSI/IMEI fields.
* _guard_plaintext() mirrors the regex guard in alert_sink.AlertSink.write
(same \b\d{15}\b pattern, same "plaintext-IMSI shape detected — refusing
write" error contract). Applied on every write path: upsert_sighting
guards cell_id; record_paging guards both cell_id and subscriber_hash.
* Two indexes on paging_events (ts, cell_id) for the v0.3.1 lookup path.
* 4 tests cover the contract: new-insert, bump-count, hash accept, and
the plaintext-IMSI refusal. Full sweep: 42 passing (38 prior + 4 new).
* feat(sentinelle-gsm): API — /scan/start /scan/stop /scan/status /observations (ref #347)
Task 4 of the v0.3.0 observation-pipeline plan. Wires the LivemonRunner +
GsmtapListener + ObservationsDB introduced in Tasks 1–3 to four new HTTP
endpoints behind the existing nginx + Authelia JWT gate.
- POST /scan/start {freq} → bind GSMTAP UDP listener, spawn
grgsm_livemon_headless on freq, start a
background consume task that drains
Observations into the SQLite store
- POST /scan/stop → cancel the consume task FIRST so no
orphan subprocess is left behind, then
SIGTERM the runner, then close the
listener socket (the CHILD owns the
RTL-SDR, never the parent)
- GET /scan/status → current runner state (running / pid /
freq / started_at / stderr_tail)
- GET /observations?limit → recent cell sightings, newest first;
limit clamped to [1, 1000], default 200
Synthetic cell_id formation until v0.3.1's L3 BCCH decode lands:
`f"arfcn-{obs.arfcn}-ch-{obs.channel}"`. The ObservationsDB schema
already accepts NULL for the operator quadruple (mcc/mnc/lac/ci) so
nothing downstream has to change when the real cell_id arrives.
Three new tests in api/tests/test_scan_api.py exercise the
happy path with mocked LivemonRunner + mocked GsmtapListener (so no
gr-gsm binary is spawned and no UDP socket is bound) plus a real
ObservationsDB under tmp_path so GET /observations exercises the
real SQLite path. Fixture clears `_consume_task` between tests to
keep them order-independent.
Test sweep: 42 (existing) + 3 (this) = 45 passing.
* feat(sentinelle-gsm): webui scan control + observations panels (ref #347)
Adds two new panels to the standalone MIND webui:
* Scan control — Start/Stop buttons, freq input (default 925.4M = E-GSM 900
ARFCN 975), 4-cell status row (state dot, freq, pid, started_at), and
a collapsible <details> showing the last 2 KB of stderr from
grgsm_livemon_headless. Start sends {freq: <value>} to /scan/start.
* Observations — live table of cell sightings with columns Cell ID,
ARFCN, MCC, MNC, LAC, CI, First seen, Last seen, Sightings. Badge in
the panel header tracks the current count.
JS additions (sentinelle.js):
* loadScanStatus / startScan / stopScan / loadObservations helpers
* 3 new event listeners (btnScanStart, btnScanStop, btnRefreshObs)
* Background interval (10s) that polls /scan/status always and
/observations only while a scan is running — keeps the post-scan
terminal state on screen without hammering sqlite.
CSS additions (sentinelle.css):
* .scan-status-row (4-col grid inside a panel, matches .status-strip)
* .scan-stderr / .scan-stderr-details (monospace tail block, MIND palette)
* Responsive collapse to 2 cols then 1 col matching the existing breakpoints.
API surface untouched — Tasks 1-4 already shipped /scan/start, /scan/stop,
/scan/status and /observations under /api/v1/sensor/gsm/. Test sweep still
45 passing.
* build(sentinelle-gsm): merge detect_rtlsdr_usb into livemon_runner; drop v0.1 stub; add scapy + gr-gsm deps (ref #344#347)
Co-locate RTL-SDR USB detection with the grgsm_livemon orchestrator.
- Move RTLSDR_USB_IDS + detect_rtlsdr_usb() from livemon.py into
livemon_runner.py. The function is pure sysfs (reads
/sys/bus/usb/devices/*/idVendor + idProduct) — it does NOT claim
the USB device, so it was never the source of #344. The #344 bug
was fixed by Task 1's LivemonRunner model (child-only device claim
via subprocess.create_subprocess_exec).
- Delete lib/sentinelle_gsm/livemon.py — the v0.1 stub also carried a
useless NotImplementedError docstring for listen_gsmtap_udp, fully
superseded by gsmtap_listener.py (Task 1).
- api/main.py: collapse the two-line "from sentinelle_gsm.livemon ...
/ from sentinelle_gsm.livemon_runner ..." block into a single import
pulling LivemonRunner + detect_rtlsdr_usb from livemon_runner.
- debian/control: promote python3-scapy from Recommends to Depends
(runtime requirement once the v0.3.1 L3 decode path ships); keep
gr-gsm in Recommends (subprocess that runs scans).
dpkg-buildpackage: ok (secubox-sentinelle-gsm_0.2.3-1~bookworm1_all.deb).
dpkg-deb -c | grep livemon: only livemon_runner.py + test_livemon_runner.py,
no v0.1 stub. pytest api/tests/ tests/: 45 passed.
* chore(sentinelle-gsm): bump 0.3.0 + extend privacy invariant tests (closes#344#347)
Privacy invariant coverage extended for the v0.3.0 observation pipeline:
* test_observation_has_no_plaintext_fields — Observation dataclass
(gsmtap_listener) exposes ONLY subscriber_hash, never plaintext
IMSI/TMSI/IMEI/MSISDN/ICCID/subscriber_id.
* test_paging_event_only_stores_hash — PagingEvent dataclass
(observations) has no plaintext-identifier fields and exposes
subscriber_hash.
* test_observations_db_refuses_plaintext_imsi — ObservationsDB.record_paging
raises ValueError("plaintext-IMSI ...") when handed a 15-digit
subscriber_hash (write-time shape guard).
Version bump 0.2.3 → 0.3.0 with the full v0.3.0 changelog:
livemon_runner.py (RTL-SDR subprocess wrapper, closes#344) +
gsmtap_listener.py (asyncio UDP 4729) + observations.py (SQLite
sightings/paging_events) + /scan API + webui panels + scapy promoted
to Depends + dropped v0.1 livemon.py stub. 48/48 tests passing.
---------
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
AlertSink.stream() blocked on q.get() until the first alert was
written, leaving the HTTP response body empty. Browsers keep
EventSource.readyState in CONNECTING (0) until the first body byte
arrives — even though the response headers are correct.
Net effect on the live webui: "SSE stream: connecting…" forever, the
"open" event listener never fires, no alerts appear in real time.
Fix:
- AlertSink.stream() emits `: subscribed\n\n` IMMEDIATELY on
subscribe, then loops with asyncio.wait_for(30s) heartbeat.
- Same defensive pattern in _journal_stream() in api/main.py.
- test_alert_sink.py updated to consume the subscribed comment
first then the alert event. 5/5 still pass.
- Full sweep 29/29 green.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Adds a /journal/stream SSE endpoint that pipes `journalctl -u
secubox-sentinelle-gsm -f -o json` and yields one event: log chunk
per entry. The new "Live logs" panel in the webui consumes the
stream via EventSource, colours each line by syslog priority,
auto-scrolls (toggleable), and caps the in-DOM history at 500 lines.
Read-only — the service user is already in the systemd-journal group
via the existing v0.1 postinst, so no new privilege is needed.
The matching nginx ^~ location block in sentinelle-webui.conf mirrors
the /alerts/stream SSE block (24h timeouts, no buffering, no cache).
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
3 fixes catched by live-deploy on gk2 right after 0.2.0 landed:
1. nginx -t failed: secubox-proxy.conf snippet already declares
proxy_http_version + proxy_buffering + proxy_cache + read/send
timeouts; my SSE override re-declared the same → "duplicate
directive". Fix: drop the include, inline a strict subset.
2. POST /trusted returned 500: seeded trusted.json was 0640 root:secubox,
the service user (secubox) couldn't truncate it. Fix: chown to
secubox:secubox in postinst.
3. The standalone /sentinelle/ webui was missing the canonical global
menu bar (6-charter navbar). Fix: add `<nav class="sidebar" id="sidebar">`
placeholder + `<script src="/shared/sidebar.js">` include. Renamed the
local per-module aside from class="sidebar" to class="local-nav" so
/shared/sidebar.js can claim .sidebar without colliding.
All 3 fixes validated live on gk2: /sentinelle/ now 200, POST /trusted
persists, plaintext IMSI never written to disk, nginx -t passes.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
* 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>
Symptom: pre-login banner on ttyS0 (Minicom 2.9 VT102) rendered as
trailing `�` after every emoji line. The console driver couldn't
decode the UTF-8 multi-byte sequences for ⚡🔐🌐📡 and emitted the
replacement character instead.
The `█` box-block characters (U+2588) DO render correctly in VT102+
UTF-8 — they're a single-byte-class glyph in most modern fonts. We
keep those so the SECUBOX wordmart still looks like SecuBox.
Replacements (compatible with VT102 minicom AND SSH UTF-8 alike):
⚡ -> >>
🔐 -> [user]
🌐 -> [web]
📡 -> [ssh]
The /etc/motd block (shown after SSH login) is left untouched — SSH
sessions have proper UTF-8 terminals so the rich emojis are fine
there.
Also add scripts/build-kernel-local.sh (new): the local kernel build
script that I authored today for #255. Documented; runs the same
defconfig + merge_config + scripts/config + olddefconfig + sed ZRAM
override + bindeb-pkg flow as .github/workflows/build-kernel.yml,
without the 20-min GitHub Actions ping-pong.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Older `.streamlit.toml` files were written as `port = "8527"` (with
double-quotes). The previous extraction `cut -d= -f2 | tr -d ' '`
preserved the quotes, so the port var ended up as the literal
string `"8527"` and `ss "sport = :\"8527\""` failed to match — every
running app appeared "not running" to idle-check.
On gk2 today the symptom was `idle-check: active=0 idle=0 stopped=0`
even though 7 apps had a `.streamlit.toml` and were listening inside
the LXC. With the quote-strip fix, ports parse cleanly and the loop
body actually runs.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
debian/rules runs `install -m 755 sbin/*` then `install -m 755 scripts/*`
into the same /usr/sbin/, so the older copy in scripts/ (VERSION=1.0.0,
pre-#182 + pre-#331) silently overwrote the fresh sbin/streamlitctl
every build. Deployed v1.2.2 on gk2 still ran the v1.0.0 script with
no idle helpers or wake subcommand — that's why /var/lib/secubox/
streamlit/idle/ was empty and the idle-check sweep printed app-list
output instead of the "active=X idle=Y stopped=Z" summary.
Removing scripts/ leaves sbin/ as the only candidate. Verified the
1.2.3 .deb ships md5 1c8c2c47c021... (matches source).
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Build #5 cleared the ZRAM choice sed fix (#326) and reached the
bindeb-pkg step, then failed at:
dpkg-checkbuilddeps: error: Unmet build dependencies:
build-essential:native libssl-dev
bindeb-pkg auto-generates a debian/control that Build-Depends on
`build-essential:native`. The previous install list pulled in gcc /
make / libc-dev individually but never the meta-package, so
dpkg-checkbuilddeps failed even though all the underlying tools were
present.
libssl-dev is also in the same error line but it IS already installed
— it gets satisfied once build-essential lands because of the way
checkbuilddeps re-resolves the full Build-Depends graph in one pass.
Ref #319#323#325#326.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
The v2.5.5 udev rule called `lxc-device add zigbee /dev/%k`, but
lxc-device(1) syntax is `lxc-device -n <container> add <path>` — the
v2.5.5 form makes lxc-device parse `add` as the container name and
exits with "Container add is not running". Confirmed on the live board
during the v2.5.5 hotfix recovery: the parent agent had to fall back
to manual `-n zigbee add` to push the device into the running LXC.
Without this fix the hotplug branch silently no-ops — a re-plugged
dongle stays invisible inside the LXC and z2m crash-loops again until
an operator reboots the container.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
* docs(plan): #331 streamlit idle-timeout + wake-on-demand (ref #331)
* feat(streamlit): add idle-tracking helpers to streamlitctl (ref #331)
Add the foundation for per-app idle timeout / wake-on-demand:
- IDLE_STATE_DIR constant at /var/lib/secubox/streamlit/idle
- _idle_config: read [idle] settings from /etc/secubox/streamlit.toml
with safe defaults
- _app_running: check whether an app is listening on its port INSIDE
the LXC (existing inline ss check is host-side and unreliable)
- _app_active_conns: count ESTABLISHED connections to an app's port
- _app_last_active / _app_touch_active: per-app last-activity epoch
tracked via state-file mtime
Wire cmd_app_start to touch the state file on every successful start
so a freshly-started app does not get immediately reaped by the
upcoming idle-check.
All helpers degrade gracefully when the LXC is down (lxc_running
guard) and the helpers themselves do not change any existing
behaviour — they are purely additive.
* feat(streamlit): streamlitctl app idle-check — stop idle apps past timeout (ref #331)
Add cmd_app_idle_check that iterates apps under $APPS_PATH and stops
those that have had zero ESTABLISHED connections for longer than the
configured timeout_minutes (default 30, from [idle] in streamlit.toml).
Apps with active connections get their state file touched; apps with no
state file yet are touched too (grace period). Master switch via [idle]
enabled in /etc/secubox/streamlit.toml.
Wire idle-check + wake into the `app` subcommand dispatcher; wake's
function (cmd_app_wake) lands in the next commit (dead branch until
then). Usage line updated to include both new subcommands.
* feat(streamlit): streamlitctl app wake — lazy start + poll for port (ref #331)
Adds cmd_app_wake <name> [wait_seconds] (default 30s):
- returns 0 if already running OR if the app comes up within wait_seconds
- returns 1 on start failure or poll timeout
- returns 2 on misuse (missing name, unknown app)
Uses _app_running for readiness probing and _app_touch_active to refresh
the idle marker on a successful wake. Polls every 1s for portability.
The case-block dispatcher (idle-check / wake) was already wired in Task 2.
* feat(streamlit): systemd idle-sweep timer (every 5 min) (ref #331)
Ship secubox-streamlit-idle.service (oneshot, runs
`streamlitctl app idle-check`) and secubox-streamlit-idle.timer
(OnBootSec=5min, OnUnitActiveSec=5min) so idle Streamlit apps are
auto-suspended without a separate cron.
Wire the units through debian/secubox-streamlit.install (the package
uses a hand-rolled override_dh_auto_install, so dh_installsystemd's
auto-detection by package.suffix name doesn't pick them up — explicit
.install is needed). Enable/disable the timer in postinst/prerm
alongside the main service.
* feat(streamlit): POST /apps/{name}/wake — lazy start API endpoint (ref #331)
* feat(streamlit): default [idle] config block (timeout 30 min) (ref #331)
* chore(streamlit): bump to v1.2.2 with idle-timeout + wake summary (closes#331)
---------
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
The v2.5.5 bind-mount fix made the device nodes appear inside the LXC,
but they inherit the host's root:dialout 0660 perms. The host `dialout`
GID does NOT map to the LXC's `zigbee2mqtt` user (separate user
namespace), so z2m can't open the device even though it exists.
Two ExecStartPre lines in the z2m unit chmod the device to 0666 right
before the node process starts. Idempotent; `|| true` keeps the unit
healthy when the dongle is unplugged.
The live board needed `chmod 666` after `lxc-device add` for z2m to
actually come up — this follow-up makes that automatic on every z2m
start (including dongle re-plug, since that triggers an LXC device
event and z2m's Restart=always cycle).
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Two-part fix for the https://zigbee.gk2.secubox.in/ 502 reported in chat.
Symptom: zigbee2mqtt inside the zigbee LXC crash-looped 4528+ times.
Port 8080 never opened. The nginx vhost at zigbee.gk2.secubox.in
proxies to 10.100.0.111:8080 → 502.
Root cause: the LXC config bound /dev/secubox-zgb and /dev/serial/by-id
(both *symlinks*) plus cgroup major 188/166, but never bind-mounted
the underlying /dev/ttyUSB0 device node. The kernel followed the
symlinks inside the container and open(2) failed with ENOENT.
Fixes:
- lib/zigbee/install-lxc.sh: add bind mounts for /dev/ttyUSB0 and
/dev/ttyACM0 with the `optional` flag so the LXC still starts
when the dongle is absent.
- lib/zigbee/install-lxc.sh: udev rule gains RUN+= that calls
`lxc-device add zigbee /dev/<kernel>` on every matching
SUBSYSTEM=="tty" hotplug. A re-plugged dongle now lands in the
running container immediately instead of requiring an LXC
restart — also covers any future udev-replay scenario where
the symlink stays valid but the device node was lost.
The live LXC config on gk2 needs to be patched separately
(/data/lxc/zigbee/config plus a one-shot `lxc-device add zigbee
/dev/ttyUSB0`); the source-side change here ensures fresh
installs and zigbeectl-driven reinstalls land the correct config.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Two regressions on a --static-ip image:
1. Default match `name: "eth0"` matched nothing on modern Debian. The
booted kiosk ended up on `enp0s31f6` with link-local 169.254.x.x
because the static stanza was inert.
Fix: default STATIC_IFACE="en*" (covers enp*/eno*/ens*/enx*).
2. firstboot.sh ran `secubox-net-detect apply router` unconditionally
if the binary was present, even though build-live-usb.sh's
--static-ip branch pre-touches /var/lib/secubox/.net-configured to
short-circuit the systemd unit. That re-injected the router-mode
bootstrap (WAN + br-lan 192.168.1.1/24), colliding with the user's
gateway.
Fix: gate the firstboot net-detect block on the marker file.
Also clarify --help wording about glob semantics.
Closes#128.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
debian/secubox-metablogizer.service: lower uvicorn --log-level from
warning to info so the webhook deploy success line
`logger.info("deploy site=…")` reaches journald. Previously only
error paths were visible, making successful auto-deploys silent.
The 8 info-level log sites in api/main.py are all event-driven
(startup + webhook + sync) — no flood concern.
Closes#119.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
After three rounds of failed attempts (#323, #325, #326), the root cause
is clear: kconfig `choice` blocks are re-resolved by olddefconfig from
their declared `default <member>` in Kconfig, regardless of what's in
.config. `scripts/config --enable ZRAM_DEF_COMP_ZSTD` applied BEFORE
olddefconfig is silently undone by olddefconfig's choice pass; merging
a fragment that sets `CONFIG_ZRAM_DEF_COMP_ZSTD=y` is overridden the
same way.
The only reliable approach is post-olddefconfig sed surgery on the
choice members. Choice members are leaf symbols with no downstream
build-time deps, so no further olddefconfig is needed after the
override.
Workflow change:
- move scripts/config out of the choice (only set ZSMALLOC/CRYPTO_*)
- run olddefconfig to resolve the rest of the tree
- sed the choice into ZSTD (LZORLE off, ZSTD on)
- log .config before/after the fixup for diagnostics
- keep the final assertion `^CONFIG_ZRAM_DEF_COMP_ZSTD=y`
Ref #323#325#326.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
www/dns-provider/index.html: as soon as the POST succeeds the provider
is persisted, so close the modal immediately and surface the upstream
/domains probe verdict as a non-modal toast (auto-dismiss after 6s).
The previous flow kept the modal open on a yellow probe-warning even
after the underlying issue got resolved, so the operator saw a stale
warning that contradicted what the providers list already showed.
Closes#304.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
merge_config.sh + olddefconfig don't always honour choice-symbol
overrides set in a fragment (kconfig special-cases choices). Run
scripts/config after the merge to set ZRAM_DEF_COMP_ZSTD explicitly
and disable the upstream-default ZRAM_DEF_COMP_LZORLE.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
merge_config.sh greps every line for kconfig-shaped patterns including
the negative form `# CONFIG_X is not set`. The fragment's prose
comment quoted that literal string, which the merger mistook for a
directive and concatenated with the next real directive, producing
a poisoned merge that left CONFIG_ZRAM_DEF_COMP_ZSTD unset.
Rewording the prose so 'is not set' never appears in a comment line
unblocks the cross-arm64 kernel build.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
CI run 26222437600 failed at the sanity-check: kconfig didn't emit
CONFIG_ZRAM_DEF_COMP="zstd" verbatim because that symbol is the
auto-derived output of the CONFIG_ZRAM_DEF_COMP_<X> choice family,
not a settable variable. Check the choice itself instead.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
The old ExecStart sequence overwrote tmpfiles.d's 1777 root:root with
775 secubox:secubox, killing nginx's ability to traverse the directory
and reach the .sock files inside. Every secubox-*.sock upstream
returned 502 after each reboot. Observed AGAIN on 2026-05-21 after
the MOCHAbin freeze — ~90 services started up but only 8 had usable
sockets, the rest hidden behind broken perms.
New unit:
* Ordered After=systemd-tmpfiles-setup.service so tmpfiles.d wins.
* Re-applies the canonical rule (systemd-tmpfiles --create) as a
safety net for boards that hit the rule before /run is mounted.
* NO chown / chmod that override tmpfiles.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
The MOCHAbin custom kernel builds nf_conntrack / nf_nat / nft_chain_nat
as =y not =m, so the stock nftables.service ExecStartPre 'modprobe
nf_nat' exits 1, systemd kills the unit before ExecStart, and the
DNAT/MASQUERADE rules in /etc/nftables.d/*.nft never get loaded —
every reboot lost lyrion slimproto :3483 DNAT, mqtt forwards, etc.
* packages/secubox-core/systemd/nftables.service.d/override.conf (new):
drop-in that swallows modprobe failures (|| true).
* packages/secubox-core/debian/rules: install the drop-in to
/etc/systemd/system/nftables.service.d/override.conf.
* Closes the nftables half of #150 (board loses NAT rules at boot).
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Recurring "broken-by-vhost-add" bug: every haproxyctl vhost add ran
cmd_generate which wiped operator-added backends (nginx_vhosts,
gitea_ssh, webui-lan, …) and emitted the wrong cert path.
cmd_generate now:
- Writes to a tempfile, validates haproxy -c -f, then atomically
installs via install(1). Live cfg is LEFT UNTOUCHED on failure.
- DATA_PATH defaults to /data/haproxy (charter), /srv/haproxy
fallback. HAPROXY_DATA_PATH env override.
- Always emits mitmproxy_inspector + nginx_vhosts + webui_direct
regardless of waf_enabled.
- Reads /etc/haproxy/cfg.d/*.cfg as operator extras and appends them
verbatim — webui-lan, gitea-ssh etc. survive regens.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
* image/splash/tui-splash.sh: collapse alternating G1/G2 per-letter
coloring to a single ROOT-green (ANSI 29 ~ Charte #0A5840) across
the whole SECUBOX word.
* image/build-image.sh + image/build-live-usb.sh: pre-login /etc/issue
banner switched from 256-color orange (38;5;214) to ROOT green
(38;5;29). Post-login MOTD untouched (it keeps cyan-on-orange-frame
for contrast on console).
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
User asked for control + status metrics at /auth/ instead of an
iframe-redirect, plus a public portal vhost sso.gk2.secubox.in for
multi-SP single-sign-on across SecuBox apps.
* secubox-authelia v1.0.9
- nginx/authelia.conf: /auth/ on the canonical hub vhost becomes a
static alias to /usr/share/secubox/www/authelia/ (operator
dashboard). Used to reverse-proxy the Authelia LXC portal.
- nginx/authelia.conf: @sbx_auth_login redirects to
https://sso.gk2.secubox.in/?rd=… (the new public portal vhost).
- nginx/authelia-vhost.conf: new server block sso.gk2.secubox.in,
reverse-proxying / → /auth/ → Authelia LXC 10.100.0.20:9091.
Wildcard *.gk2.secubox.in.pem cert already covers it.
- www/authelia/index.html: rewrite as the SecuBox AUTH config
module (Charter AUTH palette #C04E24). Surfaces service state,
version, user count, cookie scopes, access rules. Was a
Lyrion-template copy-paste stub.
- lib/authelia/install-lxc.sh: session.cookies[] for the hub
domain now sets authelia_url to sso.${SECUBOX_HUB_DOMAIN}.
* secubox-nextcloud v1.3.3, secubox-zigbee v2.5.4, secubox-lyrion v1.0.9
- Their @sbx_auth_login redirects now target
https://sso.gk2.secubox.in/?rd=… for login.
Deploy-side follow-up (operator):
- DNS sso.gk2.secubox.in → board public IP
- mitmproxy routes JSON: add sso.gk2.secubox.in → [192.168.1.200, 9080]
- haproxyctl vhost add sso.gk2.secubox.in mitmproxy_inspector
(+ workaround for #286 — re-append the backends)
- ln -s ../sites-available/authelia.conf /etc/nginx/sites-enabled/
- Re-apply install-lxc.sh OR sed-patch the live /etc/authelia/configuration.yml
inside the LXC to repoint authelia_url.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
The hub's `_compute_menu_sync()` drops menu entries lacking an `id`
field. 6+ packages (lyrion, yacy, zigbee, rustdesk, grafana, authelia)
shipped a different schema (`title/url/section/module`) so they NEVER
appeared in the navbar. Plus the existing CATEGORY_META declared 12
sections (dashboard/security/network/system/core/users/services/
privacy/monitoring/publishing/apps/admin), not the 6 SecuBox charter
modules.
Changes:
* All 121 packages/secubox-*/menu.d/*.json normalised to the canonical
schema (id, name, path, category, icon, order, description). Legacy
schema aliases (title/url/module/section) preserved as fallbacks then
dropped from the file.
* Each menu entry's category remapped to one of the 6 charter modules:
AUTH (auth/users/identity/zkp/nac/openclaw),
WALL (crowdsec/waf/mitmproxy/hardening/threat-* /cve-triage/...),
BOOT (kernel-build/eye-remote/master-link/droplet/cloner/backup/...),
MIND (ai-gateway/mcp-server/grafana/ndpid/netifyd/glances/...),
ROOT (system/hub/portal/console/admin/vault/vm/rtty/...),
MESH (wireguard/dns/tor/matrix/gitea/nextcloud/mail/lyrion/yacy/
zigbee/dns-provider/rustdesk/... — all network + comms apps).
* secubox-hub v1.4.0 — CATEGORY_META rewritten with the 6 charter
modules carrying their official color from DESIGN-CHARTER.md and
the complementary-pair order. DEFAULT_MENU fallback remapped too.
* secubox-zigbee v2.5.3 — www/zigbee/index.html rewritten with the
canonical SecuBox scaffold (body display:flex, sidebar 220px fixed,
.main reserving 48px for the global-menu-bar). MESH palette.
Browser side: operators need to clear localStorage sbx_menu_cache (or
hard-refresh after the 1h TTL) to see the new sections after deploy.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
menu.d/80-zigbee.json was a verbatim copy of Lyrion's metadata, so
the navbar showed two 'Lyrion' entries and no Zigbee. Now title
Zigbee, icon fa-microchip, url /zigbee/, order 85.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Operator reported 'webui doesn't reflect Gandi info after add'. Root
causes in the WebUI Add Provider flow:
* api() returned FastAPI {detail: …} bodies as if successful — non-2xx
responses rendered as `[object Object]` in alert, so 422 validation
errors were invisible.
* Default <select> value was 'cloudflare'. Post-migration operators
arriving with a Gandi key could submit without changing it — POST
succeeded with provider=cloudflare + api_token=<gandi-key>, /domains
then failed and the WebUI showed zeros.
* No post-add probe — the WebUI dismissed the modal on POST 200
without confirming the credentials actually authenticated upstream.
Changes:
* Default the provider <select> to Gandi LiveDNS.
* New apiStrict() helper returns {ok, status, data, error} — error is
a real human-readable string (handles both .detail string and
.detail object/array from Pydantic).
* addProvider(): validates required credential fields client-side,
shows inline status during the POST, then probes
/domains?provider_id=<new> immediately and surfaces green ✓ / yellow
⚠ verdicts inline.
* Modal carries a hint about persistence
(/var/lib/secubox/dns-provider/providers.json) and the
migration-safe secrets escape hatch.
The server-side auto-seed from /etc/secubox/secrets/dns-provider-gandi.key
is a separate follow-up (will file when this PR lands).
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
gk2 board runs 15+ LXCs on 8 GiB without swap; the stock kernel ships
`# CONFIG_ZRAM is not set` so zram-tools can't activate. Adding zram
as a module with zstd as the default compressor lets userspace create
/dev/zram0, mkswap, and swapon at ~25% of RAM for safe compressed
RAM-backed swap (no plaintext on disk — CSPN-friendly).
* board/mochabin/kernel/config-6.12.85-secubox-zram.fragment (new):
CONFIG_ZRAM=m, CONFIG_ZRAM_DEF_COMP_ZSTD=y, CONFIG_ZSMALLOC=y +
zstd/lz4/lzo crypto for runtime swap of compressor.
* kernel-build/build-kernel.sh: merge the zram fragment into the
kernel config alongside the OpenWrt-merged base.
Follow-up (separate issue): ship a minimal /usr/lib/systemd/system/
zramswap.service that does `modprobe zram; mkswap /dev/zram0;
swapon -p 100 /dev/zram0` so we don't need apt outbound for
zram-tools.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
body padding-top:48px to clear the fixed .global-menu-bar (injected by
shared/sidebar.js). Sidebar grid column 260px -> canonical 220px.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Banner JS previously emitted <a href="/waf/">, <a href="/crowdsec/">,
<a href="/system/"> when running on the hub vhost. Loaded cross-origin
(e.g. embedded on oracle.ganimed.fr), the relative paths resolved to
wrong-host URLs like https://oracle.ganimed.fr/system/.
* www/shared/health-banner.js: drop the <a href> branch in the alert
renderer — always emit <div class="hb-alert …">. Banner stays
informational; navigation belongs to the dashboard sidebar.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Mirrors the nextcloud split (#282). /zigbee/ on the canonical hub stays
a SecuBox-side configuration UI; the actual zigbee2mqtt frontend moves
to its own vhost.
* nginx/zigbee.conf — /zigbee/ becomes a static alias to
/usr/share/secubox/www/zigbee/ (SecuBox config module driving
/api/v1/zigbee/*). Was a reverse-proxy to the z2m LXC in v2.4.x.
* nginx/zigbee-vhost.conf (new) — public vhost zigbee.gk2.secubox.in
reverse-proxies / to 10.100.0.111:8080 with WebSocket upgrade.
Authelia SSO via auth_request /__sbx_auth_verify (X-Original-URL +
X-Forwarded-* per #274). @sbx_auth_login redirects to
https://admin.gk2.secubox.in/auth/ — cookie scope .gk2.secubox.in.
* www/zigbee/index.html — rewritten (was a copy-paste of the Lyrion
stub). Calls /api/v1/zigbee/* with the SecuBox auth wrapper, surfaces
a prominent "Open Zigbee Manager ->" button to
https://zigbee.gk2.secubox.in/.
* debian/rules — ships the vhost file to /etc/nginx/sites-available/.
Deploy-side follow-up: DNS zigbee.gk2.secubox.in -> board IP, mitmproxy
route inside the LXC, ln -s vhost into sites-enabled, nginx reload.
Wildcard cert already covers the new vhost.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Consolidates the gk2-board apps under one cookie scope (.gk2.secubox.in)
and one SSO portal (admin.gk2.secubox.in/auth/) instead of a separate
auth.maegia.tv portal + .maegia.tv cookie. Wildcard *.gk2.secubox.in
cert already covers both new vhosts.
* secubox-lyrion v1.0.8 — server_name lyrion.maegia.tv ->
lyrion.gk2.secubox.in; @sbx_auth_login redirects to
admin.gk2.secubox.in/auth/; /__sbx_auth_verify re-declared with the
v1.0.7 X-Original-URL + X-Forwarded-* pattern (#274).
* secubox-yacy v1.0.10 — server_name yacy.maegia.tv ->
yacy.gk2.secubox.in (no SSO change in this PR — yacy-vhost still
has no auth_request; adding it is a follow-up to align with siblings).
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Operators previously had to grep the FastAPI / nextcloudctl source to
find the right key names. Symptom: Connection URLs panel on the SecuBox
config UI showed `http://localhost:8080` (the default fallback) even
after the new nc.gk2.secubox.in vhost was live.
* conf/nextcloud.toml.example — flat-key template documenting all keys
both api/main.py and sbin/nextcloudctl read. Includes the ssl_*,
http_port, and install knobs (domain, admin_user, db_type).
* debian/rules — ships to /etc/secubox/nextcloud.toml.example.
Pure-config addition. No service code change.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
v1.3.0 had reverse-proxied `/nextcloud/` on the canonical hub vhost to
the Nextcloud LXC by mistake. That URL is the SecuBox CONFIGURATION
MODULE (static UI driving /api/v1/nextcloud/), not the Nextcloud app.
The actual Nextcloud app now lives on its own vhost:
* nginx/nextcloud.conf — `/nextcloud/` reverted to the static alias of
/usr/share/secubox/www/nextcloud/ (SecuBox config UI). /api/v1/nextcloud/
socket proxy unchanged.
* nginx/nextcloud-vhost.conf (new) — public vhost `nc.gk2.secubox.in`
reverse-proxying to the LXC Apache at 10.100.0.21:80 with Authelia
SSO gating. Re-declares /__sbx_auth_verify with X-Original-URL +
X-Forwarded-* forwarding (#274 pattern, server-block scope).
@sbx_auth_login redirects to https://admin.gk2.secubox.in/auth/
— session.cookies on .gk2.secubox.in cover both vhosts (#272).
* debian/rules — installs nginx/nextcloud.conf to BOTH /etc/nginx/secubox.d/
AND /etc/nginx/secubox-routes.d/ (matches secubox-authelia convention).
Installs nginx/nextcloud-vhost.conf to /etc/nginx/sites-available/nextcloud.conf
for operator-driven symlink enable.
Deploy-side follow-up (operator): DNS for nc.gk2.secubox.in, HAProxy SNI ACL,
mitmproxy haproxy-routes.json entry, ACME cert, Nextcloud trusted_domains
update.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Nextcloud package predated the SSO chain. On gk2 it sat as a static
stub alias on /nextcloud/, the LXC was hand-patched to veth at
10.100.0.20 (colliding with secubox-authelia), and there was no
Authelia gating.
* nginx/nextcloud.conf — /nextcloud/ is now a reverse-proxy to the
Nextcloud LXC at 10.100.0.21:80 with Apache-friendly headers
(Host, X-Forwarded-{For,Proto,Host}), 300s read timeout, request
buffering off, no body-size cap (large uploads / WebDAV).
* nginx/nextcloud.conf — SSO-gated via auth_request /__sbx_auth_verify
(handler owned by secubox-authelia v1.0.8, #278). User identity
forwarded to Apache via X-Forwarded-{User,Groups}.
* sbin/nextcloudctl — LXC template moved from lxc.net.0.type=none
(host-mode) to veth + br-lxc + 10.100.0.21/24. Frees 10.100.0.20
for secubox-authelia. Defaults aligned with v2.11.1 (LXC_PATH=
/data/lxc, DATA_PATH=/data/volumes/nextcloud). Env override knobs
per MODULE-GUIDELINES.md §3.
Operators on gk2-style boards must manually rebind the existing LXC
to .21 (recipe in #280). install-lxc.sh modernization deferred to
v1.4.0.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
The named location handling 401 from auth_request was defined in
secubox-zigbee but consumed by secubox-lyrion too — uninstalling
zigbee would silently break lyrion's redirect-to-portal flow.
Authelia is the natural owner since it provides the portal.
* secubox-authelia v1.0.8 — nginx/authelia.conf now defines
`location @sbx_auth_login` (moved verbatim from zigbee.conf).
* secubox-zigbee v2.4.5 — nginx/zigbee.conf drops the local
definition, leaves an in-source comment pointing readers to
authelia.conf.
Same canonical hub server block via `include /etc/nginx/secubox.d/*`,
so the named-location lookup is unchanged at runtime — no functional
behaviour change.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
After v1.0.6 added the second session.cookies[] entry for the SecuBox
hub domain, login worked but every auth_request /__sbx_auth_verify
still returned 401 → infinite redirect loop between /auth/?rd=... and
the protected URL.
Root cause: Authelia 4.39's /api/verify needs the original URL to
(a) pick the right session.cookies[].domain entry for session lookup
(multi-cookie deployments have several) and (b) apply access_control
rules. We were forwarding only Cookie + Authorization, so Authelia
defaulted to the first cookies[] entry (maegia.tv), didn't find the
gk2.secubox.in session, returned 401.
Fix:
* nginx /__sbx_auth_verify sets X-Original-URL https://\$host\$request_uri
plus X-Forwarded-{Method,Proto,Host,Uri,For}.
* FastAPI /verify passes those headers through to the Authelia LXC.
Builds on #272 (multi-cookie session config).
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>