Commit Graph

2943 Commits

Author SHA1 Message Date
cf1c220134 fix(mediaflow,rpi400): postinst #DEBHELPER# comment bug + kiosk conffile prompts
Two failures surfaced by the strict apt-get pass that --kiosk runs in the
rpi chroot (the lenient `dpkg -i --force-depends || true` had been swallowing
the first one on every build):

1. secubox-mediaflow postinst had `#DEBHELPER#` inside a comment. dh_installsystemd
   substitutes that token textually wherever it appears, so it expanded the
   systemd block mid-comment and orphaned "; kick one refresh" onto its own
   line → `syntax error near ';'` → configure fails on EVERY install. Reword
   the comment (real token stays alone on line 22). Bump to ~bookworm3.

2. The kiosk apt-get relied on DEBIAN_FRONTEND=noninteractive, which governs
   debconf, not dpkg's conffile prompt — a pre-existing /etc/secubox/mesh.toml
   triggered an interactive prompt that EOFs on the closed chroot stdin and
   aborted the build. Pass --force-confdef/--force-confold (as the main
   dpkg --configure pass already does) to auto-keep existing conffiles.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-22 13:11:45 +02:00
feda7182ed fix(rpi400): ship auth runtime deps + wire --kiosk through CI
secubox-auth/users crash at import on the rpi400 image because it is built
with `dpkg -i --force-depends` + debootstrap --include, so their declared
Depends (python3-argon2, python3-pyotp, python3-qrcode) are never pulled —
unlike normal apt installs on mochabin. The engine does `from argon2 import
...` / `import pyotp` / `import qrcode`, so the daemon dies at startup →
nginx 502 → no login. Add pyotp+qrcode (pure-Python) to INCLUDE_PKGS and
argon2 via the QEMU-proven pip step (mirroring the cryptography exclusion).

