mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 13:59:40 +00:00
da09fa6987
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. |
|||
| 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. |
|||
| 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.
|
|||
| 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. |
|||
| 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" -> "📜" |
|||
| 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
|
|||
| 0f184a076b |
feat(yacy,grafana,rustdesk): apply dual-vhost MUST pattern (MODULE-GUIDELINES §4)
Third batch after lyrion/zigbee/authelia (commit
|
|||
| 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. |
|||
| 54da8a7cb1 |
fix(zigbee,authelia): admin buttons read access URL from API (Lyrion pattern)
Apply the source-of-truth pattern from secubox-lyrion
|
|||
| 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. |
|||
| 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. |
|||
| 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. |
|||
| 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.) |
|||
| 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. |
|||
| 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.
|
|||
|
|
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>
|
||
| 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. |
|||
| 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. |
|||
| 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. |
|||
| 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. |
|||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |