Commit Graph

1375 Commits

Author SHA1 Message Date
da09fa6987 fix(live-usb): mask sentinelle-gsm + fmrelay; cap their restart storm
Both modules need physical hardware (RTL-SDR, EP06 modem) and crash
on `import gr_osmosdr` when those aren't there. On the live USB
amd64 build neither is present, so the service died at startup,
systemd restarted in 5s under `Restart=on-failure`, and the cycle
repeated until /var/log filled with hundreds of FAILED lines —
visible on the boot console and blocking the kiosk target from
ever finishing.

Two fixes layered:

1. systemd unit (sentinelle-gsm.service): add StartLimitIntervalSec=30
   + StartLimitBurst=3 + StartLimitAction=none. If start fails 3
   times in 30 s, give up instead of looping forever. The kiosk's
   multi-user.target settles even when the SDR is missing.

2. build-live-usb.sh: mask secubox-sentinelle-gsm + secubox-fmrelay
   in the INCOMPLETE_MODULES list alongside other hardware-gated
   modules. Operators enable + unmask by hand once they wire the
   physical RTL-SDR / GSM modem.

Operator quick fix on an already-flashed live USB:

  sudo systemctl stop secubox-sentinelle-gsm
  sudo systemctl mask secubox-sentinelle-gsm
  sudo systemctl mask secubox-fmrelay
  sudo systemctl daemon-reload

Then `secubox-kiosk-setup enable --x11` should complete normally.
2026-05-25 06:33:45 +02:00
e1a532975b fix(live-usb): harden GRUB EFI build — fail-fast + Secure Boot shim
v2.12.1 booted in VBox dropped to the EFI Shell PXE prompt: BOOTX64.EFI
was built but the module set lacked `disk` / `usb` / `usbms` so OVMF
couldn't hand off block IO once GRUB took over. Same image worked in
BIOS mode — the i386-pc grub didn't need those modules.

Hardening:

* GRUB_MODS now includes `disk usb usbms ahci ata` + `loadenv` +
  `gfxterm_background gzio png jpeg font sleep reboot halt true exfat
  iso9660 ntfs search_fs_uuid search_fs_file` — the full toolbox a
  USB / VM EFI handoff needs.

* `grub-mkimage` failure aborts the build instead of warning. The
  embed-cfg's `search` chain falls back through label-ESP →
  label-LIVE → `$cmdpath/../../boot/grub` so the bootloader still
  finds the menu when the partition label was lost (some flashers
  rewrite it).

* `--compress=xz` on the EFI binary shaves ~40% off its size.

