* 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>
Root cause of failed installs on gk2: maigret/SpiderFoot pull C-extension deps
(lxml, pandas, cryptography, netaddr…) with no stock arm64 wheels on PyPI, so a
minbase LXC can't pip-install them. Fix: pip --extra-index-url piwheels (prebuilt
aarch64 wheels — install drops from a 17-min failing compile to ~2 min) + apt
build-essential/dev headers as fallback. Install logs no longer /dev/null'd so
failures are diagnosable.
NOTE: containers also need host ip_forward=1 for egress via the masquerade NAT;
99-secubox-zz-lxc-forward.conf sets it (zz- to win over the hardening drop-in),
but the runtime value had drifted to 0 — re-applied with 'sysctl --system'.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
The secubox-proxy snippet already sets proxy_read_timeout, so including it AND
setting 300s in the same location failed nginx -t. Set the proxy headers inline
(like fmrelay's stream location) with a single 300s timeout for long SpiderFoot
scans. Verified nginx -t + reload on gk2.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
- secubox-spiderfoot: SpiderFoot (200+ passive modules, correlation graph =
interim Maltego-style hub) in an LXC container (10.100.0.43, mem 1536M).
spiderfootctl lifecycle (debootstrap + git clone + venv + in-container
systemd unit binding sf.py to the container LAN IP only, never a host port);
api/main.py aggregator-served, plain-def handlers, detached install, JWT +
audit on install/start/stop/restart-ui; status probe = bounded curl to the
container UI port. Panel + menu.d 711 + sudoers least-priv. 4 guard + 10 API tests.
- SECURITY (review HIGH): SpiderFoot has no native auth, so every nginx
location (panel, API, and the /spiderfoot-ui/ container-UI proxy) now carries
the shared SecuBox auth gate (auth_request /__sbx_auth_verify + @sbx_auth_login,
LAN-pass/else-deny), matching the peer pattern (fmrelay/grafana). Applied the
same gate to secubox-maigret's locations for consistency (defense-in-depth on
top of its app-layer require_jwt).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Adversarial review follow-ups:
- /admin/export.sbxsite: base64 + json.dumps now run in a thread (asyncio.to_thread),
not on the shared event loop — a photo-heavy backup no longer stalls the module.
- /admin/override: compare the operator secret on bytes; a non-ASCII input used to
raise TypeError in hmac.compare_digest → 500. Now a clean rejection + audit event.
- Uploads: surface the skipped-file count (?media_skipped=N + dashboard flash)
instead of silently dropping invalid/oversized images. 107 tests.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
- /billets/ operator panel: full Administration card linking every admin
surface (Dashboard, Nouveau billet, Modération, Backup .sbxsite, Change-pw,
Flux) instead of a couple of buttons.
- Forgotten-password OVERRIDE for the operator: GET/POST /admin/override — no
session, gated by the module operator secret (/etc/secubox/secrets/billets,
root-only, constant-time compare, rate-limited 5/h). Generates a strong
random password (token_urlsafe), shows it once (rendered in the POST response,
never in a URL), audit-logged (author.password_overridden / override_failed).
Linked from the panel; the reset page is same-origin on billets (no CORS,
no cross-origin secret handling).
- billets-admin.css: flash.good + code tokens. 106 tests (2 new override).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
- Upload images (png/jpeg/webp/gif ≤5 MiB) alongside the text of a billet:
multi-image mini-gallery. New api/services/media.py validates + FULLY
re-encodes every upload via Pillow (drops EXIF/GPS, neutralises polyglots;
SVG refused), producing a cleaned original + a ≤480px thumbnail.
- Storage under BILLETS_MEDIA_DIR (/var/lib/secubox/billets/media), served by
nginx /media/ alias (in-process StaticFiles fallback). img-src 'self' already
covers it — no third party, no CSP relaxation.
- Public: vignette grid in feed + permalink, pure-JS zoomable lightbox with
◀▶ keyboard/arrow navigation (graceful degradation: links open the full
image with JS off). First image drives og:image / twitter:image.
- Admin editor: multipart file input + existing-media thumbnails with delete.
New media table (migration 0002, ON DELETE CASCADE), repo CRUD, media delete
route; billet delete removes files. All Pillow work off the event loop
(asyncio.to_thread).
- Portable backup: GET /admin/export.sbxsite emits every billet + media inlined
as base64 (single re-importable file).
- nginx client_max_body_size 6m; requirements pillow==11.1.0. 104 tests (11 new).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
- /billets/ management panel rebuilt to the SecuBox WebUI Panel Guidelines
(certs reference): restrained header + stat grid + glass cards + dense list
+ toast, dropping the funky hero/blobs. Cyan, Courier Prime, emoji glyphs.
- Login rate-limit now keys on the real client via X-Forwarded-For (admin
_client_ip) instead of the shared proxy IP, so one operator's attempts no
longer lock out everyone. nginx preserves the XFF chain (X-Real-IP +
$proxy_add_x_forwarded_for).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Public feed + permalink redesigned funky/party (theme-aware gradients, emoji
accents, playful cards). Embeds now render INLINE in the feed too (responsive
16:9-ish media, CSP frame-src augmented for self-hosted hosts). Inline embedded
reference links shown as 🌐 chips. Prominent emoji quick-share row (Bluesky/X/
WhatsApp/Telegram/Reddit/LinkedIn/email + Mastodon/copy via billets.js) on every
billet in the feed and permalink (replaces the hidden Republier details).
Asset URLs versioned (?v=2) to bust the sbxwaf media cache. 90 tests.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
menu.d/415-billets.json (category mind) surfaces Billets in the SecuBox sidebar.
A funky, emoji-rich hybrid-dark panel at /billets/ (admin webui) with live stat
count-ups, reaction breakdown, latest billets and action buttons — fed by a new
CORS-enabled GET /stats.json (public aggregate counts). Fixed a latent import-time
BILLETS_DB read (now resolved at connect()). 89 tests.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Found during live deploy to billets.gk2.secubox.in:
- markdown-it linkify=True needs linkify-it-py (not in requirements) -> 500 on any
body with a URL; spec only needs markdown [links], so linkify is off (bare-URL
autolink stays for comments via linkify_plain).
- systemd ExecStartPre tried to chown the shared /run/secubox as the secubox user
-> service failed to start; removed (tmpfiles already provides 1777 /run/secubox).
- debian/compat conflicted with debhelper-compat in control; removed.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
services/feeds: title/excerpt derivation (billets have no title field) + Atom and
JSON Feed 1.1 builders (XML-escaped, well-formed). Routes: /feed.xml, /feed.json,
and /oembed (outbound — only our own permalinks, returns a self-contained
blockquote, never an iframe). Full OpenGraph + Twitter Card meta per billet +
json+oembed discovery link. Republier share menu (Bluesky/X/Facebook/LinkedIn/
WhatsApp/Telegram/Reddit/email server-rendered; Mastodon-instance via localStorage
+ copy-link in billets.js). 85 tests.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Session login: argon2id (verify off-loop via to_thread), optional TOTP, signed
session cookie (HttpOnly/SameSite=Lax/Secure), in-memory login rate-limit,
double-submit CSRF on every admin POST. Billet CRUD (draft/publish/edit/archive/
delete) via a single URL field (ref|embed). Each mutation appends to the
BLAKE2b-chained event_log AND commits a Gitea revision (content+style) off the
event loop. IPs are BLAKE2b-hashed, never stored raw. 40 tests pass.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Public read surface: keyset (cursor) pagination on (published_at,id), permalinks
with view counting, restricted-markdown -> nh3-sanitized HTML (raw HTML escaped,
no live script), theme-aware CSS, h-entry microformats, strict CSP + security
headers. Plus per-user request: a git-backed revision store (services/revisions)
versioning each billet's content+style into a Gitea repo (front-matter .md,
best-effort push, full git-log history). 31 tests pass.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Removes a stray secubox-peertube.postinst.debhelper (regenerated at build from
debian/secubox-peertube.tmpfiles via #DEBHELPER#) and ignores the pattern.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Live gk2 findings: haproxyctl vhost-add is blocked by its drift-guard and is
redundant with default_backend mitmproxy_inspector, so route_ok now reflects the
WAF route (the operative mechanism), vhost stays advisory. And the board runs
Python 3.11.2 (no tarfile filter= kwarg) — replaced filter=data with a portable
manual member check (rejects traversal/absolute/links).
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
secubox-publish's infra provisioner ran as unprivileged `secubox` and tried
to write /etc/nginx/sites-available/<domain>.conf, rewrite
/etc/haproxy/haproxy.cfg, write /etc/secubox/waf/haproxy-routes.json, and
reload nginx/haproxy/secubox-waf directly. All of these silently failed
(permission denied), which was the root cause of published sites answering
421/never routing. Metablogizer already owns routing through the audited
secubox-publishctl root helper (apply_route + provision_cert), so add a
POST /publish/route endpoint there and have the hub call it instead of
touching root config itself.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
Adds the Task-7 5-step Publish wizard (Content -> Version -> Route ->
Cert -> Backup) to www/metablogizer/index.html, wired to the Task-6
endpoints (POST /publish/wizard multipart, GET /publish/export/{name}).
Reuses the page's existing token()/API helpers and the
uploadSiteContent() multipart-fetch/401-retry pattern for consistency;
JSON result is rendered via textContent only.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>