Also expose the kiosk GUI through CI: a `kiosk` workflow_dispatch boolean
→ build-image.sh --kiosk → forwarded to build-rpi-usb.sh, so a rebuilt
rpi400 image can ship the chromium fullscreen dashboard.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-22 11:53:42 +02:00
db0ebe1675 fix(meshtastic): ExecStartPre clears stale socket so the daemon survives restart (ref #897)
Some checks are pending
License Headers / check (push) Waiting to run
uvicorn/uvloop refuses to rebind an existing UDS ('Address already in use') →
crash-loop on restart. ExecStartPre=+/bin/rm -f the socket (root, clears any
owner in the sticky /run/secubox) before ExecStart.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-22 10:46:56 +02:00
536bcd1c0f fix(meshtastic): postinst must NOT chown shared parents (broke auth OTP + exposed secrets) (ref #897)
install -d -o root -g root chowned /etc/secubox to root:root (auth/users engine
= secubox lost write → OTP login 500/JSON.parse error) and set
/etc/secubox/secrets to 0755 (secrets exposure). chmod-only on existing shared
parents, never chown; secrets stays 0700.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-22 10:43:22 +02:00
66366537df fix(meshtastic): daemon survives an unreachable MQTT broker at startup (ref #897)
Deploy found a crash-loop: the shipped default config has [shared_grid] set, but
the opt-in mosquitto is not running, so Bridge.start()/the paho adapter's
synchronous connect() raised ConnectionRefusedError and killed the daemon.
Bridge.start() now skips an unreachable broker (log + continue); the paho adapter
uses connect_async + loop_start (non-blocking, auto-reconnect). +regression test.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-22 10:36:42 +02:00
CyberMind
4cdae8d1eb
Merge secubox-meshtastic multi-grid LoRa node + passive listener (#898, refs #897)
secubox-meshtastic — multi-grid LoRa node + passive listener (mock-driven, hardware-gated #897)
2026-07-22 10:31:32 +02:00
dc145db9c3 fix(meshtastic): final review — panel webui→ctl wiring (NNP+sudoers user) + mosquitto Recommends + minors (ref #897)
Reconcile the three pieces the webui→ctl delegation needs to actually work:
NoNewPrivileges=yes on the daemon unit neutralized sudo, and sudoers.d
granted to the wrong user (secubox instead of secubox-meshtastic, the
daemon's actual User=). Verified _ctl_cb's systemd-run argv matches the
sudoers command lines for set-mode/set-grid token-for-token.

Also demote mosquitto from Depends to Recommends (module never installs/
runs it; README already documents opt-in setup) and add sudo to Depends
for the ctl delegation. Minor cleanups: unused `field` import in model.py,
dead gridpolicy re-export in daemon.py, and a malformed broker port
(e.g. host:abc) no longer crashes Bridge.start() at daemon startup.

Deferred: append a note to the multigrid design spec about the
gridpolicy/bridge default broker-port mismatch (8883 vs 1883) and the
nft hostname-resolved-once caveat for future on-grid containment work.
2026-07-22 09:02:55 +02:00
72187424d4 feat(meshtastic): debian packaging + README (ref #897)
Finalizes the secubox-meshtastic package for install: debian/install
(api -> usr/lib/secubox/meshtastic/api, sbin -> usr/sbin, www, nginx
route, menu entry, config example, mosquitto broker reference conf),
debian/postinst (secubox-meshtastic system user in dialout, restricted
leaf dirs under 0755 shared parents per #511/#474, config seed-if-absent,
enable+start the daemon which runs fine with radio: absent), debian/prerm,
debian/rules (0440 sudoers drop-in + dh_installsystemd --name=), and
debian/control deps (fastapi/uvicorn/pydantic/paho-mqtt/mosquitto,
secubox-core). Adds README documenting the API, the three grids, passive
mode, and the meshtastic pip (non-Debian) dependency note.

Clean dpkg-buildpackage build verified (secubox-meshtastic_0.1.0-1~bookworm1_all.deb),
60/60 tests pass, SPDX headers clean.
2026-07-22 08:50:56 +02:00
340bb6a650 feat(meshtastic): systemd + nginx + menu + private broker (ref #897)
- systemd/secubox-meshtasticd.service (+ debian/ copy): dedicated
  secubox-meshtastic user/group, dialout for serial, ProtectSystem=strict
  hardening, DeviceAllow for ttyUSB/ttyACM. No RuntimeDirectory=secubox
  (ref #896 landmine) — daemon binds its socket directly into the shared
  1777 /run/secubox.
- nginx/meshtastic.conf: static panel + dedicated-socket API proxy,
  mirrors secubox-profiles' prefix-preserving proxy_pass convention.
- menu.d/71-meshtastic.json: panel entry under the mesh category.
- conf/mosquitto-secubox-meshtastic.conf: private shared_grid broker
  listener bound to the MirrorNet IP (10.10.0.1), anonymous access
  disabled, creds/ACL left to the operator.
2026-07-22 08:40:31 +02:00
6d5139ec7b feat(meshtastic): webui panel (ref #897)
Add www/meshtastic/index.html — cyan hybrid-dark panel (Courier Prime w/
monospace fallback, no external CDN/fonts/tiles) with 5 tabs: Nodes (list +
offline canvas map plotting positioned nodes, ringed/colored by SNR), Messages
(per-channel view + send via POST /send), Channels (per-channel grid
off/shared/on toggle via POST /grid), Sniffer (census table + channel-activity
bars from /packets), and Grid (radio present/absent banner + mode selector via
POST /mode). Reads localStorage.sbx_token, shows a persistent auth-required
banner on 401, and a radio-absent banner throughout.
2026-07-22 08:32:35 +02:00
760c28eab5 fix(meshtastic): wire daemon main() to web.create_app(cache,send_cb,ctl_cb) (ref #897) 2026-07-22 08:25:37 +02:00
c9ab0fa4ad feat(meshtastic): FastAPI webui backend (ref #897)
create_app(cache, send_cb, ctl_cb) exposes GET status/nodes/messages/packets
over the injected StateCache, and POST send/mode/grid — mode and grid are
validated against config.MODES/GRIDS (422) BEFORE delegating to ctl_cb,
mirroring secubox-profiles' validate-before-delegate pattern. All routes
require the real secubox_core.auth.require_jwt.
2026-07-22 08:17:45 +02:00
16fb771d7e fix(meshtastic): task-9 review — egress drop-in to /etc/nftables.d + accurate comments (ref #897)
Fixes:
1. Change EGRESS_DROPIN path to /etc/nftables.d/ (matches actual repo structure)
2. Update docstring + _render_egress comments: soften overstate default-drop claim
   - These are allow-list add-ons (policy accept), not default-drop enforcers
   - Fail-safe: empty ruleset never OPENS egress without enabled broker
3. Fix sudoers comment: grid has manual per-token validation, not choices=
4. Add control-char escaping to _toml_str (\n, \r, \t) for valid TOML output

All existing tests still pass (14/14). visudo validation: réussi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 08:13:52 +02:00
75c94abcd2 feat(meshtastic): secubox-meshtasticctl + sudoers (ref #897)
Root-only webui->ctl CLI for /etc/secubox/meshtastic.toml, mirroring
secubox-profiles' cli.py/actuate_paths.py conventions: set-mode/set-region/
set-role/set-grid/set-psk edit the config via a small TOML dump/validate/
atomic-swap (double-buffer/4R), apply-egress renders gridpolicy's nft rules
into an nftables drop-in (empty rule list keeps DEFAULT DROP). Each verb
requires root and appends a JSON audit line. Scoped systemd-run-wrapped
sudoers grants let the panel (User=secubox) invoke each verb without a
broader sudo surface.
2026-07-22 08:02:29 +02:00
c72129bd69 fix(meshtastic): _SerialRadio.on dispatches per-topic payload (ref #897)
Carried fix from Task 4 review: the single lambda used for all three pubsub
topics forwarded `packet` for every event, but meshtastic publishes `node=`
on meshtastic.node.updated (no `packet` kwarg) and neither on
meshtastic.connection.established. Deliver the correct kwarg per topic
(packet/node/interface). Untested (no real device), no new test required
per task brief.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:51:51 +02:00
9d8bf30efc feat(meshtastic): daemon engine + launcher (ref #897)
Implement Task 8: Engine class wiring radio → state → cache/passive/bridge
on packet receive, plus main() building real objects (config.load, open_serial,
paho-mqtt-backed Bridge factory imported lazily, PassiveCapture, StateCache).
Radio-absent and api.web-absent (Task 10 not yet done) paths degrade gracefully
instead of crashing. sbin/secubox-meshtasticd launches `python3 -m api.daemon`.
Tests drive Engine with MockRadio + FakeMqtt, no real device/broker. 33/33 tests
pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:51:45 +02:00
c2acedc80d feat(meshtastic): host-side MQTT bridge
Implement Bridge class for serial↔MQTT bridging with grid-policy-driven
publishing. Tests verify correct broker connection and topic filtering per
channel grid configuration. All 30 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:45:00 +02:00
449a4c9398 feat(meshtastic): passive capture pipeline
Implement Task 6: PassiveCapture with packet recording (JSON lines),
node census tracking, and per-channel statistics. Payload withheld
when decrypted=False; included when decrypted=True.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-22 07:41:36 +02:00
bda0e1280e feat(meshtastic): grid policy + nft egress allow-list
- Implement targets_for(channel, cfg) to return grid membership subset
- Implement nft_egress_rules(cfg) to generate allow-rules for enabled on-grid brokers
- TDD: 5 test cases all passing (offgrid, shared+on, on-disabled, empty rules, broker rules)
- Full test suite: 23 passed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:37:56 +02:00
bd2f9ac48c feat(meshtastic): RadioInterface + MockRadio + lazy serial
Implements Task 4: RadioInterface protocol with MockRadio test double and
SerialRadio wrapper. Lazy meshtastic import in open_serial() ensures test
suite never requires the library. Returns None when device absent (radio: absent
path). All tests pass; full suite clean (18/18).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:32:36 +02:00
e9d4559410 fix(meshtastic): task-3 review — deepcopy cache isolation + lock write + header (ref #897)
- Use copy.deepcopy in StateCache.get() and update() to prevent nested-mutable sharing
- Move _write_atomic() call inside lock in update() for atomicity
- Add copyright line to tests/test_cache.py header
- Add test_get_returns_deep_copy_not_live_reference() to verify isolation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:30:30 +02:00
5477ad5fc8 feat(meshtastic): StateCache double-cache (in-mem + state.json + bg thread)
Add api/cache.py with StateCache class supporting:
- update(state_dict) for in-memory + atomic file write
- get() returning warm cache, file fallback, or {"radio": "absent"}
- start_refresh(producer, interval, stop) spawning lint-recognized
  threading.Thread(target=self._refresh_loop, ...) background refresher

Add tests/test_cache.py covering roundtrip persistence, cold read, missing
file fallback, and refresh thread lifecycle. All 14 suite tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:25:29 +02:00
8adb9cd3f0 feat(meshtastic): mesh state model + packet parser
TDD implementation: Packet dataclass with parse_packet() parser,
Node dataclass for mesh participants, MeshState with apply_packet()
and apply_nodeinfo() to build census and channel message logs.
Parser consumes meshtastic pubsub dict format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:21:39 +02:00
0cc74b7be9 fix(meshtastic): task-1 review — debhelper Build-Depends + config KeyError guards (ref #897)
Changes:
1. debian/control: Add Build-Depends: debhelper-compat (= 13) and Rules-Requires-Root: no
2. debian/compat: Removed (now managed via Build-Depends)
3. api/config.py: Guard ch["name"] and sec["broker"] lookups against KeyError
4. tests/test_config.py: Add test_rejects_channel_without_name and test_rejects_broker_section_without_broker

All 6 tests pass (4 existing + 2 new). dpkg-checkbuilddeps reports no issues.
2026-07-22 07:19:55 +02:00
b89c79c0cc feat(meshtastic): package scaffold + config loader (ref #897)
Task 1 complete:
- Package scaffold with proper directory structure
- Config loader using tomllib with dataclass-based interface
- Comprehensive test coverage (4 tests, all passing)
- Example TOML configuration with sensible defaults
- Debian packaging (control, compat=13, changelog, rules)
- Full SPDX header compliance

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:14:12 +02:00
4cf0178887 docs(meshtastic): implementation plan — 13 tasks, mock-driven (ref #897)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-22 06:19:00 +02:00
4b986f5c23 docs(meshtastic): spec — multi-grid LoRa node + passive listener (ref #897)
secubox-meshtastic: USB Meshtastic node, native-host daemon, three composable
grids (off-grid RF / private shared-grid over MirrorNet / opt-in public MQTT,
host-side bridged) + optional passive CLIENT_MUTE listener feeding the SOC.
Hardware procurement in #897.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-22 05:19:48 +02:00
github-actions[bot]
c4de548b00 ci: Update metrics [skip ci] 2026-07-21 05:42:09 +00:00
03b4e3ff72 fix(ci): release.yml declares workflow_call so sync-all.yml parses
sync-all.yml's release job does `uses: ./.github/workflows/release.yml`, but
release.yml only declared push/workflow_dispatch triggers — a reusable-workflow
reference to a workflow without `on: workflow_call:` makes the CALLER fail at
startup ('workflow file issue'), which is why every sync-all run was red at 0s.
Add a bare workflow_call trigger (tag-push + dispatch paths unchanged; the
release job is tags-guarded and sync-all runs on branch pushes, so the callable
path is not exercised — this only restores a valid reference).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-21 07:40:48 +02:00
68a3e78673 fix(ci): cache-lint recognizes threading.Thread background-refresh idiom
secubox-frigate follows the double-cache pattern (daemon refresh thread +
in-memory _cache guard + file cache + compute fallback) but used a sync handler
+ threading.Thread(target=refresh_cache) instead of asyncio.create_task — which
the lint's has_bg_refresh detection didn't recognize → false-positive
[no-cache-signals] → Dashboard Cache Lint red. Detect threading.Thread(target=
refresh_*)/Thread(target=refresh_*) alongside asyncio.create_task. +self-test.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-21 07:38:39 +02:00
bd8e2e382e feat(waf-ng): reconcile to a single HARDENED non-root unit; retire worker@ fan-out (ref #896)
Some checks failed
License Headers / check (push) Has been cancelled
The board ran a hand-created unhardened ROOT sbxwaf on :8085 while the package
shipped only a worker@ fan-out that crash-looped (panic on the root-only
cookie-audit log) and, as secubox-waf + RuntimeDirectory=secubox, re-chowned the
shared /run/secubox on every crash-restart — breaking every secubox-user socket
bind (profiles 502s). Ship ONE hardened unit (secubox-waf-ng.service, :8085,
User=secubox-waf, full sandbox, NO RuntimeDirectory — sbxwaf only connects to
waker.sock). postinst asserts the perms a non-root sbxwaf needs
(haproxy-routes.json 0644, cookie-audit ledger writable by secubox-waf),
disables leftover worker@ units, drops the /etc override. Validated live on gk2:
264 routes, real routing, admin/gitea/billets/yacy 200; a WAF restart no longer
touches /run/secubox and profiles stays up.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-21 07:28:46 +02:00
7b8fd5f1e3 fix(profiles): secubox-sleeper ships DISABLED (pilot; never auto-sleep crowdsec) (ref #896)
The postinst enabled+started the sleeper, which stops idle sleepable modules —
and health-sync currently lists crowdsec among sleepable. Ship it disabled
(--no-enable --no-start; postinst try-restart only, never enable). Operator
opts in after auditing the sleepable set. Waker stays enabled (never stops).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-21 07:06:53 +02:00
e91f2aa252 Merge origin/master (#894 merge commit) into scale-to-zero release 2026-07-21 07:02:34 +02:00
c97805548f Merge scale-to-zero for public services + two-phase wake UX (#896, #893)
Sleep-when-idle + wake-on-access for public on-demand services, per-module
lifecycle policy, durable route restoration, awake-level panel setter, and a
two-phase terminal wake splash (waker + nginx error_page) generalized to all
on-demand vhosts. Includes #893 profiles-actuation robustness.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-21 07:01:48 +02:00
23735e118c release(profiles): 0.10.0 — terminal wake splash + nginx-sync phase-2 + tracking (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-21 07:01:35 +02:00
9383d1f5c6 fix(profiles): per-block nginx-sync idempotency + document server-level scope (ref #896)
Review follow-ups: wire each on-demand server block in a multi-block file (was
file-wide marker → skipped 2nd+ block); document that the snippet's server-level
directives cover co-located proxy locations (single-app vhost trade-off).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-21 06:58:26 +02:00
a0f8e23659 feat(profiles): generalize phase-2 wake splash to all on-demand vhosts (nginx-sync) (ref #896)
Repurpose the (dead, Task-7) nginx-sync into a transactional injector that wires
the phase-2 splash into EVERY on-demand vhost, not just yacy:
- nginx/secubox-waking.conf is now fully self-contained at server level
  (proxy_intercept_errors + proxy_connect_timeout 3s + error_page → splash), so
  per-vhost wiring collapses to a single `include snippets/secubox-waking.conf;`.
- nginxgen.py rewritten: find_config (by server_name), wire/unwire (idempotent,
  marker-based, insert after the domain's server_name line — right block in
  multi-server files), and sync_and_reload (wire every on-demand vhost via
  wafsync.ondemand_vhosts, nginx -t, rollback from in-memory originals on
  failure, reload on success — NO .bak in the nginx dir).
- `secubox-wakectl nginx-sync` (root) runs it; `set-lifecycle` runs it after the
  waf/health resync (best-effort — opting a service on-demand auto-wires its
  splash); postinst runs it so every install/upgrade re-asserts the wiring.

Live on gk2: one nginx-sync wired 5 on-demand vhosts (yacy/gitea/nextcloud/
podcaster/billets), reported streamlit as having no nginx config, idempotent on
re-run; yacy down → nginx serves the 503 splash in 3.0s. 286 tests pass.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-21 06:50:48 +02:00
28b7c96ecc feat(profiles): terminal wake splash + nginx phase-2 error_page handling (ref #896)
Two-phase graceful wake UX, one shared splash page:
- templates/waking.html reworked into a pseudo-terminal "virtual screen": a CRT
  panel with scanlines, an auto-executing boot log, blinking cursor, elapsed
  counter, and a "taking longer / repair" panel after ~90s. Fully self-contained,
  JS-driven (service name from the vhost hostname, elapsed from sessionStorage),
  auto-refreshing — so it needs no server-side substitution and works verbatim
  for BOTH phases.
- waker (phase 1, container down / route absent): serves the page verbatim.
- nginx (phase 2, container up but app still booting → backend 502/503/504):
  new nginx/secubox-waking.conf snippet intercepts the gateway error and serves
  the same splash (503 + Retry-After) instead of a raw 502. Wire per on-demand
  vhost with `include snippets/secubox-waking.conf;` + `proxy_intercept_errors on;`
  + `proxy_connect_timeout 3s;` (fail fast so the splash beats HAProxy/sbxwaf).
- debian/install ships the page to /usr/share/secubox/www/waking/ and the snippet
  to /etc/nginx/snippets/.

Proven live on yacy: full path (HAProxy→sbxwaf→nginx) serves the terminal splash
during the down/boot window, then the real app once it answers. 279 tests pass.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-21 06:32:52 +02:00
224f79d7ec release(profiles): 0.9.0 — awake-level setter (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 19:20:28 +02:00
6e4591e397 fix(profiles): refuse lifecycle change on protected modules + fsync manifest (ref #896)
Review follow-ups for the awake-level setter:
- reject a protected module server-side (web 409) and in the ctl (refused,
  reason=protected) — it is forced always-on anyway, so the write is spurious
  and would dirty a core manifest; mirrors set_pin's protected refusal.
- fsync manifest_edit's atomic write before rename (the manifest is source of
  truth, not a regenerable derived file) — parity with web._atomic_write.
- tests: protected refusal (web + cli), duplicate-same-key collapse, manifest
  with no trailing newline.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 19:07:18 +02:00
c1970decc9 feat(profiles): set the awake level (lifecycle + wake_class) from the panel (ref #896)
The scale-to-zero policy (lifecycle always-on/eager/on-demand/manual +
wake_class normal/urgent) was manifest-only. Add a per-module selector to the
/profiles/ panel that writes the manifest and resyncs the derived files, via the
webui->ctl pattern:

- api/manifest_edit.py: targeted line-edit of a manifest's lifecycle/wake_class,
  preserving everything else (comments, inline portal table, …); refuses a
  sectioned manifest; atomic write.
- api/cli.py: `secubox-profilectl set-lifecycle <mod> --lifecycle X --wake-class Y`
  (root) — edits the manifest then re-runs waf-sync + health-sync so the change
  takes effect; resync paths derive from --root (test-isolated). argparse choices=
  hard-gate the enums; refuses an unknown module.
- api/web.py: POST /api/v1/profiles/lifecycle — validates enum (422) + known
  module (404) BEFORE any sudo, then delegates to the root ctl via systemd-run.
- sudoers.d: one scoped grant (three bounded wildcards, panel pre-validates +
  ctl re-validates — execve, no shell).
- www/profiles/index.html: two compact <select>s per module (protected → 🔒),
  POSTing /lifecycle and refreshing.

16 new tests (manifest_edit, cli set-lifecycle, web route); 275 pass, no
live-state leakage, sudoers + JS validated.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 19:01:04 +02:00
5f86544125 docs(profiles): note remember_path as a hardcoded test-isolation path (ref #896)
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 18:39:29 +02:00
9edf69a158 feat(profiles): durable portal-route memory so wake restores the WAF route (ref #896)
The scale-to-zero pilot proved the wake-trigger chain but found the round-trip
broken: a woken portal module started (container up, backend serving) yet stayed
unreachable via its vhost because its sbxwaf route was never re-added. wake.py
passed routes={} to apply_plan, and by wake time the route is long gone from the
live haproxy-routes.json (removed at sleep) and out of the rotated 4R snapshot.

Fix — a durable per-domain route memory (portal_routes.remember/recall,
/var/lib/secubox/profiles/portal-routes.json): the actuator persists the live
route value just before removing it on STOP; wake recalls it and passes it as the
routes map, so the existing snapshot->START->_portal_add path restores it. No
change to snapshot/apply/_portal_add semantics. Best-effort memory (a failed
write never breaks a STOP). remember_path_for(root) mirrors snap_root_for for
test isolation.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 18:34:33 +02:00
356f8293a8 fix(profiles): default lifecycle is always-on, not eager (ref #896)
On a 184-module fleet, DEFAULT_LIFECYCLE="eager" made every module with
no manifest opinion idle-sleep-eligible, including core services (admin,
gitea, nextcloud) that never opted into scale-to-zero. Flip the default
to always-on: sleep is now a strict opt-in via lifecycle="eager" or
"on-demand" declared explicitly in the manifest. scan()-derived
manifests already relied on this same default (no code change needed
there), so they inherit the safer behavior too.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 18:11:24 +02:00
ed45608e0c fix(profiles,waf-ng): final review fixes for scale-to-zero pilot (ref #896)
Three findings from the pre-pilot review:

- waf-sync/health-sync _write_atomic left the produced file 0600 root-owned
  (tempfile.mkstemp default) with no chmod before the rename. sbxwaf (user
  secubox-waf) and secubox-hub (user secubox) could never read their own
  public on-demand-vhosts.json / sleepable-modules.json — the wake trigger
  and health-distinction were silently inert on install. Chmod 0644 before
  os.replace in both helpers.

- The two sbxwaf workers (@1, @2) each hold independent in-memory
  Begin/End vhost state but flushed to the SAME --vhost-signals path,
  last-writer-wins clobbering each other every ~5s — a service kept fresh
  by one worker could be read as stale by the sleeper and wrongly STOPped.
  Unit now passes a per-instance path (vhost-signals.@%i.json, no Go
  change needed); sleeper_daemon._signal_reader globs and merges per-vhost
  (max last_request_ts, sum active_conns) across workers, falling back to
  the legacy plain path when no per-worker files exist yet.

- manifest.load_manifest stored portal_domain verbatim; the rest of the
  front pipeline (waker exact-match, sleeper keys, wafsync output) is
  lowercase, so a hand-edited mixed-case domain would silently never wake
  nor sleep. Lowercase at load time.

Full profiles suite: 251 passed. mypy --strict api/: unchanged 102
pre-existing errors (diffed against HEAD, only reordering, zero new).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 17:33:58 +02:00
dee65ed6c8 fix(profiles): sleeper unit not ProtectSystem=strict (drives LXC in-process) (ref #896)
secubox-sleeper.service actuates in-process as root (no sudo/systemd-run,
unlike the panel/waker which escape their own sandbox before touching
systemd/LXC). ProtectSystem=strict made everything under /run read-only
outside the listed ReadWritePaths, but lxc-start/lxc-stop write
LXC-internal runtime/lock/cgroup paths (/run/lxc/, /run/lock/lxc/, mount-
namespace setup) that are not part of this codebase's stable contract to
enumerate. api/actuate.py::_issue only hard-fails on rc is None, so the
resulting EROFS would have been silent: auto-sleep for every LXC-backed
on-demand module would quietly stop working.

Drop ProtectSystem=strict and ReadWritePaths=; keep ProtectHome=true,
PrivateTmp=true, NoNewPrivileges=true, and RuntimeDirectory=secubox. A
root daemon driving LXC in-process without ProtectSystem is at this
codebase's normal floor, not a regression.

debian/postinst: keep pre-creating /var/log/secubox and
/var/lib/secubox/profiles/rollback (still needed by the actuator
regardless of sandboxing), reword the comment to drop the
ProtectSystem=strict framing.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 17:14:53 +02:00
d67e2c03b7 feat(profiles): package waker/sleeper + lifecycle policy, v0.8.0 (ref #896)
Wires the scale-to-zero services into debian/ packaging: secubox-wakectl
entry point (/usr/sbin), templates/waking.html now ships, secubox-waker
and secubox-sleeper systemd units registered via dh_installsystemd
--name=, enabled/restarted (try-restart, preserving runtime state) in
postinst alongside secubox-profiles.service. postinst now also runs
secubox-wakectl waf-sync/health-sync so sbxwaf and the health monitor
have their lists from first install (nginx-sync stays unwired per the
2026-07-20 pivot).

Hardens secubox-sleeper.service with ProtectSystem=strict and an
explicit ReadWritePaths covering the actuator's real write set, traced
through api/actuate.py/snapshot.py/audit.py: /run/secubox,
/var/lib/secubox/profiles/rollback, /var/log/secubox, /data/lxc, and
/etc/secubox/waf (haproxy-routes.json, written on a routed module's
STOP — missed by a naive reading of the write set).

Documents the lifecycle/wake_class policy, the waker/sleeper mechanism
and a pilot procedure in README.md, wiki/Architecture.md and
.claude/MODULE-COMPLIANCE.md. Bumps changelog to 0.8.0.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 17:06:47 +02:00
dc08a8c139 feat(profiles): boot reconciliation + watchdog exclusion for on-demand (ref #896)
boot_should_start(m) is the pure boot policy (always-on/eager start
immediately, on-demand/manual wait for a real wake) and
watchdog_should_manage(m) documents/tests that no sleepable module is ever
force-revived by secubox-watchdog.

Investigation found: HEAD's secubox-watchdog has no auto-revive logic at
all today (monitor_loop only logs up/down, restarts are manual-only); the
unmerged feat/watchdog-auto-revive branch adds one gated purely on
lxc.start.auto, which actuate.py::runtime_stop already clears before
lxc-stop specifically to avoid this race — the same flag streamlit sleepers
rely on for exclusion today. No separate exclusion file exists to reuse, so
none was invented; once that branch merges it is already #896-safe with
zero further wiring.

The real, closeable gap was secubox-hub's own sidebar health-batch, which
reported a sleeping on-demand module's inactive systemd unit as "warn".
Added a profiles-side export (secubox-wakectl health-sync ->
sleepable-modules.json, mirrors waf-sync/nginx-sync) and wired it into
secubox-hub so a sleepable module shows "Asleep (on-demand)" instead of an
alarm; a failed unit still alarms regardless. The full admin /health/ page
reads module_prober.py/prober.py, which aren't sourced in this repo yet
(TODO #393) -- documented as a precise cross-package follow-up.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 16:51:56 +02:00
8e9ca2df12 feat(profiles): panel lifecycle/wake_class/sleep-state + manual sleep/wake (ref #896)
Status payload now surfaces effective lifecycle, wake_class and a derived
sleep_state (up/asleep/n-a) + wake budget per module. Adds POST /wake and
POST /sleep, webui->ctl (JWT + _apply_lock + fixed sudo argv), refusing
unknown/non-sleepable modules locally before any sudo call. Two new bounded
sudoers grants: synchronous wakectl wake (distinct from the waker's
fire-and-forget grant) and profilectl apply --only. Panel gains a
sleep-state pill + manual Sleep/Wake buttons per module row.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 16:31:17 +02:00
51b5c26140 fix(waf-ng): vhost signal excludes WAF-blocked/banned traffic (real activity only) (ref #896)
The Begin/End hook was placed before the WAF-inspection block, so a
request the WAF blocks (403 warning/ban) still refreshed last_request_ts —
scanner/bot traffic against public on-demand vhosts (near-constant
internet-wide scanning) kept the signal "fresh" forever, so
should_sleep()'s idle-age check never passed. Defeated auto-sleep for the
feature's primary deployment (public on-demand vhosts).

Move the Begin/defer End bracket to right after the WAF-inspection block's
403/warning/ban early-returns, immediately before the media-cache-hit
check. One placement still covers three real-response exit paths via the
single defer: the media-cache hit (counts — genuine content served),
media-cache-miss proxy, and the plain proxy. Blocked, banned, waker, and
421 requests never reach this line.

Adds TestVhostSignalsExcludedForWAFBlock (verified RED against the old
placement, GREEN after the move).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-20 16:17:59 +02:00