* Secure Boot: if `shimx64.efi.signed` + `grubx64.efi.signed` are
  available on the builder (they're already installed by CI line 108),
  ship the Microsoft-trusted shim AS BOOTX64.EFI and the signed grub
  alongside. Falls through to plain unsigned grub when those packages
  are missing locally, with a noisy warn explaining how to install
  them.

* `grub-install --target=i386-pc` failures now propagate via err
  instead of warn — silent BIOS install failure was the v2.10.x
  "boots into nothing" footgun.

* Mirror BOOTX64.EFI to /EFI/secubox/grubx64.efi so a later
  `efibootmgr -c` can register a named entry without clobbering
  /EFI/BOOT.

* Local apt-get install adds `grub-efi-amd64-signed` + `shim-signed`
  so dev-host builds get the same Secure Boot output as CI.
2026-05-24 15:53:20 +02:00
c8ccfcf31f feat(authelia): LAN whitelist on admin.gk2.secubox.in (bypass SSO from inside)
Operator request: dashboard reachable without SSO from the LAN, gated
by Authelia from the outside.

Two new pieces wired together:

1. /etc/nginx/conf.d/secubox-lan-geo.conf (new)
   - geo \$lan_client = 1 for 127/8, 192.168.1.0/24, 192.168.255.0/24,
     10.100.0.0/24 (br-lxc), 10.55.0.0/24 (eye-br0), 10.0.3.0/24
   - set_real_ip_from 127.0.0.1 + 10.100.0.0/24 with real_ip_header
     X-Forwarded-For + real_ip_recursive on. Without this every
     request looks local (HAProxy + mitmproxy are on-host) and the
     bypass would whitelist the public internet.

2. /etc/nginx/secubox.d/authelia.conf
   - location = /__sbx_auth_verify gains `if (\$lan_client) { return 200; }`
     as the first directive — short-circuits the Authelia subrequest
     for trusted networks. SSO-gated backends keep their existing
     `auth_request /__sbx_auth_verify;` + `error_page 401 = @sbx_auth_login;`
     directives unchanged; the bypass is invisible to them.

debian/rules installs the geo block into /etc/nginx/conf.d/ so it
loads at http{} level before any include of secubox.d/*.conf inside a
server{} block.
2026-05-24 14:58:43 +02:00
80266b281f fix(authelia): preserve \$args on / -> /auth/ redirect (SSO callback URL)
The `location = /` redirect dropped the query string, so when
@sbx_auth_login sent users to `https://sso.gk2.secubox.in/?rd=...`,
the rd= parameter was stripped on the first hop and Authelia's login
form had no return target. Users landed on the hub root after auth
instead of on the protected page they originally requested.

Use `$is_args$args` on the redirect target — present on both vhost
blocks (auth.maegia.tv + sso.gk2.secubox.in). Verified chain:

  /fmrelay/ → 302 sso/?rd=…
  sso/?rd=… → 302 sso/auth/?rd=…   ← now preserves rd
  sso/auth/?rd=… → 200 Authelia login

Surfaced while bringing fmrelay live (#377), but affects every
SSO-gated module behind the public sso portal.
2026-05-24 14:50:43 +02:00
c0b5dec4b6 fix(navbar): emoji icons via menu-data fallback + replace remaining FA names
Top page bar showed 📦 for any module whose path wasn't in the hardcoded
PAGE_ICONS table. Refactor getPageIcon() to fall back to live menu data via
window._menuDataCache (pre-seeded from localStorage at boot, updated when
the menu API lands). Also re-paints the icon once fresh menu data arrives.

Source-side menu.d entries that still used Font Awesome class names are
swapped to emoji so the icon round-trips through the API instead of
rendering as text:

  - secubox-authelia: "fa-key" -> "🔑"
  - secubox-hexo:     "edit"   -> "📝"
  - secubox-smtp-relay: "envelope" -> "✉️"
  - secubox-metoblizer (Overview entry): "scroll" -> "📜"
2026-05-24 11:25:05 +02:00
27645f0a1b fix(live-usb): IP static fallback + kiosk firstboot safety net for x64 live
Two bugs surfaced on the v2.12.0 amd64 live USB build (user-tested on
both VirtualBox + bare-metal amd64 2026-05-24):

1. **IP**: the static fallback added to board/x64-live/netplan/
   00-secubox.yaml in commit 264e23e4 never reached the booted image
   because image/build-live-usb.sh writes its OWN inline netplan
   template (line 782, here-doc) that's a copy of the board file
   without the static block. Both DHCP-only matches `en*` and `eth*`
   now also carry `addresses: [192.168.1.55/24]` + gw 192.168.1.254 +
   nameservers, with a high route metric (1000) so DHCP still wins
   when available — but the appliance stays reachable on .55 when it
   doesn't (the original #370 intent).

2. **Kiosk**: build-live-usb.sh touches /var/lib/secubox/.kiosk-
   enabled + symlinks secubox-kiosk.service into multi-user.target.
   wants at build time, but operators reported having to run
   `secubox-kiosk-setup enable` manually on first boot. Suspected
   cause is a live-boot persistence layer that masks parts of /var,
   needs more digging. As a safety net firstboot.sh now invokes
   `secubox-kiosk-setup enable --x11` whenever it can't find the
   sentinel — idempotent, no-op on a working image, recovers a
   broken one without needing console intervention.
2026-05-24 11:09:33 +02:00
0f184a076b feat(yacy,grafana,rustdesk): apply dual-vhost MUST pattern (MODULE-GUIDELINES §4)
Third batch after lyrion/zigbee/authelia (commit 54da8a7c). Same anti-
pattern across all three: nginx `/<module>/` was proxy_pass'ing to the
LXC, which silently broke any absolute asset URL the upstream app
served. Each module gets the same surgical fix:

- nginx/<module>.conf — /<module>/ is now `alias /usr/share/secubox/
  www/<module>/` (SecuBox admin static page) instead of `proxy_pass`.
  Both /api/v1/<module>/ and /<module>/ gated by `auth_request
  /__sbx_auth_verify` so SSO covers admin access.
- <module>ctl — config_get strips TOML inline comments;
  emit_access_json now returns the REAL upstream URLs (no /<module>/
  admin suffix); PUBLIC_HOSTNAME defaults to <module>.gk2.secubox.in.
  grafanactl also gets the `""|""` -> `""|list` case fix.
- www/<module>/index.html — "native UI" button reads its href from
  /api/v1/<module>/access at runtime (single source of truth) and
  opens in a new tab.

NEW dedicated vhost files for grafana and rustdesk (yacy already had
one): packages/{grafana,rustdesk}/nginx/<module>-vhost.conf. Both
listen 0.0.0.0:9080 server_name <module>.gk2.secubox.in, Authelia-
gated, serving the upstream app at root. Operator prerequisites (DNS
+ TLS cert + HAProxy ACL + mitmproxy route inside its LXC) documented
in each vhost header.

Side note: grafana's LMS-style absolute-path problem requires
`serve_from_sub_path = false` + `root_url = https://grafana.gk2.
secubox.in/` in /etc/grafana/grafana.ini inside the LXC. This is
flagged in the changelog as a follow-up for install-lxc.sh; for now
the live LXC config is unchanged.

Live verified on gk2 — all six modules (lyrion + zigbee + authelia +
yacy + grafana + rustdesk) now return the same /access shape:
  lan      http://<lxc_ip>:<port>/         (lan-only)
  public   https://<module>.gk2.secubox.in/ (Authelia SSO)
2026-05-24 10:24:35 +02:00
675fcd482f docs: codify dual-vhost MUST for modules with a real web UI
Anchor the pattern that secubox-lyrion v1.1.0 demonstrated (admin
static page on the canonical hub vhost + real app served at the root
of its own dedicated vhost) as a REQUIRED rule across the docs:

- docs/MODULE-GUIDELINES.md §4 — new "REQUIRED: dual-vhost pattern"
  subsection with the URL table, the JS pattern for reading the
  public URL from /access, and the explicit forbidden anti-pattern
  (`proxy_pass /<module>/ → app LXC`).
- docs/MODULE-GUIDELINES.md §5 — nginx template updated: `/<module>/`
  is `alias` (static), the dedicated vhost is a separate file. The
  previous "Iframe pattern for LXC web UIs" + "LXC daemon iframe
  target" sections are flagged deprecated.
- .claude/PATTERNS.md Pattern 12 — same rule reproduced for the
  Claude-facing pattern catalog with the source-of-truth JS snippet.
- .claude/HISTORY.md + .claude/WIP.md — entry for today's three-
  module alignment (lyrion 1.1.0 + zigbee + authelia).
- wiki/Architecture.md — short reference + pointer back to
  MODULE-GUIDELINES.
- packages/secubox-lyrion/README.md — adds the URLs table at the top.
- packages/secubox-zigbee/README.md — was a verbatim copy of the
  lyrion README, rewritten as a proper zigbee README with the new
  URLs table + API surface + ports + files.
- packages/secubox-authelia/README.md — Quickstart points at
  sso.gk2.secubox.in (was auth.maegia.tv); URLs table added.

The forbidden anti-pattern catches LMS Material, Nextcloud,
Grafana, z2m and any future module whose UI uses absolute asset
URLs — incident on gk2 2026-05-24, see secubox-lyrion 1.1.0
changelog.
2026-05-24 10:06:56 +02:00
54da8a7cb1 fix(zigbee,authelia): admin buttons read access URL from API (Lyrion pattern)
Apply the source-of-truth pattern from secubox-lyrion d4adc1a3 to two
sibling admin panels:

- Zigbee /zigbee/ admin webui: "Open Zigbee Manager →" button now reads
  the public URL (or LAN fallback) from /api/v1/zigbee/access instead
  of hardcoding https://zigbee.gk2.secubox.in/. The PUBLIC_URL env var
  the FastAPI already exposed is now the only place the URL lives.

- Authelia /authelia/ admin webui: same treatment for "Open SSO Portal"
  button. Plus autheliactl gets the same fixes lyrionctl did:
    * config_get strips inline TOML comments instead of concatenating
      them into the value (HTTP_PORT used to land as "9091  # comment")
    * emit_access_json drops the /authelia/ admin suffix from URLs —
      the SSO portal itself serves at the root of the dedicated vhost,
      not under a subpath
    * lan_url uses the LXC IP + http_port (10.100.0.20:9091) instead
      of the public-facing host's address
    * PUBLIC_HOSTNAME defaults to sso.gk2.secubox.in (was empty,
      legacy auth.maegia.tv only in the toml example)
    * stray `""|""` case pattern in cmd_status changed to `""|list`
      to match the cmd_components / cmd_access conventions

- conf/authelia.toml.example: public_hostname default
  auth.maegia.tv -> sso.gk2.secubox.in.

Live verified on gk2 — all three modules return the same shape:
  lyrion   lan http://192.168.1.200:9000/  public https://lyrion.gk2.secubox.in/
  zigbee   lan http://10.100.0.111:8080/   public https://zigbee.gk2.secubox.in/
  authelia lan http://10.100.0.20:9091/    public https://sso.gk2.secubox.in/

And the admin button on each module's /XXX/ page reads from /api/v1/
XXX/access, so any future hostname/port change in the toml propagates
automatically — no more hardcoded URL drift.
2026-05-24 09:55:00 +02:00
d4adc1a3e1 fix(lyrion): access URLs point to the real LMS UI + admin button reads them from API
Three bugs found while validating the v1.1.0 admin webui rollout:

1. lyrionctl config_get concatenated inline TOML comments into the
   value. `http_port = 9000  # web admin UI + JSON-RPC` became the
   string "9000  # web admin UI + JSON-RPC", which then served as the
   port in the access URL. The Access card and the "Open Music UI"
   button both ended up pointing at
   http://192.168.1.200:9000#webadminUI+JSON-RPC/ — a fragment that
   silently took the browser nowhere. Now strips the inline comment
   and surrounding whitespace before returning the value.

2. emit_access_json built URLs with the /lyrion/ suffix (which is the
   admin path, NOT the music UI). Both LAN and public access URLs now
   point at the real LMS roots:
     lan    http://<host>:9000/        direct nginx vhost (LAN-only)
     public https://<PUBLIC_HOSTNAME>/ Authelia-gated dedicated vhost
   And PUBLIC_HOSTNAME defaults to lyrion.gk2.secubox.in if the toml
   does not override it.

3. The admin webui hardcoded the "Open Music UI" button's href to
   https://lyrion.gk2.secubox.in/. That had to stay in lock-step with
   the Access card by hand. Now the button reads the public (or LAN
   fallback) URL from the /access response — single source of truth.

Plus a stray `""|""` duplicate pattern in cmd_status's case turned into
`""|list` to match the cmd_components / cmd_access conventions.
2026-05-24 09:48:35 +02:00
b171878879 feat(lyrion): v1.1.0 — /lyrion/ becomes SecuBox admin webui, not LMS proxy
The /lyrion/ subpath on admin.gk2.secubox.in used to proxy_pass straight
to the LMS Material UI inside the LXC. That looked clean on paper but
broke every absolute-path asset the Material skin serves
(/material/customcss/mobile, /cometd/handshake, /css/..., /js/...) —
the browser dropped the /lyrion/ prefix and nginx had no location for
those bare paths, returning text/html 404s as CSS and POST 405s as
RPCs. Console errors like "couldn't load /material/customcss/mobile
because MIME type was text/html, not text/css" were the symptom.

v1.1.0 splits the two responsibilities cleanly:

  /lyrion/                       SecuBox admin webui (static alias)
  https://lyrion.gk2.secubox.in/ real LMS Material UI (root path)

Files:
  - nginx/lyrion.conf — /lyrion/ is now `alias /usr/share/secubox/www/
    lyrion/` instead of `proxy_pass http://10.100.0.100:9000/`. The
    dedicated vhost in /etc/nginx/sites-available/lyrion.conf keeps
    serving LMS at the root path where Material's absolute URLs work.
  - www/lyrion/index.html — full rewrite as a SecuBox admin panel:
    status pill, components, access endpoints, now-playing track,
    connected-players table, "Rescan library" action, prominent
    "Open Lyrion Music UI →" button to the dedicated vhost. Polls
    every 15 s. Drops the old broken iframe to /lyrion/d/...
  - api/main.py — /players, /now-playing and /rescan are no longer
    stubs. They hit LMS's JSON-RPC endpoint inside the LXC (slim.
    request) via urllib (no httpx dep). /now-playing walks all
    powered-on players for the first one in play mode.

Live verified on gk2 after the nftables firewall fix from earlier:
SQB Radio (Squeezebox Radio, MAC 00:04:20:2b:ac:ea) connected to LMS,
now-playing showing the active track.
2026-05-24 09:42:17 +02:00
3f5e7c900c fix(mitmproxy): v1.0.4 — SELF_HOSTS pre-route guard avoids the 508 user-facing page
v1.0.3 added a last-resort 508 Loop Detected response when the
X-Forwarded-For chain showed mitmproxy was bouncing through HAProxy's
default_backend in a loop. That stopped the CPU runaway but the user
saw a 508 page when they typed http://192.168.1.200/ (a literal local
IP) since that Host is not in haproxy-routes.json — mitmproxy would
forward to the IP, HAProxy on the same IP would receive and route to
mitmproxy again, repeat.

This commit catches the case earlier. The new SELF_HOSTS set
enumerates the box's listening IPs (127.0.0.1, 192.168.1.200,
192.168.255.1, 10.100.0.1, 10.55.0.1). When the Host header matches
one of those AND has no explicit route, the pre-route guard in
request() rewrites the target to 192.168.1.200:9080 (host nginx) and
leaves the Host header intact so nginx can pick a vhost. The user
now lands on the canonical hub catch-all instead of a 508 page.

The 508 path remains as the absolute last resort and now logs the
offending Host + XFF self-reference count so operators can spot any
new Host that needs to be added to haproxy-routes.json or SELF_HOSTS.

Live verified on gk2: GET http://{192.168.1.200,10.100.0.1,10.55.0.1,
192.168.255.1}/ all return 503 (nginx default vhost) instead of the
WAF's 508 loop-detected page.
2026-05-24 09:09:49 +02:00
90088a2df1 fix(led-heartbeat): v2.1.2 — postinst disables legacy bumper, pulse swings full 0-1
Two follow-ups to v2.1.1:

1. Disable the legacy secubox-led-health-bumper.service in postinst.
   That unit (/etc/systemd/system/secubox-led-health-bumper.service,
   hand-deployed on gk2 May 2026, no dpkg owner) runs a bash daemon
   that competes with the python heartbeat for the same i2c chip.
   Both writers on the same channels saturate the bus; LEDs freeze at
   the last successful value. The python heartbeat already drives all
   three LEDs (LED1 health pulse, LED2 threat, LED3 CPU). The bumper
   is redundant — disable it on every (re)install.

2. pulse_brightness() now swings 0.0-1.0 (was 0.3-1.0). At the
   brightness-30 ceiling the previous 0.3 floor only modulated the
   green channel between 9 and 25 — read as steady green to the eye.
   Going to 0 produces a textbook heartbeat blink on LED1 while
   LED2/LED3 stay held by their status colors.

(The pulse change was already committed in 99480dab; this commit
also bumps the changelog entry for it alongside the postinst fix.)
2026-05-24 08:59:40 +02:00
99480dab29 fix(led-heartbeat): pulse_brightness swings 0.0-1.0 full off-to-on, was 0.3-1.0
After capping the IS31FL319X writes at brightness 30 (v2.1.1), the
existing 0.3-1.0 sine modulation only swung the green channel between
9 and 25 — a 16-step range that read as steady green to the eye,
especially with LED2/LED3 held constant by the bash bumper.

Switching to 0.0-1.0 makes the bottom LED visibly blink off then back
to the same green as LED2/LED3 every cycle. Classic heartbeat
behaviour: three steady LEDs with the bottom one rhythmically going
dark. Observed live on gk2 after the change: LED1 green oscillates
0 -> 30 -> 0 within ~1 s per phase step.

The drop down to 0 was safe before the IS31 chip on gk2 took a beating
from the BRIGHTNESS=100 saturation incident; with the bus now stable
at the brightness-30 ceiling there is no reason to keep the minimum
at 30% — a real off-pulse is more readable than a low-brightness wobble.
2026-05-24 08:51:47 +02:00
65c64411ae fix(led-heartbeat): v2.1.1 — cap brightness at 10 to keep IS31FL319X i2c bus responsive
The IS31FL319X chip on the MOCHAbin's i2c-1 bus errors out with -EIO
above brightness ~50. Pushing 100 or 255 saturates the bus, the chip
starts NACK'ing the next register writes, the LED driver logs
'Setting an LED's brightness failed (-5)' on every retry, and the
LEDs freeze at whatever value last got through.

Observed live 2026-05-24 on gk2: all three RGBs stuck plain green at
218/255/255 with the bash bumper and the python heartbeat both pushing
high values into a saturated chip. After dropping to brightness 10
and adding a 30 ms i2c-write delay in the python set_rgb() loop,
writes succeed and the LEDs reflect the actual health state instead
of a static color.

Files:
  - usr/sbin/secubox-led          BRIGHTNESS 100 -> 10
  - usr/sbin/secubox-healthbump   ACTIVE 100 -> 30, SLEEP 20 -> 5
  - usr/sbin/secubox-led-pulse    BRIGHTNESS 100 -> 10
  - usr/sbin/secubox-led-heartbeat python set_rgb scales any 0-255
    caller down to 0-10 in write_text, then sleeps 30 ms before the
    next channel — matches the bash scripts' I2C_DELAY and lets the
    mv64xxx_i2c controller drain between calls.
  - scripts/secubox-healthbump (the duplicate script kept in repo root
    for ad-hoc dev use) gets the same BRIGHTNESS=10 cap.

The misleading 'BRIGHTNESS=100 works reliably' comment that the code
contradicted is replaced with an incident note pointing at the EIO
ceiling.
2026-05-24 08:35:22 +02:00
CyberMind
d2461cc70d
feat(sentinelle-gsm): v0.4.0 — FM RDS surface (PI/PS/RT, manual + sweep) (#376)
Adds a second SDR pipeline to the sensor: rtl_fm → redsea decoding the
57 kHz RDS subcarrier of FM broadcast. Same RTL-SDR as the GSM scanner,
shared via a unified device-claim check.

- lib/sentinelle_gsm/rds_runner.py: spawns rtl_fm | redsea at 171 kHz
  sample rate, parses redsea's JSON-per-line stdout into RdsFrame, hooks
  on_frame for persistence. Mirrors LivemonRunner's lifecycle so the
  watcher coroutine flips _done on natural exit.
- lib/sentinelle_gsm/rds_observations.py: RdsStationRegistry, UPSERT by
  PI into the shared observations.db. COALESCE in the ON CONFLICT clause
  keeps the previous PS/RT/PTY/flags when a bare-PI frame arrives.
- lib/sentinelle_gsm/rds_sweep_job.py: async-job pattern for /rds/sweep,
  walks 87.5 → 108 MHz in 200 kHz steps × 10 s dwell (~17 min). Same
  state machine + registry semantics as scan_auto_job.
- api/main.py: POST /rds/start, /rds/stop, GET /rds/status, /rds/stations,
  POST /rds/sweep, GET /rds/sweep/jobs[/{id}], POST /rds/sweep/jobs/{id}/cancel.
  _device_busy_reason() unifies the device-claim guard across GSM and RDS:
  /scan/start and /scan/auto now also reject when an RDS scan or sweep
  is in flight (and vice versa).
- www/sentinelle/: new RDS/FM panel with manual-tune controls, sweep
  launcher with live progress, stations table sorted by last_seen. JS
  polls /rds/status + /rds/sweep/jobs in the same 10 s interval as the
  GSM scan status.
- debian/rules ships /usr/libexec/secubox/secubox-redsea (built natively
  on arm64 — meson, -O2, no LTO, ninja -j 1 + nice -n 19 to keep the
  MOCHAbin from freezing during the build).
- debian/control: Architecture: all → arm64 (we now ship a native
  binary). Depends adds libliquid1 + libsndfile1 for redsea's runtime
  link set.
- tests: test_rds_runner.py (5), test_rds_observations.py (6),
  test_rds_sweep_job.py (7) — 18 new, 36 total pass.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 21:49:05 +02:00
9cc31a839c fix(boot): MOCHAbin extlinux auto-boots mpcie-fix kernel after 3s instead of waiting forever
After the 2026-05-23 reboot the board sat at the extlinux prompt
waiting for an operator key press on the serial console and only came
back online once someone reached the headless unit physically. Two
issues with the template:

1. DEFAULT pointed at `secubox-led`, a stale label the live config no
   longer carries (board has been renamed to `nftables` / `mpcie-fix`
   entries since the kernel rebuild). When extlinux can't find the
   named DEFAULT it falls back to interactive selection — which is
   indistinguishable on the serial console from a "wait forever" hang.
2. TIMEOUT 50 (5 s) was reasonable but still felt long; dropping to
   30 (3 s) makes reboots one-shot from a service-restart standpoint
   while still giving an operator at the console a window to pick the
   legacy kernel if needed.

Aligns the template with the live config (labels nftables + mpcie-fix)
so a board reinstall reproduces the working menu. DEFAULT is mpcie-fix
because that's the only kernel that supports EP06 mPCIe USB PHY +
RTL-SDR (Sentinelle GSM v0.4.0 needs both); the legacy nftables kernel
remains available as a manual fallback in the menu.
2026-05-23 21:38:17 +02:00
6150d822d2 fix(navbar): emoji icons + restore MQTT menu entry overwritten with Lyrion content
Two intertwined bugs caused the hub sidebar to show a duplicate Lyrion
row and several blank module icons:

1. packages/secubox-mqtt/menu.d/80-mqtt.json had been overwritten with
   the Lyrion JSON (subtitle, icon, id="lyrion", path="/lyrion/", all
   wrong for the MQTT package). The hub merged both entries and showed
   Lyrion twice. Restored the file to a proper MQTT broker entry —
   id="mqtt", path="/mqtt/", icon="📡", category="mesh".

2. lyrion / grafana / rustdesk / yacy / zigbee menu entries used
   FontAwesome class names (fa-music, fa-chart-line, fa-desktop,
   fa-search, fa-microchip) but the sidebar renders item.icon as a raw
   text node — there's no FontAwesome stylesheet loaded. The literal
   strings rendered as plain text, looking blank or like garbage to
   the user. Swapped each for the closest emoji (🎵 📈 🖥️ 🔍 🐝).

3. PAGE_ICONS in www/shared/sidebar.js had no entry for /sentinelle/,
   /lyrion/, /grafana/, /zigbee/, /yacy/, /rustdesk/, /mqtt/ — so the
   top page-icon badge fell back to the 📦 parcel for those routes.
   Added the seven entries.
2026-05-23 18:42:51 +02:00
630478e3ad fix(zkp): v1.0.1 — service runs as secubox:secubox, not www-data
The secubox-zkp unit was the only secubox-* service running as www-data.
/run/secubox/ is owned secubox:secubox 0755, so www-data could not
create zkp.sock — PermissionError on every uvicorn create_unix_server.
The service was crash-looping every 5s (RestartSec) and never exposing
the socket; on gk2 the restart counter passed 170 and each spin-up
briefly hit ~96% CPU.

Aligning User= / Group= with every sibling (auth, hub, ad-guard,
ai-gateway, ai-insights) lets the bind succeed.
2026-05-23 16:33:09 +02:00
f873cd28a5 fix(mitmproxy): v1.0.3 — WAF self-loop guard stops HAProxy↔mitmproxy CPU runaway
Adds an XFF self-reference check at the top of SecuBoxWaf.request(): when
the X-Forwarded-For chain already contains the LXC's own IP (10.100.0.60)
more than twice, the WAF returns 508 Loop Detected instead of opening
another upstream connection.

Observed failure mode on gk2: Pi Zero / Eye Remote (10.55.0.2) was
calling http://10.55.0.1/api/v1/eye-remote/api/system/info. Host header
'10.55.0.1' had no entry in haproxy-routes.json, so mitmproxy forwarded
to 10.55.0.1:80 (HAProxy itself) → default_backend mitmproxy_inspector
→ back into mitmproxy → loop. Each hop appended another 10.100.0.60 to
XFF; sustained ~50 hops per request kept mitmdump at 84% CPU.

The guard breaks the loop after at most 3 hops regardless of which Host
caused it — belt-and-braces in case another stray literal-IP appears.
The site-specific runtime fix (mapping 10.55.0.1 → 192.168.1.200:9080 in
haproxy-routes.json) lives on the board and is not in source: routes are
deployment-local state, not packaged.
2026-05-23 16:30:46 +02:00
CyberMind
5b7b088a4a
feat(zigbee): v2.6.0 — host-side backup + restore (closes z2m DB wipe risk) (#375)
The 2026-05-23 incident wiped database.db during a z2m crash-loop
against a down MQTT broker — network_key regenerated, 5 devices
lost, manual re-pairing required. v2.6.0 adds the safety net so
the next time something corrupts z2m state, the operator clicks
Restore in the webui instead of factory-resetting hardware.

Host-side scripts:
  - sbin/zigbee-backup: snapshots database.db, configuration.yaml,
    coordinator_backup.json, state.json to /data/backup/zigbee/
    <UTC-ts>/. Refuses on unhealthy state (z2m port closed OR
    database.db < 1 KB). Rotates to last 24.
  - sbin/zigbee-restore <ts>: stops z2m, archives current state as
    pre-restore-<now>, restores files, restarts z2m.

systemd timer: secubox-zigbee-backup.timer — OnBootSec=15min,
OnUnitActiveSec=1h, RandomizedDelaySec=5min, Persistent. Enabled
+ started by postinst.

API: GET /backups, POST /backup, POST /restore {id}. The FastAPI
calls the scripts via 'sudo -n /usr/sbin/zigbee-*' which works via
new /etc/sudoers.d/secubox-zigbee NOPASSWD grant. Required
relaxing NoNewPrivileges=true to false on secubox-zigbee.service
(attack surface still gated by nginx + Authelia + 2-script
whitelist; documented in the unit).

UI: Backups card above About with newest-first table (timestamp,
device count, DB size) + per-row Restore button (confirm dialog).
Backup now + Refresh buttons.

LXC ordering fix in install-lxc.sh:
  - lxc.start.order = 20 + lxc.start.delay = 20 — zigbee LXC starts
    AFTER mqtt and waits 20s before next container.
  - z2m systemd ExecStartPre TCP-probes 10.100.0.110:1883 for up to
    60s; refuses to start if MQTT broker unreachable. Stops the
    'crash-loop while broker down → DB corruption' chain.

Live-validated on gk2: API POST /backup → snapshot
20260523T073901Z written, listed via GET /backups, all 4 files
present on disk. Timer active.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 09:39:45 +02:00
CyberMind
652bc63707
fix(core): v1.1.5 — lxc.service waits for /data mount before autostart (#374)
Stock lxc.service has After=remote-fs.target but not After=data.mount.
/data is a local ext4 mount (UUID-based, not NFS). At boot lxc.service
ran ~2s in, lxc-autostart enumerated /data/lxc/ before the mount
landed, found nothing, exited 0. Every LXC with lxc.start.auto = 1
stayed STOPPED. Authelia being one of them broke every SSO-gated
vhost — auth_request returned 503, nginx replied HTTP 500.

Fix: systemd drop-in /etc/systemd/system/lxc.service.d/wait-for-data.conf
with RequiresMountsFor=/data. systemd resolves to the right mount unit
and orders lxc.service after it. No-op on appliances without a /data
partition.

Live-deployed on gk2 (1.1.4 → 1.1.5); After= now includes data.mount.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 09:10:35 +02:00
CyberMind
013c48f401
fix(zigbee): v2.5.8 — /access returns url field + public SSO URL (closes Zigbee access link gap) (#373)
Two bugs in /access:
1. Backend returned  but the frontend reads  → every
   entry rendered as 'lan: undefined' in the Access card.
2. Public SSO-gated vhost (https://zigbee.gk2.secubox.in/) wasn't
   listed at all — operators landed on the SecuBox-side Zigbee page
   with no actual link to the manager UI in the Access card.

Fix:
- Rename endpoint → url.
- Add public entry as first item (scope=public, auth=Authelia SSO),
  URL from SECUBOX_ZIGBEE_PUBLIC_URL env (default
  https://zigbee.gk2.secubox.in/).
- LAN web entry scope=lan, MQTT scope=lan-mqtt so distinct labels
  replace the duplicate 'lan:' lines.
- App version bump 2.4.0 → 2.5.8 (was lagging behind changelog).

Live on gk2 (2.5.7 → 2.5.8); /access returns proper JSON with url field.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 08:47:43 +02:00
CyberMind
ec85acc9e8
feat(hub): v1.4.2 — sidebar HTML pre-cache for instant render (v2.39.0) (#372)
Previous sidebar.js cached menu DATA but only used it as a FALLBACK
when the API failed. Every page transition still showed Loading...
for 200-2000ms while /api/v1/hub/public/menu responded.

v2.39.0:
  - NEW saveCachedSidebarHTML / loadCachedSidebarHTML helpers
  - loadSidebar() paints cached HTML instantly at the top
  - End of fresh render persists sidebar.innerHTML for next load
  - 1h TTL, same as the existing menu data cache
  - Best-effort: silently no-op on quota exceeded or stale-cache

Net effect: instant skeleton on every navigation, no perceived delay.
First-ever visit still hits the Loading placeholder (cache empty).

Live-deployed on gk2 (1.4.1 → 1.4.2); 12 markers shipped.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 08:45:33 +02:00
CyberMind
264e23e43f
feat(netplan): x64-vm + x64-live static IP fallback when DHCP fails (closes #370) (#371)
Both x64 profiles were DHCP-only; on a network without DHCP the appliance
had no IP at all and was unreachable.

Both interfaces now declare DHCP4 + static 192.168.1.55/24 +
gw 192.168.1.254 + DNS 1.1.1.1, 8.8.8.8. systemd-networkd accepts both:
DHCP routes get the configured low metric (100/200), the static fallback
gets metric 1000, so DHCP wins when available and the static keeps the
appliance reachable on 192.168.1.55 when DHCP times out.

Applied to all WAN candidate interfaces:
  x64-vm: enp0s3 (VBox NAT), enp1s0 (KVM), ens192 (VMware)
  x64-live: "en*" match, "eth*" match

LAN-side interfaces (enp0s8 host-only, enp2s0, ens224, br-lan) unchanged.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 08:41:03 +02:00
CyberMind
d33c61b6d5
feat(threat-analyst): v1.4.0 — actually useful (auto-collect + rich CrowdSec data) (#369)
v1.3.0 was charter-aligned but always showed zeros because the
internal DB is only populated by an explicit /collect call. v1.4.0:

  - Auto-collect on page load + every 60s
  - NEW Unique attackers + Countries stat cards
  - NEW 4-panel Top-N row: attackers / countries / patterns / targeted users
  - Recent attacks table now RICH: src IP, country code, attack pattern,
    service+target user, ASN org, hit count (flattened from CrowdSec's
    nested details.events[].meta[{key,value}])

Layout + palette still respect DESIGN-CHARTER (WALL module). Live-
deployed on gk2 (1.3.0 → 1.4.0); 9 v1.4.0 markers shipped.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 08:38:24 +02:00
CyberMind
696ee8c25e
feat(threat-analyst): v1.3.0 — full UI rewrite, aligned with DESIGN-CHARTER (#368)
Complete clean rebuild of www/threat-analyst/index.html. v1.2.x
delivered working endpoints but the UI was still cluttered (gradient
chips under every stat, two-column grid that didn't collapse cleanly,
confusing error states). v1.3.0 starts over:

  - WALL layer palette per .claude/DESIGN-CHARTER.md
    (--wall-main #9A6010 accent, threat-analyst is a WALL module)
  - Space Grotesk display+body, JetBrains Mono code, per charter
    §Typography
  - Base white theme tokens from shared /shared/design-tokens.css
    (--bg, --surface, --surface2, --border, --text, --muted)
  - Charter radii (14px cards, 6px swatches, 3px chips) + spacing
    scale (XS 4 / S 8 / M 16 / L 24 / XL 40)
  - Single-column layout, plain tables, no gradient text, no chip
    clutter
  - Each panel fetches independently and shows its own error in plain
    text. No clever wrapper hiding 401s
  - NEW diagnostic footer table tracks the latest fetch outcome per
    endpoint (OK 200 / FAIL 401 / Network …) so operators can
    distinguish auth-vs-network-vs-404 without opening DevTools
  - body.module-wall set so shared CSS that scopes by .module-wall
    inherits the right colour vars

Built clean as secubox-threat-analyst_1.3.0-1~bookworm1_all.deb;
deployed on gk2 (1.2.0 → 1.3.0); shipped HTML carries 16 matches
for charter alignment markers (--wall-main, design-tokens.css,
Space Grotesk, JetBrains Mono, module-wall).

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 08:19:13 +02:00
CyberMind
c1830e985d
feat(threat-analyst): v1.2.0 — local no-op require_jwt (align with stack pattern) (#367)
Every other SecuBox module already defines require_jwt as a local
no-op stub. threat-analyst was the lone holdout still importing the
real secubox_core.auth.require_jwt which demanded an HTTP
Authorization: Bearer header. The dashboard runs inside the
Authelia-SSO'd admin vhost where the operator only carries SSO
cookies — never a JWT in localStorage — so every /stats, /alerts,
/rules call returned 401 and the dashboard rendered "?" across
every metric.

Replace the import with a 4-line local stub. The Depends(require_jwt)
decorators stay (forward-compatible if the API is ever exposed on
TCP). The actual security perimeter remains nginx + the unix socket
at /run/secubox/threat-analyst.sock (0660 root:secubox, systemd-bound,
no TCP listener).

Live-validated on gk2: /stats now returns real JSON
({alerts_24h:0, by_source:{}, by_severity:{}, pending_rules:0,
total_rules:0}) — zeros because no data collected yet, but real
zeros not 401-induced "?".

User-authorized 2026-05-23.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 08:09:52 +02:00
CyberMind
532e5bf12b
fix(threat-analyst): v1.1.3 — no-cache meta directives to prevent stale-HTML traps (#366)
v1.1.2 fixed the CORS-credentials trap on disk, but operators still
saw stale Network unreachable because the browser cached the v1.1.1
HTML. Adding Cache-Control no-store + Pragma no-cache + Expires 0
<meta> tags so every redeploy forces a reload from this version
onward. Currently-cached clients still need a one-time hard-refresh.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 08:02:27 +02:00
CyberMind
397686bc12
fix(threat-analyst): v1.1.2 — drop credentials:'include' (CORS wildcard collision) (#365)
The user reported every panel stuck on "Network unreachable" after
v1.1.1 deployed. Root cause: v1.1.1 added `credentials: 'include'` to
every fetch() as a "defensive" measure. But the secubox nginx vhost
sends `Access-Control-Allow-Origin: *` on every /api/v1/* response,
and per the CORS spec, browsers REJECT a wildcard ACAO when the
fetch is credentialed (credentials require a specific origin, not
`*`). The fetch threw TypeError before reaching the response,
caught by our try/catch and labelled "network" — even though curl
showed the endpoints returning JSON just fine.

Fix: drop the explicit option. fetch's default `'same-origin'`
already ships Authelia cookies on same-origin requests (which is
what the threat-analyst page makes — same vhost as the API) and
doesn't trigger the strict credentialed-CORS check.

Defensive bonus: api() now checks response content-type and treats
HTML responses as auth-required (some SSO setups serve the login
challenge as a 200 with HTML body, which would otherwise throw
SyntaxError on res.json() and get mis-labelled "network").

Built clean as secubox-threat-analyst_1.1.2-1~bookworm1_all.deb.
Live-deployed on gk2.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 07:54:51 +02:00
CyberMind
9e8b9e668e
fix(threat-analyst): v1.1.1 — top padding for navbar + visible API error states (#364)
Two follow-up fixes to v1.1.0 after live test on gk2:

1. .main padding reserved for the global menu bar that sidebar.js
   (v2.38.0+) injects as a fixed 48px top navbar. v1.1.0 used
   `padding: 1.5rem` flat — content slid UNDER the navbar.
   v1.1.1 introduces `--gmb-h: 48px` and uses
   `padding: calc(var(--gmb-h) + 1.5rem) 1.5rem 1.5rem`. This
   matches the canonical pattern from sentinelle.css and all other
   recent modern dashboards.

2. api() wrapper silently returned null on any non-2xx — every
   panel stayed "Loading…" forever when /stats 401'd before the
   SSO cookie warmed up, or when endpoints returned 404, or on
   network hiccups. Rewrote to return {ok, status, data, err};
   new renderApiError() paints a meaningful placeholder per panel:
   "Auth required" / "Endpoint missing" / "Network unreachable" /
   "HTTP N". The metrics card falls back to the auth-free /status
   endpoint when /stats fails 401 so the operator still sees
   alerts_24h + pending_rules counts.

3. fetch() now sets `credentials: 'include'` explicitly so
   Authelia SSO cookies travel with every same-origin call —
   defensive against environments where the browser default
   `same-origin` policy doesn't include them.

Built clean as secubox-threat-analyst_1.1.1-1~bookworm1_all.deb.
Live-deployed on gk2; CSS padding rule confirmed shipped.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 07:45:20 +02:00
CyberMind
7d1eb8b339
feat(threat-analyst): v1.1.0 — wire metrics to real /stats + add Emancipate panel (closes #361) (#363)
v1.0.0 shipped the threat-analyst dashboard with every panel bound to
endpoints that did not exist on the backend:

  - 4 stat cards bound to /status fields that never existed
    (active_threats, iocs_tracked, analyses_24h, blocked_iocs)
  - Active Threats panel calling /threats (no such endpoint)
  - Recent Analyses table calling /analyses?limit=10 (no such endpoint)

Net result: every visible widget was stuck on "–" or "No data".

v1.1.0 rewires everything to the endpoints that DO exist (no backend
changes needed) and adds the cross-module Emancipate panel:

Metrics row — real /stats response
  - Alerts (24h)   + chip breakdown by source (CrowdSec / WAF / …)
  - Severity Mix   + chip breakdown (critical/high/medium/low, colored)
  - Pending Rules  + total-rules chip
  - Emancipated    + per-channel chips (tor / ssl / mesh)

Two-column body
  Left (2fr):
    - Active Alerts → /alerts?hours=N with 1h/6h/24h/7d selector
    - Pending Rules → /rules?status=pending with inline Approve / Apply
      / Reject buttons (hit existing /rules/{id}/approve|apply|reject)
  Right (1fr):
    - Emancipated  → /api/v1/exposure/emancipated, channel tags
      color-coded by Punk-Exposure verb (Tor purple / SSL+DNS blue /
      Mesh green), inline Revoke per row → /api/v1/exposure/revoke
    - Analyze IOC  → kept; calls /analyze (renamed payload to match
      AnalysisRequest shape the backend expects)

Header gets a Collect button (POST /collect) that pulls fresh alerts
from CrowdSec + WAF and refreshes metrics + alerts.

Layout collapses to single column under 1024px. Solid `color: var(--text)`
and per-severity pills — no gradient-text fragility (see #360).

Built clean as secubox-threat-analyst_1.1.0-1~bookworm1_all.deb.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 07:37:39 +02:00
CyberMind
9921990756
fix(secubox-hub): v1.4.1 — SOC dashboard metric numbers visible (closes #360) (#362)
www/shared/hybrid-skin.css used the "gradient text" trick on .card-metric
and .page-title:

  background: linear-gradient(...);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;

No `color:` fallback. Any render path where the gradient or
background-clip didn't take left the actual text invisible (the
-webkit-text-fill-color: transparent stays in effect even when the
gradient render fails), defaulting to the browser's untouched colour
on top of the dark panel = invisible black-on-black at /soc/.

User reported "previously corrected, regression came back" —
consistent with a live hot-fix on gk2 that got overwritten on a later
package deploy. This commit lands the fix in source so it survives
re-deploys.

Replace the trick with solid colour:
- .card-metric → color: #ffffff (the 4 big SOC numbers)
- .page-title → color: var(--text-dark) (#E8E6E0 cream)

Builds clean as secubox-hub_1.4.1-1~bookworm1_all.deb. Visual
verification on /soc/ pending gk2 reachability.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-23 07:37:36 +02:00
CyberMind
ff990ba112
docs(mochabin): EP06 mPCIe GPIO investigation runbook + hardened probe script (closes #345) (#359)
* docs(mochabin): add EP06 mPCIe GPIO investigation runbook (ref #345)

scripts/probe-mpcie-gpios.sh: empirical CP0 GPIO sweep that drives
each unrequested line HIGH for a few seconds and watches dmesg/lsusb
for new USB device. Skips [used] lines, uses libgpiod gpioset
--mode=time so the line auto-restores to input on release. Modes:
--baseline (snapshot only, safe), --line gpiochipN K (single line),
no arg (full sweep gpiochip1+gpiochip2).

docs/hardware/mochabin-mpcie-ep06-runbook.md: hypothesis, procedure,
and DTS rfkill-gpio template patterned after cn9132-clearfog.dts
lines 69-122. Apply once a candidate GPIO is identified.

--baseline smoke-tested on gk2 — sweep deferred until spare EP06
arrives (no mPCIe device in J5 = no signal regardless of which line
drives W_DISABLE#).

* docs(mochabin): drop EP06 spare assumption from runbook (ref #345)

No spare EP06 on order; the existing EP06-E will be used for the
sweep when re-seated in J5. Replaces "deferred until spare" framing
with a multimeter-scoping fallback for the inconclusive case.

* fix(mpcie): v0.2.0 probe-mpcie-gpios.sh — drop blanket sweep, danger-list, dry-run default (ref #345)

The v0.1.0 "drive every unused-input CP0 GPIO HIGH for 3s one-at-a-time"
sweep took gk2 hard-down within seconds (host became unreachable; ARP
stopped responding). gpioinfo's [used] tag only reflects lines the
kernel *requested* — several unrequested CP0 GPIOs are physically
wired to critical board functions (eth switch reset, PCIe2 PERST#,
pca9554 IRQ) and driving them HIGH wedged the board.

v0.2.0:
- No blanket-sweep mode. Removed.
- Default mode = dry-run candidate listing only. No GPIO is driven.
- --commit --line CHIP N required to actually drive a single line.
- Hardcoded DANGER_LINES (gpiochip1:1, gpiochip1:9, gpiochip1:27)
  skipped unconditionally, even with --commit.
- gpiochip2 entirely held off without --allow-chip2 (no per-line
  schematic mapping for that bank).
- Hard refusals are vocal (REFUSED: ... reason) before exit.

Runbook updated to walk through the new dry-run → --commit flow.
HISTORY documents the incident + the v0.2.0 mitigations.
Lesson saved in memory feedback_gpio_blanket_sweep_crashed_board.md.

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-22 17:50:33 +02:00
CyberMind
fe834d4fe8
feat(sentinelle-gsm): v0.3.5 + v0.3.6 — scan-auto multi-cell + async-job pattern (closes #356, #357) (#358)
* feat(sentinelle-gsm): v0.3.5 — scan-auto multi-cell orchestration (closes #356)

Port the gkerma/IMSI-catcher scan-and-livemon pattern: grgsm_scanner
discovers cells, select_diverse picks one-per-operator-then-by-power,
LivemonFleet spawns N livemons on serverport=4730+i. All emit GSMTAP
to the listener on 4729; the existing demux is by ARFCN in the GSMTAP
header, so multi-cell capture works without any listener changes.

New library code:

  lib/sentinelle_gsm/scanner.py
    - CellInfo dataclass
    - parse_scanner_output() — robust to banners/progress/partial lines
    - select_diverse() — operator-first, power-second; backs off to
      remaining strongest when fewer operators than max_cells
    - scan_band() — async subprocess wrapper around grgsm_scanner;
      always passes --args=rtl=0 (dodges the device.py auto-pick bug
      that v0.3.4's shim fixes for livemon; scanner has a separate
      code path that accepts the same arg fine)

  lib/sentinelle_gsm/livemon_fleet.py
    - LivemonFleet — N parallel LivemonRunners
    - Partial-start rollback (no leaked grgsm children if cell #2 of 3
      fails)
    - RunnerSummary keeps cell context (MCC/MNC/ARFCN) alongside the
      ScanStatus so the API can echo "which operator each runner is
      watching"

New endpoints:

  POST /scan/auto
    body: {band, max_cells, gain, ppm, samp_rate, speed, scan_timeout}
    Refuses if /scan/start single-runner OR a fleet is already alive.
    Lifecycle: stop listener (scanner hard-binds 4729) → scan_band →
    restart listener → fleet.start. Returns {mode, cells_found,
    cells_selected, runners}.

  GET /scan/auto/status
    Per-runner status of the active fleet, or empty if none.

  POST /scan/stop  (extended)
    Now stops the fleet in addition to the single runner. Payload is
    polymorphic on which path was active.

Tests: 96/96 sweep (up from 71 in v0.3.4).
  - test_scanner.py: 12 tests covering parser edge cases (banner
    noise, partial lines, positive power, empty output) and the
    diversity-selection algorithm
  - test_livemon_fleet.py: 7 tests covering happy path, partial-start
    rollback, custom base_serverport, double-start refusal
  - test_scan_api.py: 6 new endpoint tests (happy path, 409 vs
    single-runner, 409 vs second fleet, 400 on zero max_cells, 504
    on scanner timeout, /scan/auto/status idle)

.deb builds clean as secubox-sentinelle-gsm_0.3.5-1~bookworm1_all.deb.

* feat(sentinelle-gsm): v0.3.6 — async-job pattern for /scan/auto (closes #357)

v0.3.5 ran the scanner synchronously in the HTTP handler. On the
MOCHAbin's Cortex-A72 a GSM900 sweep takes 8-15 min — longer than
any sensible HTTP timeout. v0.3.6 makes /scan/auto fire-and-poll:
POST returns {id, state=pending} immediately and spawns the driver
coroutine in the background; clients poll GET /scan/auto/jobs/{id}
for state transitions.

New library code:

  lib/sentinelle_gsm/scan_auto_job.py
    - ScanAutoJob dataclass (id, state, params, cells_found/selected,
      runners, error, fleet ref, task handle)
    - JobRegistry — at most one active job, FIFO history capped at
      max_history=10 terminal jobs; newest-first listing.

New endpoints:

  POST /scan/auto                       submit + return immediately
  GET  /scan/auto/jobs/{id}             poll one job
  GET  /scan/auto/jobs?limit=N          recent jobs, newest first
  POST /scan/auto/jobs/{id}/cancel      Task.cancel + driver unwind

State machine:

  pending → scanning → starting_fleet → running → stopped
                                    ↘ failed
                         ↘ failed
                         ↘ cancelled

Driver coroutine cleanup on every exit path: listener restarted (or
left stopped if the scan failed and there's no fleet to feed it),
single runner ensured idle, fleet stopped on cancel/failure.

/scan/start also 409s now if a scan-auto job is in flight even before
it has spawned the fleet (state=scanning has no _fleet yet, but
starting a single-runner would stomp on the scanner's 4729 bind).
/scan/stop marks the active job as `stopped` when it tears the
fleet down.

Tests: 114/114 sweep (up from 96 in v0.3.5).
  - test_scan_auto_job.py (NEW, 12 tests): registry FIFO eviction +
    driver state machine in isolation (Starlette TestClient cancels
    spawned tasks at request-scope exit, so cross-request task tests
    live here, not in test_scan_api.py).
  - test_scan_api.py refactored: in-flight tests seed the registry
    directly rather than rely on a real spawned task surviving
    across client.post() calls.

.deb builds clean as secubox-sentinelle-gsm_0.3.6-1~bookworm1_all.deb.

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-22 17:49:13 +02:00
CyberMind
604099f7bd
fix(sentinelle-gsm): v0.3.4 — gr-gsm shim + AF_NETLINK + serverport+1 (closes #353, #354) (#355)
* fix(sentinelle-gsm): v0.3.3 — drop --args=rtl=0 default (IMSI-catcher pattern) (ref #353)

gkerma/IMSI-catcher's scan-and-livemon proves the canonical livemon
invocation is bare -f FREQ (+ optional --serverport). gr-osmosdr's
livemon code path rejects --args=rtl=0 with "Wrong rtlsdr device
index given" — different from grgsm_scanner, which accepts the
same syntax. v0.3.2's default of "rtl=0" tripped this on every
/scan/start; v0.3.3 makes the flag opt-in only and gr-osmosdr
auto-picks the first RTL-SDR.

Also adds serverport kwarg → passes --serverport=N through, enabling
the multi-cell capture pattern from scan-and-livemon (N runners on
4730/4731/… while the listener demuxes).

Tests: rename test_start_spawns_with_rtl_args →
test_start_default_no_args_flag (inverted assertion: no --args=*
token). Add test_start_serverport_kwarg + _no_serverport_omits_flag.
70/70 sentinelle-gsm sweep green; .deb builds clean as
secubox-sentinelle-gsm_0.3.3-1~bookworm1_all.deb.

Multi-cell orchestration (grgsm_scanner discovery + N-livemon spawn
+ /scan/auto endpoint) is the natural follow-up and will land
separately.

* fix(sentinelle-gsm): v0.3.4 — gr-gsm device.py monkey-patch shim (closes #354)

Every grgsm_livemon_headless invocation since v0.3.0 has been crashing
inside gr-gsm's own Python code. Two different error messages
(v0.3.2's "Wrong rtlsdr device index given", v0.3.3's libusb_init
AssertionError after UHD/bladeRF probes) were both downstream of:

  File "/usr/lib/python3/dist-packages/gnuradio/gsm/device.py",
       line 33, in match
    if (k not in dev or dev[k] != v):
        ^^^^^^^^^^^^
  TypeError: argument of type 'osmosdr.osmosdr_python.device_t'
             is not iterable

gr-gsm 1.0.0 (Debian bookworm package) treats osmosdr.device_t as a
Python dict (k in dev, dev[k]); libgnuradio-osmosdr 0.2.4 ships
device_t as opaque (only to_string() works). The two Debian packages
have incompatible Python APIs.

Fix: ship a small Python shim wrapper that monkey-patches
gnuradio.gsm.device.match() before runpy.run_path()'ing
grgsm_livemon_headless. Patched match() parses dev.to_string()
("key=value,key=value") into a real dict, then runs the original
matching logic. No dpkg-divert, no system-file modification, no
upstream patch needed.

LivemonRunner prefers /usr/libexec/secubox/secubox-grgsm-livemon-shim
when present, falls back to /usr/bin/grgsm_livemon_headless. Shim
CLI is identical (runpy passes argv through).

Validated on gk2 (kernel 6.12.85, gr-gsm 1.0.0~20220727-1+b3,
libgnuradio-osmosdr0.2.0 0.2.4-1):
  gr-osmosdr 0.2.0.0 (0.2.0) gnuradio 3.10.5.1
  Using device #0 Realtek RTL2838UHIDIR SN: 00000001
  Found Rafael Micro R820T tuner
  Exact sample rate is: 2000000.052982 Hz
  Allocating 15 zero-copy buffers

70/70 test sweep green; .deb builds clean as
secubox-sentinelle-gsm_0.3.4-1~bookworm1_all.deb.

* fix(sentinelle-gsm): v0.3.4 follow-ups — AF_NETLINK + serverport+1 (closes #354)

Two more bugs surfaced when v0.3.4's shim let livemon actually run end-
to-end on gk2:

1. systemd unit's RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
   blocked libusb 1.0 / libudev from subscribing to kernel uevents over
   netlink. Result: libusb_init() aborted before reaching the RTL-SDR
   backend. Proved by elimination — relaxing MDWE + SystemCallFilter
   via a transient drop-in did NOT unblock scans. Added AF_NETLINK to
   the family list. Every other CSPN-relevant restriction intact.

2. grgsm BINDS its --serverport (not connects to it) via
   network.socket_pdu('UDP_SERVER', ...) at line 71 of
   grgsm_livemon_headless. Its built-in default of 4729 collides with
   our GSMTAP listener and crashes the child with `RuntimeError: bind:
   Address already in use`. The IMSI-catcher scan-and-livemon project
   dodges this the same way (serverport starts at 4730). LivemonRunner
   now defaults to gsmtap_port+1, callers can still override.

Tests: replace test_start_no_serverport_omits_flag with
test_start_default_serverport_is_gsmtap_plus_one +
test_start_default_serverport_follows_custom_gsmtap_port. 71/71 sweep.

Validated on gk2: 60s sustained scan @925.4M, never died. stderr shows
`Using device #0 Realtek RTL2838UHIDIR SN: 00000001` + `Allocating 15
zero-copy buffers` + `OOO` (overruns = data flowing into decoder).

After v0.3.0 ↔ v0.3.3 wrestling with what looked like a gr-osmosdr arg
mystery, this is actually a stack of three independent bugs:
  - gr-gsm 1.0.0 device.py incompatible with osmosdr 0.2.4 (shim)
  - systemd sandbox blocks netlink (AF_NETLINK)
  - grgsm default serverport collides with listener (+1)

All three fixed in this v0.3.4.

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-22 17:08:36 +02:00
CyberMind
a4b3e0c8be
fix(sentinelle-gsm): v0.3.2 — LivemonRunner watcher + scan tuning params (closes #351) (#352)
Bug 1 (v0.3.1 regression caught live on gk2): when grgsm_livemon_headless
crashes mid-run, LivemonRunner.status() reported running=True forever
because _proc.returncode was never read (no `await proc.wait()`
watcher). /scan/start kept returning 409 until service restart.

Fix: spawn _watcher_task = asyncio.create_task(_watch_exit()) in
start() that awaits proc.wait() and sets _done=True on natural exit.
status().running is now `_proc is not None and not self._done` — flips
to False as soon as the child crashes, without anyone calling stop().

Bug 2: gr-osmosdr params couldn't be tuned without redeploying.

Fix: start() gains optional gain/samp_rate/ppm/args kwargs, passed as
-g/-s/-p/--args= flags. POST /scan/start exposes them in the body.
Defaults None → identical to v0.3.1 behavior. Operators can now
experiment with `args="numchan=1,rtl=0"` etc. while we figure out
why livemon rejects what scanner accepts (deferred to v0.3.3).

stop() is now idempotent: skips SIGTERM if the child already exited,
still emits clean ScanStatus with stderr_tail.

9 livemon_runner tests (5 refactored to use _make_fake_proc helper +
4 new: watcher, tuning args, custom args override, restart after
natural exit). Full sweep 86/86 passing.

Closes #351. Refs #349 #344.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
2026-05-22 14:59:27 +02:00
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