secubox-core.service is a Type=oneshot (mkdir+chown) that RemainAfterExit=yes. A hard
Requires= on ~108 units cascade-stops them all if core is restarted/fails (e.g. a
secubox-core package upgrade) — a thundering-herd outage. After= keeps the ordering;
Wants= keeps the soft dependency without the cascade. Prereq for mass native apply
(Phase 3). Scaffolds (new-module.sh/new-package.sh) updated so future units use Wants=.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
* docs(device-guardian): Project A consolidation design — merge 5 device modules into nac (ref #817)
* docs(device-guardian): Project A implementation plan — 9 tasks (ref #817)
* feat(nac): SQLite device store + idempotent legacy migration (ref #817)
Add packages/secubox-nac/api/store.py — pure-stdlib sqlite3 module with
canon_mac(), DeviceStore (WAL, best-value upsert, list/get/count,
history), and migrate_legacy() for idempotent, per-source best-effort
import of mac-guard/device-intel/iot-guard legacy shapes. Reserved
Project-B columns (plane/provenance/geo_cc/geo_asn) created but unused.
Task 1 of the Device Guardian consolidation plan (docs/superpowers/plans/
2026-07-05-device-guardian-consolidation.md).
* fix(nac): store — preserve first_seen, skip corrupt legacy sources, no stray db, SPDX (ref #817)
* feat(nac): unified dnsmasq+ISC+ARP discovery merge (ref #817)
* fix(nac): discovery — latest ISC lease wins the IP on renewal (ref #817)
The merge loop only updated ip/source on a strict rank increase
(incoming_rank > existing_rank), so equal-rank sightings for the same
MAC never touched ip/source. Since dnsmasq -> isc -> arp is processed
in non-increasing rank order, multiple ISC dhcpd.leases blocks for one
MAC (a fresh block per renewal) are equal-rank ties, and the first
(oldest) block's ip was frozen instead of the latest one winning, as
the original secubox-device-intel._get_dhcp_leases did.
Change the condition to incoming_rank >= existing_rank so the latest
same-or-higher-rank sighting updates source, and overwrites ip when
the incoming ip is non-empty. Hostname rule and the lower-rank branch
are unchanged, so a later ARP sighting still cannot clobber a
higher-rank ip/hostname/source.
Add test_isc_latest_lease_wins: two ISC lease blocks for one MAC with
ip/hostname renewal, asserting the latest block wins. Confirmed this
test fails against the pre-fix >-only logic.
* feat(nac): absorbed enrichers — OUI vendor + device-type/risk + OpenWrt fingerprint (ref #817)
* fix(nac): enrich — is_router strictly vendor-based; cover classify/oui edge cases (ref #817)
* feat(nac): background collector + double-cache; handlers def, no inline scan (ref #817)
Task 4 of the Device Guardian consolidation: a Collector background
loop now owns all discover -> enrich -> upsert work (replacing
_monitor_clients), publishing an in-memory snapshot(). The status,
clients, and client/{mac} handlers become plain def reading the SQLite
store / collector cache instead of calling _discover_clients() inline
on every request -- the #808 aggregator SPOF.
* fix(nac): finish #808 — def-convert alerts/list_quarantine/summary; first_seen + recent_events (ref #817)
alerts/list_quarantine were still `async def` calling blocking
_discover_clients() inline on the shared aggregator loop; summary
awaited alerts on top of that (double loop-block). Converted all three
to plain `def` reading the SQLite store/collector snapshot instead,
completing the #808 SPOF fix started for status/clients/client.
Also: collector.cycle_once() now stamps first_seen on newly discovered
devices (store.upsert keeps the earliest via COALESCE), and get_client's
recent_events merges the SQLite device_history (client_joined) with the
JSON event log so a device's join event shows up again.
* feat(nac): allow/deny actions + def-convert action handlers (finish #808) (ref #817)
Task 5: fold mac-guard's allow/deny into nac's zones/nft. New
POST /allow/{mac} and /deny/{mac} set allow_state in the SQLite store
and mirror it into nft's existing inet secubox_nac blocked/lan_allowed
sets via _nft_add_element/_nft_delete_element (mac-guard's separate
inet secubox_mac_guard table stays retired; its element migration is
Task 9).
Carried from Task 4 review: zones, add_to_zone, remove_from_zone,
approve_client, ban_client, update_client, quarantine_client and
unquarantine were still async def wrapping blocking subprocess/nft
calls on the shared aggregator loop (#808). Converted to plain def so
FastAPI threadpools them off the loop; add_to_zone/ban_client's webhook
notify now fires via a new _fire_webhook_sync helper
(asyncio.run_coroutine_threadsafe against the loop captured at
startup) since a plain def can no longer await _notify_webhooks
directly.
Tests: tests/test_actions.py mocks nft via monkeypatched
_nft_add_element/_nft_delete_element/_nft_list_set (+ subprocess.run,
since ban_client/unban_client build raw nft argv) into an in-memory
set-membership dict; covers deny/allow/allow-then-deny, zones,
add_to_zone+approve_client, and ban_client+unban_client.
* feat(nac): absorbed endpoints — vendors/scan/probe/mdns/groups/export (ref #817)
* fix(nac): /scan enriches + validates subnet; /probe IP + CSV injection guards (ref #817)
* feat(nac): retire mac-guard/device-intel/iot-guard via 308 redirects; auth-gate network-anomaly (ref #817)
secubox-mac-guard, secubox-device-intel and secubox-iot-guard are
superseded by secubox-nac's consolidated Device Guardian. Their
api/main.py are reduced to thin shims: every route 308-redirects to
its secubox-nac equivalent (/devices->/clients, /device/{mac}->
/client/{mac}, /whitelist|/allow->/allow, /blacklist|/deny->/deny,
/scan, /vendors, /groups, /export/* pass through unchanged, and a
catch-all forwards anything else), with an X-SecuBox-Deprecated
header. /health stays a plain 200 so liveness probes never 308-loop.
Package removal is a separate, gated follow-up.
secubox-network-anomaly stays (separate traffic-anomaly engine) but
its /status endpoint was unauthenticated while every sibling route
already required a JWT; it now matches.
* feat(nac): webui — vendor/type/risk columns, groups view, export; drop retired webuis (ref #817)
- clients.js: render enriched /clients fields — oui_vendor, device_type,
risk_level (color-coded badge)/risk_score, and OpenWrt/SecuBox/Router
fingerprint badges. All values are attacker-influenceable (DHCP
hostname/vendor) and are rendered exclusively via E()/textContent,
never innerHTML. Null/missing device_type/vendor/risk render as
"unknown"/"—", never "null". Adds an Export CSV button (direct link to
GET /export/csv — auth via the existing SSO session cookie, same as
every other same-origin nav link in this app; no new auth scheme).
- groups.js (new): list/create/delete device groups and assign a device
to a group, hitting GET/POST/DELETE /groups* — mirrors zones.js's
structure and auth (sbxFetch).
- nav.js: register the new "Groupes" tab alongside Zones.
- Remove packages/secubox-{mac-guard,device-intel,iot-guard}/www — their
functionality is now consolidated into nac; secubox-network-anomaly/www
is untouched (stays standalone per plan).
Note for Task 9: device-intel/iot-guard's debian/rules unconditionally
`cp -r www/<name>/.` — now broken since www/ is gone; mac-guard's rule is
already guarded (`[ -d www ] && ... || true`). Needs a debian/rules fix
when Task 9 retires/redirects these packages.
* chore(nac): packaging — ieee-data dep, nft migration, retirement redirects, changelog 3.0.0; fix retired debian/rules www (ref #817)
* fix(nac): whole-branch — lazy init for in-aggregator mount, collector off-loop, sqlite lock, batched zone lookup, sentinel-preserve (ref #817)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(nac): risk_score split — known router low, unknown router high (rogue-AP signal) (ref #817)
Split the router risk axis out of the generic device-type/open-ports
scoring: a router/gateway already known (allow-listed or already in the
store) is trusted infrastructure -> low risk; a newly-seen/unknown
router is a rogue-AP signal -> high risk, regardless of open ports.
Removes the old flat -10 "trusted router" discount. Non-router devices
keep the existing scoring unchanged.
risk_score() gains an is_known=False parameter (backward compatible).
collector.cycle_once() and main.scan() now compute is_known from the
prior store row (existing row or allow_state == "allow") before
upserting, reusing the row they already fetch for is_new/enrichment.
* feat(nac): mesh-sync (p2p peers as devices) + fingerprinted-router classification for rogue-AP risk (ref #817)
Part A: absorb iot-guard's missed "mesh-sync" capability. GET /mesh/peers
and POST /mesh/sync now live in nac, reading live P2P peers over
/run/secubox/p2p.sock (HTTP-over-UDS GET /peers, fail-safe -> [] on any
error/timeout/absent socket) and upserting one sb:-prefixed synthetic
device per peer (iot-guard's exact md5-hash MAC scheme), device_type
"mesh_node", source "mesh". iot-guard's retired redirect-shim catch-all
already forwards /mesh/* here, so no shim change needed.
Part B: classify_device_type's "router" keyword list is much narrower
than ROUTER_VENDORS (misses tp-link/asus/linksys/d-link/gl.inet/xiaomi/
huawei), so a router-vendor MAC with a generic hostname fingerprinted
is_router=True still classified as device_type="unknown" and skipped
risk scoring entirely (the omit-unknown guard) -- the known-router-vs-
rogue-AP HIGH signal never fired for the most common vendor case. Both
enrichment call sites (Collector.cycle_once and /scan) now reclassify
device_type="unknown"+is_router to "router" before scoring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(nac): postinst ensures secubox_nac table + zone sets (incl lxc_zone)
Nothing shipped created nac's inet secubox_nac table or its zone sets — they
were assumed to pre-exist (they don't: the live board had no such table), so
operator 'move to zone' failed for every zone and the mac-guard element
migration silently no-op'd. postinst now creates the table + all ZONES sets
(lan_allowed, lxc_zone, iot_zone, guest_zone, quarantine_zone) + blocked,
idempotently and best-effort, before the migration. Makes operator moves to
the LXC Containers zone work on a fresh install. nft commands validated on
gk2 (create/idempotent re-add/add-element/list), no live drift left.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
---------
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add /shared/hybrid-dark.css: a shared overlay that remaps the design tokens
(consumed by crt-light.css, sidebar-light.css and every module's inline styles)
to the cyan hybrid-dark palette from /certs/ + /wireguard/. body.hybrid-dark
(specificity 0,1,1) beats :root and inline :root, so one token remap flips the
whole tree — no per-panel rewrite. Each panel gains the overlay <link> + the
hybrid-dark body class (2-line additive swap; crt-light.css kept as base).
See .claude/WEBUI-PANEL-GUIDELINES.md.
Panels with fully bespoke styles (certs/wireguard/users/nac already hybrid;
~14 non-crt-light custom panels) are untouched.
* perf(aggregator): async-sweep — 243 blocking route handlers async def→def (#738)
Mounted in the aggregator's single event loop, an 'async def' route handler
that runs blocking code (subprocess/journalctl/openssl/argon2) freezes the
WHOLE loop -> aggregator.sock Connection refused -> 502 board-wide.
Deterministic AST codemod (scripts/async-sweep.py) converts route handlers
that (a) are decorated with an HTTP verb, (b) contain a known blocking call,
(c) have NO await/async-with/async-for/yield, (d) are never used as a
coroutine elsewhere -> plain 'def'. Starlette then runs them in the AnyIO
threadpool, so the blocking call no longer stalls the gateway. await/stream/
websocket handlers are left untouched. Every file py_compile-checked.
243 handlers across 56 modules (system 17, qos 13, netdiag/hexo 12, hub 11...).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(aggregator): raise AnyIO threadpool to 80 tokens (#738)
The async-sweep moves ~243 blocking handlers to the threadpool. With ~110
modules in one process, the default 40-token pool can queue head-of-line under
concurrent blocking load. Raise to 80 on startup (best-effort, never breaks
boot).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(hub): double-buffer status cache + emojized health page (#738)
Navbar status (menu + health-batch) is now a strict double-buffer cache:
- request handlers NEVER compute on the request path — they return the current
snapshot instantly (or a 'warming' placeholder), so the sidebar's polling can
no longer serialize behind a ~3s systemctl walk and starve the loop;
- the background refresher is kicked from the request path (_ensure_bg) because
mounted sub-apps receive neither startup nor @app.middleware events under the
aggregator — the previous lazy-start middleware never fired there;
- snapshots are built complete then swapped atomically, so the dashboard never
shows partial/bad counts.
Served by the dedicated secubox-hub process (:8001, isolated loop) the navbar
stays <50ms and holds 200 under 25+ concurrent polls where the aggregator-
mounted copy wedged (000). health.js: 🟢🟡🔴 emoji status indicators.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* style(hub): render health status emoji cleanly (neutralize .led dot) (#738)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(sidebar): kill per-module health storm, use batch endpoint (#738)
The navbar refreshed LEDs by firing ONE /api/v1/<module>/health request per
module — ~119 requests every 30s, in batches of 8 — straight at the aggregator's
single shared event loop. Combined with the in-process module mount this is a
prime driver of the recurring board-wide 502 wedge (user-identified).
checkAllHealth + refreshStaleHealth now call /api/v1/hub/public/health-batch
ONCE (served by the dedicated, double-buffered hub process) and populate every
module's LED from that single response. 119 reqs/cycle -> 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Systemic clobber: the scaffold boilerplate (install -d -m 750 /var/lib/secubox,
/run/secubox) put restrictive modes on SHARED parents in ~56 module postinsts,
reverting them to 0750 on every install/upgrade and breaking traversal for
non-secubox daemons (kbin/toolbox 500). Empirically confirmed install -d -m only
modes the final component, so /parent/leaf forms are harmless — only bare-parent
targets were rewritten. Multi-arg lines (incl. ones making /var/lib world-writable
1777) split per-parent: /run/secubox=1777 root:root, /var/lib|cache|etc=0755
secubox:secubox; module-private leaves keep 0750. Scaffold + PATTERNS.md fixed so
new packages don't reintroduce it.
Hub + portal stayed `inactive (dead)` on real-hardware boot. The
journal error was `Failed to set up mount namespacing: /run/systemd/
unit-root/run/secubox: No such file or directory` at the NAMESPACE
step. Root cause: 96 services declare `RuntimeDirectory=secubox`
without the matching `RuntimeDirectoryPreserve=yes`. When any of them
stops (including any of the LXC-backed services that fail their
health probe and Restart=on-failure for a few cycles), systemd
removes /run/secubox on the way out. The next service with the same
RuntimeDirectory= that tries to namespace its inputs hits the gap
and falls into 226/NAMESPACE failure — Restart hammers a few times,
then the unit goes failed-permanent.
Earlier fmrelay + sentinelle units got the fix individually
(v2.12.0/v2.12.3 era). Now applied to all 96 remaining units in a
single sweep via:
sed -i '/^RuntimeDirectory=secubox/a RuntimeDirectoryPreserve=yes' "$f"
No version bumps in changelog — `dpkg -i --force-depends` in the
live-USB slipstream picks up the new .deb regardless of version
number.
The hub's `_compute_menu_sync()` drops menu entries lacking an `id`
field. 6+ packages (lyrion, yacy, zigbee, rustdesk, grafana, authelia)
shipped a different schema (`title/url/section/module`) so they NEVER
appeared in the navbar. Plus the existing CATEGORY_META declared 12
sections (dashboard/security/network/system/core/users/services/
privacy/monitoring/publishing/apps/admin), not the 6 SecuBox charter
modules.
Changes:
* All 121 packages/secubox-*/menu.d/*.json normalised to the canonical
schema (id, name, path, category, icon, order, description). Legacy
schema aliases (title/url/module/section) preserved as fallbacks then
dropped from the file.
* Each menu entry's category remapped to one of the 6 charter modules:
AUTH (auth/users/identity/zkp/nac/openclaw),
WALL (crowdsec/waf/mitmproxy/hardening/threat-* /cve-triage/...),
BOOT (kernel-build/eye-remote/master-link/droplet/cloner/backup/...),
MIND (ai-gateway/mcp-server/grafana/ndpid/netifyd/glances/...),
ROOT (system/hub/portal/console/admin/vault/vm/rtty/...),
MESH (wireguard/dns/tor/matrix/gitea/nextcloud/mail/lyrion/yacy/
zigbee/dns-provider/rustdesk/... — all network + comms apps).
* secubox-hub v1.4.0 — CATEGORY_META rewritten with the 6 charter
modules carrying their official color from DESIGN-CHARTER.md and
the complementary-pair order. DEFAULT_MENU fallback remapped too.
* secubox-zigbee v2.5.3 — www/zigbee/index.html rewritten with the
canonical SecuBox scaffold (body display:flex, sidebar 220px fixed,
.main reserving 48px for the global-menu-bar). MESH palette.
Browser side: operators need to clear localStorage sbx_menu_cache (or
hard-refresh after the 1h TTL) to see the new sections after deploy.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
PR #169 moved login.html out of secubox-hub to /usr/share/secubox/www/
under secubox-portal's ownership, but 67 source files across the
package set still redirected to /portal/login.html which no longer
exists on disk. nginx fell back to /index.html (hub root) on every
hit, whose checkAuth() bounced back to /portal/login.html, producing
an infinite "Redirecting to login..." loop with no way for any user
to authenticate.
sed -i 's|/portal/login\.html|/login.html|g' across all packages/*/www
sources (82 occurrences). The two prior partial fixes that triggered
this investigation (bcc6a781, 464e3379) only covered a subset and
missed the kiosk root checkAuth in secubox-hub/www/index.html plus
65 module index.html files.
Verified: grep -rl '/portal/login\.html' packages/ | grep -v /debian/
returns empty.
Discovered while testing the firstboot v2-schema+argon2 chain end-to-end
in a VBox amd64 boot of CI run 26082003883 (the #218 image build fix)
— firstboot now works, users.json gets a valid argon2 hash for
admin/secubox, the portal API runs, but the login UI is unreachable
because of this dangling URL.
Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
- Add /multigadget skill covering autorun, storage, round UI, tooling
- Add Eye-Remote-Multigadget.md unified wiki hub
- Add Build-System.md to wiki with build documentation
- Add AI-BUILD-PROMPT.md for GPT/Gemini assistance
- Update sidebar and home page with new links
- Update WIP.md with #70 and #71 done
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Auto-generated health check endpoints for sidebar status:
- Returns {status: "ok", module: "name"}
- Public endpoint (no auth required)
- Used by sidebar.js for LED status display
Added via scripts/add-health-endpoints.py
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>