Commit Graph

1338 Commits

Author SHA1 Message Date
CyberMind
0ca16778cc
feat(secubox-sentinelle-gsm): v0.3.1 — L3 decode + scoring engine + baseline + qualified alerts (closes #349) (#350)
* 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>
2026-05-22 14:34:54 +02:00
CyberMind
5f3d268c05
feat(secubox-sentinelle-gsm): v0.3.0 — passive observation pipeline (closes #344 #347) (#348)
* 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>
2026-05-22 13:43:54 +02:00
CyberMind
934b6c9fb9
fix(sentinelle-gsm): v0.2.3 — SSE first-byte heartbeat (unstucks browser CONNECTING) (#346)
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>
2026-05-22 13:04:16 +02:00
CyberMind
2ae5be0328
feat(sentinelle-gsm): v0.2.2 — live logs panel (SSE journalctl stream) (#343)
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>
2026-05-22 12:47:04 +02:00
CyberMind
7998e185ff
fix(secubox-sentinelle-gsm): v0.2.1 — nginx SSE block, postinst perms, global navbar injection (#342)
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>
2026-05-22 11:58:57 +02:00
CyberMind
95550edc4e
feat(secubox-sentinelle-gsm): v0.2.0 — standalone WebUI + local alerts (SSE + browser notification) (closes #340) (#341)
* docs(plan): #340 secubox-sentinelle-gsm v0.2 standalone webui + local alerts (ref #340)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-22 11:49:05 +02:00
CyberMind
b6923cd051
fix(image): replace emojis in /etc/issue with VT102-safe ASCII tags (#339)
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>
2026-05-22 10:07:15 +02:00
CyberMind
285a67266c
fix(secubox-streamlit): v1.2.4 — strip quotes from port value in _app_running/_app_active_conns (#338)
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>
2026-05-22 08:20:09 +02:00
CyberMind
5ffb77f06e
fix(secubox-streamlit): v1.2.3 — drop stale scripts/streamlitctl that clobbered sbin/ (#337)
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>
2026-05-22 08:18:02 +02:00
CyberMind
97015bbb7f
fix(kernel-build): install build-essential so dpkg-checkbuilddeps passes (#336)
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>
2026-05-22 08:02:01 +02:00
CyberMind
a82b8eedb7
fix(secubox-zigbee): v2.5.7 — udev RUN+= uses lxc-device -n zigbee add, not add zigbee (#335)
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>
2026-05-22 08:01:12 +02:00
CyberMind
832141cd0a
feat(secubox-streamlit): v1.2.2 — per-app idle timeout + wake-on-demand (closes #331) (#334)
* 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>
2026-05-22 07:58:33 +02:00
CyberMind
2b85c794fd
fix(secubox-zigbee): v2.5.6 — z2m ExecStartPre chmod 0666 on /dev/tty{USB,ACM}0 (#333)
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>
2026-05-22 07:31:43 +02:00
CyberMind
3bc40dc35b
fix(secubox-zigbee): v2.5.5 — bind /dev/ttyUSB0+ttyACM0 into LXC, udev hotplug RUN+= (#332)
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>
2026-05-22 07:26:03 +02:00
CyberMind
e2df52ae16
fix(build-live-usb): default --iface to en*, skip net-detect when static netplan is baked (closes #128) (#330)
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>
2026-05-22 07:09:41 +02:00
CyberMind
e83fc3ba5b
fix(secubox-metablogizer): v1.2.1 — uvicorn --log-level info, surface webhook deploy success (#329)
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>
2026-05-22 07:07:17 +02:00
CyberMind
f56ece69ca
fix(kernel-build): force ZRAM_DEF_COMP_ZSTD via sed POST-olddefconfig (#328)
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>
2026-05-22 07:05:13 +02:00
CyberMind
37c2d43958
fix(secubox-dns-provider): v1.0.3 — dismiss Add-Provider modal on save, toast the probe verdict (#327)
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>
2026-05-22 06:53:39 +02:00
CyberMind
8e5e08449f
fix(kernel-build): enforce ZRAM choice via scripts/config after merge (#326)
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>
2026-05-22 06:45:59 +02:00
CyberMind
fc48024e46
feat(/data Phase 1): migrate 5 core packages from /srv to /data with postinst auto-mv (closes #319 Phase 1) (#324)
Phase 1 — 5 high-impact core packages migrated to the canonical
/data storage convention (charter §Storage). Each package's debian/postinst
gains an idempotent migration block that moves legacy /srv/<dir> → /data/<dir>
on upgrade and leaves a symlink behind for back-compat.

* secubox-mitmproxy v1.0.2: migrates /srv/mitmproxy*, /srv/mitmproxy-waf,
  /srv/mitmproxy-in. 8 files in source updated.
* secubox-waf v1.1.1: same dirs (shared with mitmproxy). 5 files updated.
* secubox-mail v2.3.1: migrates /srv/mail. 2 files updated.
* secubox-mail-lxc v2.2.1: migrates /srv/mail. 1 file updated.
* secubox-gitea v1.4.2: migrates /srv/gitea. 4 files updated.

Total: 20 files, 58 substitutions in source. Postinst auto-migration
is service-aware (stop → mv → ln -s → start). Boards previously on
/srv/<pkg> are seamlessly moved; new installs land directly on /data.

Phase 2 (~12 packages) and Phase 3 (3 cosmetic) tracked in #319.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-22 06:27:22 +02:00
CyberMind
8157761843
fix(kernel-build): purge 'is not set' prose from ZRAM fragment comments (#325)
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>
2026-05-21 17:58:37 +02:00
CyberMind
c24ddcfed3
fix(kernel-build): assert CONFIG_ZRAM_DEF_COMP_ZSTD not the auto-derived string (#323)
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>
2026-05-21 17:16:15 +02:00
CyberMind
0693d4eb6c
feat(/data Phase 1): migrate 5 core packages from /srv to /data with postinst auto-mv (closes #319 Phase 1) (#322)
Phase 1 — 5 high-impact core packages migrated to the canonical
/data storage convention (charter §Storage). Each package's debian/postinst
gains an idempotent migration block that moves legacy /srv/<dir> → /data/<dir>
on upgrade and leaves a symlink behind for back-compat.

* secubox-mitmproxy v1.0.2: migrates /srv/mitmproxy*, /srv/mitmproxy-waf,
  /srv/mitmproxy-in. 8 files in source updated.
* secubox-waf v1.1.1: same dirs (shared with mitmproxy). 5 files updated.
* secubox-mail v2.3.1: migrates /srv/mail. 2 files updated.
* secubox-mail-lxc v2.2.1: migrates /srv/mail. 1 file updated.
* secubox-gitea v1.4.2: migrates /srv/gitea. 4 files updated.

Total: 20 files, 58 substitutions in source. Postinst auto-migration
is service-aware (stop → mv → ln -s → start). Boards previously on
/srv/<pkg> are seamlessly moved; new installs land directly on /data.

Phase 2 (~12 packages) and Phase 3 (3 cosmetic) tracked in #319.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-21 17:10:35 +02:00
CyberMind
1e90f0d018
fix(secubox-core): v1.1.4 — secubox-runtime defers /run/secubox perms to tmpfiles.d (closes #151) (#321)
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>
2026-05-21 16:43:11 +02:00
CyberMind
aba60ae356
feat(/data Phase 1): migrate 5 core packages from /srv to /data with postinst auto-mv (closes #319 Phase 1) (#320)
Phase 1 — 5 high-impact core packages migrated to the canonical
/data storage convention (charter §Storage). Each package's debian/postinst
gains an idempotent migration block that moves legacy /srv/<dir> → /data/<dir>
on upgrade and leaves a symlink behind for back-compat.

* secubox-mitmproxy v1.0.2: migrates /srv/mitmproxy*, /srv/mitmproxy-waf,
  /srv/mitmproxy-in. 8 files in source updated.
* secubox-waf v1.1.1: same dirs (shared with mitmproxy). 5 files updated.
* secubox-mail v2.3.1: migrates /srv/mail. 2 files updated.
* secubox-mail-lxc v2.2.1: migrates /srv/mail. 1 file updated.
* secubox-gitea v1.4.2: migrates /srv/gitea. 4 files updated.

Total: 20 files, 58 substitutions in source. Postinst auto-migration
is service-aware (stop → mv → ln -s → start). Boards previously on
/srv/<pkg> are seamlessly moved; new installs land directly on /data.

Phase 2 (~12 packages) and Phase 3 (3 cosmetic) tracked in #319.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-21 16:32:41 +02:00
CyberMind
5b489b183a
fix(secubox-core): v1.1.3 — nftables.service tolerates built-in nf_nat modules (closes nftables half of #150) (#318)
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>
2026-05-21 13:17:30 +02:00
CyberMind
a64e4eee30
fix(auth): icon 🛂🎯 per charter §Module Icons/Symbols (AUTH = hexagonal target) (#317)
Two places updated:
* packages/secubox-authelia/www/authelia/index.html header H1
* packages/secubox-hub/api/main.py CATEGORY_META.auth.icon

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-21 11:43:19 +02:00
CyberMind
feab3c8238
fix(secubox-haproxy): v1.3.0 — atomic cmd_generate + always-emit canonical backends + cfg.d extras (closes #286) (#316)
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>
2026-05-21 11:18:53 +02:00
CyberMind
0b7a25f179
fix(splash): single ROOT-green SECUBOX banner per charter (closes #314) (#315)
* 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>
2026-05-21 11:12:56 +02:00
CyberMind
efcfbd9edf
feat(secubox-threats): v1.1.0 — centralised threats dashboard (closes #312) (#313)
Operator request: 'travailler une vraie interface avec tous les
metrics centralises dedies sur https://admin.gk2.secubox.in/threats/'.

Rebuilt www/threats/index.html on the canonical SecuBox scaffold:
* body display:flex, sidebar 220px fixed, .main padding-top 48px
* WALL palette (Charte SecuBox §Six Module Color System, #9A6010)
* Five-stat strip: threat level, active threats, alerts 24h, IOCs
  tracked, open incidents.
* 2-col row: 24h hourly alert timeline (histogram) + risk score
  breakdown.
* 3-col row x2: recent alerts, top IOCs, open incidents, alert
  sources, threat feeds, recent reports — every endpoint exposed by
  api/main.py is wired up.
* Auto-refresh every 30s on the volatile panels.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-21 11:00:15 +02:00
CyberMind
024f6f01e8
feat(secubox-authelia): split SSO portal from operator dashboard (closes #310) (#311)
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>
2026-05-21 10:50:13 +02:00
CyberMind
9172052910
feat(secubox-certs): reconstruct source package from running deployment (closes #308) (#309)
The certs module shipped as ghost files (/usr/lib/secubox/certs,
/usr/share/secubox/www/certs, /etc/nginx/secubox.d/certs.conf,
/usr/share/secubox/menu.d/36-certs.json, secubox-certs.service) but
no Debian package owned them. Reinstalls + migrations dropped the
module entirely. Now properly owned.

* packages/secubox-certs/{api,www,nginx,menu.d,debian}/* — all
  reconstructed from the running gk2 deployment.
* api/main.py — FastAPI ACME manager (704 lines, copied verbatim).
* www/certs/index.html — WebUI dashboard.
* menu.d/36-certs.json — category=auth (charter consolidation #306).
* debian/* — control / changelog / rules / postinst / prerm + the
  systemd unit secubox-certs.service.
* README.md — module overview.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-21 10:40:59 +02:00
CyberMind
5e93c73600
feat(navbar): consolidate to 6 charter categories + fix dropped menu entries (closes #306) (#307)
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>
2026-05-21 10:03:05 +02:00
CyberMind
0e02d54e01
fix(secubox-zigbee): v2.5.2 — proper menu.d entry (closes #303) (#305)
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>
2026-05-21 09:46:39 +02:00
CyberMind
16e5d75b18
fix(secubox-dns-provider): v1.0.2 — modal z-index 100 -> 1500 (closes #301) (#302)
Add Provider dialog opened behind .global-menu-bar (z-index 998).
Modal needed to clear the navbar.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-21 09:38:32 +02:00
CyberMind
a65e70c335
fix(secubox-dns-provider): v1.0.1 — Add Provider modal hardening (closes #299) (#300)
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>
2026-05-21 09:28:46 +02:00
CyberMind
c23d3256e8
ci(kernel-build): GitHub Actions cross-arm64 workflow (closes #297) (#298)
Manual-dispatch workflow that produces a fresh linux-image-*.deb
artifact when source-side kernel config changes (e.g. #295 ZRAM).

* workflow_dispatch inputs: kernel_version (default 6.12.85),
  revision (default 2secubox).
* Steps: checkout, install cross toolchain + build deps, fetch
  upstream tarball, apply repo patches, merge OpenWrt-merged base
  fragment + ZRAM fragment, sanity-grep key configs, run
  `make bindeb-pkg` to cross-build .deb, upload all .debs as
  artifact with 30-day retention.
* Concurrency-grouped on ref so a re-dispatch cancels the old run.
* 90-minute timeout (typical run ~25-40 min).

Operator workflow:
  gh workflow run "Build SecuBox kernel (cross arm64)"
  gh run download <run-id>
  scp linux-image-*.deb root@gk2:/tmp/
  ssh root@gk2 dpkg -i /tmp/linux-image-*.deb
  ssh root@gk2 reboot

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-21 08:47:57 +02:00
CyberMind
8d9b012c6e
feat(kernel-build): add CONFIG_ZRAM=m with zstd default (closes #295) (#296)
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>
2026-05-21 08:37:09 +02:00
CyberMind
a0228b4753
fix(secubox-zigbee): v2.5.1 — /zigbee/ UI under global navbar (closes #293) (#294)
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>
2026-05-21 08:30:28 +02:00
CyberMind
051c3b6c3c
fix(secubox-hub): v1.3.4 — health-banner emits non-clickable alerts (closes #290) (#292)
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>
2026-05-21 08:25:21 +02:00
CyberMind
38a854b3ae
feat(secubox-zigbee): v2.5.0 — split SecuBox config module from public z2m vhost (closes #289) (#291)
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>
2026-05-21 08:25:18 +02:00
CyberMind
75566ce75e
refactor(secubox-lyrion+yacy): move public vhosts to *.gk2.secubox.in (closes #287) (#288)
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>
2026-05-21 08:25:15 +02:00
CyberMind
c454a374fc
feat(secubox-nextcloud): v1.3.2 — ship nextcloud.toml.example (closes #284) (#285)
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>
2026-05-21 07:47:21 +02:00
CyberMind
10f1842cb9
fix(secubox-nextcloud): v1.3.1 — split SecuBox config module from public app vhost (closes #282) (#283)
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>
2026-05-21 07:34:48 +02:00
eaf2d82732 docs(HISTORY): secubox-nextcloud v1.3.0 reverse-proxy + SSO gating (#280) 2026-05-21 07:27:29 +02:00
CyberMind
8375f140cd
feat(secubox-nextcloud): v1.3.0 — reverse-proxy + SSO gating + move to 10.100.0.21 (closes #280) (#281)
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>
2026-05-21 07:25:37 +02:00
1138c0f1a4 docs(WIP): Authelia SSO loop fix end-to-end (issues #272 #273 #274 #278) 2026-05-21 07:16:54 +02:00
CyberMind
eff829784f
refactor(secubox-authelia+zigbee): own @sbx_auth_login in authelia.conf (closes #278) (#279)
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>
2026-05-21 07:14:55 +02:00
0d3ce05523 docs: HISTORY — Authelia SSO loop fix (issues #272 #273 #274, validated) 2026-05-21 07:10:29 +02:00
CyberMind
fe37e2c766
fix(secubox-authelia): v1.0.7 — forward X-Original-URL + X-Forwarded-* to /api/verify (closes #274) (#277)
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>
2026-05-20 19:30:55 +02:00