Compare commits

...

24 Commits

Author SHA1 Message Date
d05dcf615e fix(metrics): real Top-Vhosts (nginx per-vhost logs) + WAF block rate (waf-threats.log)
Some checks are pending
License Headers / check (push) Waiting to run
live_hosts now counts per-vhost requests from /var/log/nginx/<vhost>_access.log
over the window (this topology has one HAProxy frontend, so no per-vhost
signal there) + exposes total_requests. health/summary WAF rate reads the
live sbxwaf threat log over the last hour as a share of traffic (was the
stale /var/log/mitmproxy path + a //10 heuristic); adds waf.blocks_1h.
postinst adds the service user to 'adm' for nginx-log read. Bumps 1.0.5.
2026-06-29 17:34:56 +02:00
877fb9e19a fix(portal): bind Active bans to /crowdsec/decisions count (2.2.2) 2026-06-29 17:25:16 +02:00
5e4c0d2dac fix(portal): reskin to standard SecuBox template (shared sidebar + C3BOX palette)
Replace the bespoke top navbar with the shared menu-driven sidebar, swap the
P31 green theme for design-tokens/crt-light (C3BOX), wrap content in
main.main + crt-engine. Dashboard content, IDs and data-fetch JS unchanged.
Bumps secubox-portal 2.2.0 -> 2.2.1.
2026-06-29 16:49:30 +02:00
de15937ccf fix(appstore): navbar entry -> /usr/share/secubox/menu.d + 'root' section (0.2.2) 2026-06-29 16:43:26 +02:00
e51a310010 fix(appstore): NoNewPrivileges=no for sudo bridge + try-restart on upgrade (0.2.1) 2026-06-29 16:36:20 +02:00
6034dfb0c3 feat(appstore): Phase B/C — navbar integration + quick actions + config editor
UI integrates the shared sidebar (nav#sidebar + sidebar.js + crt-engine) and
adds per-service quick actions (start/stop/restart/enable/disable) + a config
editor drawer (view/edit module TOML, shows deps). API: POST
/module/{name}/action/{verb}, GET/PUT /module/{name}/config. Privileged work
via validated root helper secubox-appstorectl (secubox-* units +
/etc/secubox/<name>.toml only, TOML-validated writes) + narrow sudoers.
postinst rebuilds the hub menu cache so the store shows in the navbar.
Bumps 0.2.0.
2026-06-29 16:34:17 +02:00
8f46bcb93b Merge branch 'feature/appstore-phase-a' — App Store Phase A (read-only catalog) 2026-06-29 13:20:49 +02:00
58f1f1a2c8 fix(appstore): serve catalog API from secubox-routes.d (beats /api catch-all)
The /api/v1/appstore/ route must live in secubox-routes.d/ (the hub's
authoritative API include) or the generic /api/ -> aggregator catch-all
swallows it (404). Split: API route -> secubox-routes.d/appstore-routes.conf
(standalone appstore.sock), UI -> secubox.d/appstore.conf. Bumps 0.1.1.
2026-06-29 13:20:17 +02:00
66301f4307 feat(appstore): SecuBox App Store Phase A — read-only module catalog
New secubox-appstore module: api/main.py serves /catalog (category/tier/
state/q), /module/{name}, /categories, /health — merging a build-time
catalog (generated from every module's debian/secubox.yaml) with live dpkg
+ systemctl state (available/installed/running/tier-locked). Categorized,
tiered, searchable grid UI at /appstore/. Standalone service on
/run/secubox/appstore.sock, nginx route + static (no aggregator dependency).
Also backfills secubox-lyrion's missing manifest.
2026-06-29 13:16:37 +02:00
a9f349a57d docs(appstore): SecuBox App Store + Module Composer architecture sketch + GPT prompt
Catalog over the 128 secubox.yaml manifests; granular control
(module/component/navbar/appearance); profiles (4R atomic apply); P2P-mirrored
apt repo + federated catalog; multi-service mesh agents. Includes a complete
GPT architectural-research-notes prompt (§10).
2026-06-29 13:09:15 +02:00
a1ec2601c8 fix(p2p): render P2P web UI — API-shape handling + live mesh graph
JS read /peers and /threats as arrays but the API returns {peers,count} /
a dict -> nothing rendered. Handle both shapes; loadOverview uses
service_count. Mesh canvas was a hardcoded placeholder; now wired to GET
/mesh. /mesh derives nodes/links from wg_mesh.json (local + wg peers).
Bumps secubox-p2p 1.7.7 -> 1.7.8.
2026-06-29 13:03:03 +02:00
a0f9c7811f fix(p2p): P2P web UI empty — surface the wg-mesh in /peers + /status
The dashboard read the unused legacy peers.json; the live mesh lives in
wg_mesh.json (Phase 1). Add mesh.peer_nodes() and drive /peers + /status
from it; /status now returns total_peers/active_peers (the fields the web
UI actually reads). Per-peer name + mesh_ip give friendly node labels.
Bumps secubox-p2p 1.7.6 -> 1.7.7.
2026-06-29 12:45:09 +02:00
b8fce891de Merge branch 'feature/gondwana-phase1-mesh' — Gondwana Phase 1 mesh substrate
secubox-p2p becomes the single WireGuard mesh owner: subnet collision fixed
(10.100.0.0/24 -> 10.10.0.0/24), port 51822, pure api/mesh.py (collision
guard, p2p.toml loader, master-assigned IP, wg.conf parse/render, key
adoption, DDNS name), root sbx-mesh-up provisioner, secubox-p2p 1.7.6.
Live: gk2 (.1 master, key-preserving state sync, no bounce) + c3box (.2) +
amd64 (.3) — full 3-node mesh verified alive.
2026-06-29 12:19:05 +02:00
7effe5fb1a fix(p2p): delegate provisioning to sbx-mesh-up, mesh_ip on manual approve, top import, SPDX/test polish
- enable_wireguard: remove inline wg-quick + wg.conf render (KeyError on
  roaming peers without endpoint, fails as secubox user); now only sets
  enabled=true and returns a message directing root to run sbx-mesh-up
- _assign_mesh_ip helper DRYs allocation; ml_approve now also allocates
  mesh_ip for manually-approved peers (was silently None before)
- `from . import mesh` moved from mid-file WireGuard section to top import block
- SPDX headers added to tests/conftest.py and tests/test_mesh.py
- `import pytest` moved to top-level in test_mesh.py; redundant sys.path.insert removed
- ddns_name: slug[:63] RFC label cap + empty-string falls back to "node";
  test_ddns_name_empty_falls_back added (17 tests, all green)
2026-06-29 11:59:52 +02:00
b080612396 build(p2p): ship p2p.toml.example + sbx-mesh-up, dep wireguard-tools, 1.7.6 2026-06-29 11:46:04 +02:00
96c048860d fix(p2p): drop unused subprocess import + PKG var in sbx-mesh-up, assert listen_port
Cleanups in secubox-p2p (Task 7 follow-up):
- Remove unused 'subprocess' import from sbx-mesh-up heredoc
- Remove unused 'PKG' shell variable (path hardcoded in sys.path.insert)
- Add listen_port assertion in test_adopt_state_imports_existing_key_when_absent
2026-06-29 11:44:25 +02:00
7e75efffd2 feat(p2p): root sbx-mesh-up provisioner (adopt key, guard, render, up) 2026-06-29 11:42:10 +02:00
910b87fd3a fix(p2p): drop colliding 10.100.0.x fallbacks from enable_wireguard
Add address guard (400 if /wireguard/init never called), use
config['address'] directly (no fallback), replace hardcoded peer
AllowedIPs fallback '10.100.0.0/24' with mesh.MESH_NETWORK constant.
Collision guard now covers all code paths.
2026-06-29 11:40:04 +02:00
a3ec30ed96 feat(p2p): adopt mesh.py — 10.10.0.0/24:51822, role-aware addressing, collision guard 2026-06-29 11:35:37 +02:00
8ef46e086b feat(p2p): per-node DDNS identity name helper 2026-06-29 11:31:36 +02:00
a949b2e495 feat(p2p): parse/render wg-mesh.conf (key adoption + provisioning) 2026-06-29 11:29:33 +02:00
6a662a165c feat(p2p): master-assigned mesh IP allocation (.2+, .1=master) 2026-06-29 11:26:34 +02:00
29897b40bc feat(p2p): /etc/secubox/p2p.toml [wireguard] loader + example 2026-06-29 11:23:54 +02:00
d70db5ea7e feat(p2p): mesh module with subnet collision guard 2026-06-29 11:20:55 +02:00
35 changed files with 1672 additions and 332 deletions

View File

@ -0,0 +1,165 @@
# SecuBox App Store / Module Manager — Architecture Sketch
**Date:** 2026-06-29 · **Status:** brainstorm sketch (pre-spec) · **Author intent:** "appstore categorized + live prefs/components care; lyrion/peertube/nextcloud/webmail/kbin are modules of this toolbox system; implement shortly."
---
## 1. Core idea
A single web UI to **discover → install → enable/disable → configure → monitor** every SecuBox module, like an app store but fused with a live "components care" panel (prefs + health + restart/repair). It is a *front-end + lifecycle layer over manifests that already exist* — not a new packaging system.
**Foundation already present (don't reinvent):**
- **128 modules** each ship `debian/secubox.yaml` = `{name, category, tier, description, depends, api:{socket,health}, ui:{path}}` — this IS the catalog entry.
- Categories in use: `media, email, ai, iot, communication, publishing, network, security, system, vpn, dashboard, misc`. Tiers: `lite | standard | pro | all`.
- Module anatomy: `api/main.py` (FastAPI control-plane), `nginx/*.conf`, `sbin/<mod>ctl` (29), `lib/<mod>/install-lxc.sh` (9 LXC-backed: lyrion, peertube, photoprism, jellyfin-ish, grafana, mqtt, yacy, rustdesk, zigbee), `conf/<mod>.toml.example`, `menu.d/NNN-<mod>.json` (129), `www/<mod>/` (150).
- The **aggregator** mounts each module API at `/api/v1/<name>/`; the **hub** is the central dashboard; **menu.d** drives the menu.
- apt repo `apt.secubox.in` is the package source; `dpkg` is installed-state truth.
## 2. State model (per module)
`available` (in apt, not installed) → `installed``enabled` (service on + menu/nginx wired) → `running` (healthy). Plus `update-available`, `error`, and `tier-locked` (board tier < module tier). Health = {service active?, LXC state (for LXC apps), socket up?, last /health, mem/disk}.
## 3. Backend — `secubox-appstore` module (FastAPI, on the aggregator, runs as `secubox`)
Read/aggregate endpoints (safe, no privilege):
- `GET /catalog?category=&tier=&q=` — merged list: apt-available dpkg-installed, each with manifest + state + version + update flag.
- `GET /module/{name}` — manifest + live state + health + prefs schema + screenshots.
- `GET /module/{name}/health` — proxy module `/health` + LXC/service/socket/resources.
- `GET /jobs/{id}` — async job status (install/uninstall progress).
Mutating endpoints (delegate to a root helper — see §4):
- `POST /module/{name}/install` · `/uninstall`
- `POST /module/{name}/enable` · `/disable`
- `POST /module/{name}/restart`
- `GET/PUT /module/{name}/prefs` — read/write the module TOML (schema-validated; sensitive configs go through the CSPN double-buffer/4R: shadow → validate → atomic swap → rollback).
- `POST /module/{name}/expose` — toggle public exposure (HAProxy ACL + mitmproxy route, **never waf_bypass**) — later phase.
## 4. Privilege & async model (the hard part)
The FastAPI runs as `secubox` and **cannot** apt-install, run `install-lxc.sh`, `lxc-*`, `systemctl`, or edit `/etc/nginx`. Mirror the proven `sbx-mesh-up`/`<mod>ctl` pattern:
- A **root helper `secubox-appstorectl`** does the privileged verbs (`install <name>` = apt-get install + run the module's `install-lxc.sh` if LXC-backed; `enable/disable` = systemctl + menu.d toggle + nginx reload; `uninstall` = apt purge [+ optional LXC destroy]).
- FastAPI invokes it via a **narrow sudoers rule** (only `secubox-appstorectl <verb> <name>`), or hands jobs to a **privileged oneshot/queue** (a root `secubox-appstore-worker` consuming a job dir).
- Installs take **minutes** (apt + debootstrap LXC) → **async job model**: enqueue → return job id → UI polls `/jobs/{id}` with a progress stream (stage: download → install → provision-lxc → enable → health-check).
- Every action → append-only **audit log** (`/var/log/secubox/audit.log`), CSPN requirement.
## 5. Manifest schema extension (`secubox.yaml` v2 — additive, back-compatible)
Add optional appstore fields, defaulted so the 128 existing manifests still parse:
```yaml
appstore:
icon: "lyrion.svg" # or emoji/glyph
summary: "Squeezebox / LMS music server"
long_description: | # markdown
screenshots: ["1.png","2.png"]
lxc: true # LXC-backed (drives install-lxc.sh step)
exposure: optional # none | optional | required (HAProxy/mitmproxy)
default_ports: [9000, 3483]
resources: { ram_mb: 512, disk_mb: 2048 }
conflicts: [] # mutually-exclusive modules
homepage: "https://lyrion.org"
```
## 6. Web UI — components
- **CatalogGrid** + **AppCard** (icon, name, summary, category/tier badge, state pill, primary action toggle).
- **CategoryFilter** (chips from `category`), **TierFilter**, **SearchBox** (name/desc), **State filter** (installed/enabled/available/updates).
- **AppDetailDrawer**: description + screenshots; **ActionBar** (Install/Enable/Disable/Restart/Uninstall, tier-locked → upsell); **PrefsForm** (schema-driven from `conf/*.toml.example`); **HealthPanel** (service/LXC/socket/resources, live); **LogViewer** (journalctl tail); **JobProgress** (install spinner with stages).
- **Live Care view**: cross-module health board (what's down / degraded), bulk restart/repair, update-all.
- Style: SecuBox C3BOX palette (cosmos-black/gold-hermetic/cyber-cyan, Cinzel/JetBrains Mono).
## 6b. Granular control + Profile system (requested 2026-06-29)
The store doesn't just install/remove whole modules — it **composes the appliance** at four granularity levels, and **Profiles** bundle a whole composition you can switch atomically.
**Granularity levels (each independently toggleable):**
- **L0 — Module:** install / enable / disable the whole `secubox-<name>` (service + LXC).
- **L1 — Component:** sub-features *within* a running module (e.g. lyrion: streaming on / plugins off; nextcloud: files on / calendar off; a security module: detection on / active-response off). Driven by a `components:` list the module declares in its manifest, mapped to the module's own config flags / sub-services.
- **L2 — Navbar / menu:** show/hide and reorder individual `menu.d` entries — UI surface independent of whether the module runs (hide an app from the menu without disabling it, or vice-versa).
- **L3 — Appearance:** theme / palette / layout / branding (the C3BOX themes), per-board or per-profile.
**Profiles** = a named, versioned bundle of `{enabled modules, per-module component flags, navbar layout+order, appearance/theme, exposure settings}`.
- **Built-in presets:** *Minimal*, *Media Center* (lyrion/peertube/jellyfin/photoprism + lean navbar), *Security Ops* (waf/crowdsec/dpi/soc forward), *Home/Family* (nextcloud/mail/homeassistant), *Headless/Kiosk*. Presets can align with the board **tier**.
- **Custom profiles:** create / clone / edit / export / import.
- **Switch = one atomic transaction** via the CSPN double-buffer/4R (shadow → validate → swap → rollback) — flipping a profile reconfigures modules+navbar+appearance together, with one-click rollback if it breaks.
- **Scope:** a per-board *active profile* (the appliance's persona) and optionally **per-user** profiles (master users `gk2`/`admin` see different navbars/apps) — ties into identity.
- **Storage:** `/etc/secubox/profiles/<name>.toml` + an `active` pointer + 4R snapshots; API: `GET /profiles`, `GET/PUT /profiles/{name}`, `POST /profiles/{name}/apply` (atomic), `POST /profiles/{name}/rollback`, `GET /profiles/active`.
## 6c. P2P distribution + multi-service agents (requested 2026-06-29)
The app store rides the **gondwana mesh** — both its *package source* and its *running services* are federated across nodes, so the fleet is redundant and clients are served from the nearest node.
**P2P-mirrored apt repo + federated catalog:**
- `apt.secubox.in` (today only on gk2) becomes **P2P-mirrored**: each mesh node holds a signed mirror, synced over the wg-mesh (`10.10.0.0/24`) with rsync/content-addressed deltas. A node/client installs from the **nearest reachable mirror** (local → mesh peer → upstream gk2/public), so installs are fast and **survive gk2 being offline** (access redundancy). Mirrors stay trustworthy via GPG (verify `InRelease`); never trust an unsigned peer mirror.
- The **catalog is federated**: each node advertises which package versions it holds and which services it runs; the store shows a **mesh-wide view** ("install here" vs "already running on c3box"). This is exactly the gondwana **distributed directory** (peers/services/name records) — the app store is its first big consumer.
**Multi-service agents (serve all network clients):**
- Each node runs its module services as **agents** that serve its LAN clients; the mesh makes any service reachable from any node (hub-routed today, direct later), so a client on c3box's LAN can use a service hosted on gk2 and vice-versa — **service mirroring + redundancy of access** (gondwana Phase 4).
- A thin **agent/orchestration layer** gossips health + "who-runs-what", does **failover** (a dead service → route clients to a mesh replica), and advises **placement** (install a module on the best-suited node). Clients reach services by the per-node name `<service>.<boxname>.secubox.in`, routed over the mesh.
- This makes the app store the *control plane* and the mesh agents the *data plane* of one distributed appliance, not 3 separate boxes.
## 7. Implementation plan (incremental, each shippable)
- **A — Catalog (read-only, safe):** manifest aggregator (parse 128 `secubox.yaml` + dpkg + apt state) → `GET /catalog` + `/module/{name}`. UI: CatalogGrid + filters + AppDetailDrawer (read-only). No privilege. *Delivers the store view immediately.*
- **B — Health & prefs (read + careful write):** `/health` aggregation + `/prefs` GET; PUT prefs via the CSPN double-buffer path. UI: HealthPanel + PrefsForm + Live Care board.
- **C — Lifecycle (privileged):** `secubox-appstorectl` root helper + sudoers + async job model; enable/disable first (low risk), then install/uninstall (LXC). UI: ActionBar + JobProgress + audit.
- **D — Exposure & mesh (later):** per-app public exposure toggle (HAProxy+mitmproxy, no waf_bypass); gondwana mesh mirroring/redundancy (install once, run-anywhere) tying into the Phase-2/4 mesh work.
- **E — Manifest v2 rollout:** add `appstore:` block to the headline apps first (lyrion, peertube, nextcloud, webmail/mail, kbin/gotosocial, jellyfin, photoprism), generators backfill the rest.
## 8. Open questions (to resolve in the spec / by GPT)
1. New `secubox-appstore` package vs. extend `secubox-hub`?
2. Privilege bridge: narrow sudoers vs. a root job-queue worker? (CSPN favors the auditable queue.)
3. Job/progress transport: poll `/jobs/{id}` vs. SSE/WebSocket.
4. Prefs schema source: parse `*.toml.example` comments vs. a declared JSON-schema per module.
5. Tier enforcement: hard block vs. show-and-upsell.
6. Uninstall semantics for LXC apps: keep data volume vs. purge.
---
## 9. GPT architecture-design prompt (copy-paste)
> You are a senior systems architect. Design the **"SecuBox App Store / Module Manager"** for an existing Debian-based security-appliance platform. Output a concrete architecture: data model, REST API surface, UI component tree, the manifest schema, and the install/enable/prefs/health/async-job flows — with a privilege/security model. Be specific and implementable; prefer extending what exists over inventing new systems.
>
> **Platform context (ground truth — design WITH this, not around it):**
> - SecuBox-DEB: Debian bookworm appliance. ~128 "modules", each a Debian package `secubox-<name>` from a signed apt repo (`apt.secubox.in`). `dpkg` = installed-state truth.
> - Every module already ships a manifest `debian/secubox.yaml`: `{name, category, tier, description, depends, api:{socket,health}, ui:{path}}`. Categories: media, email, ai, iot, communication, publishing, network, security, system, vpn, dashboard, misc. Tiers: lite|standard|pro|all (the board has a tier; modules above it are locked).
> - Module anatomy: a FastAPI control-plane `api/main.py` (mounted by a central **aggregator** at `/api/v1/<name>/`, served over a unix socket, running as unprivileged user `secubox`), an nginx vhost fragment, an optional `sbin/<name>ctl` CLI, an optional `lib/<name>/install-lxc.sh` that provisions the app inside an **LXC container** (9 apps today: lyrion, peertube, photoprism, jellyfin, grafana, mqtt, yacy, rustdesk, zigbee), a `conf/<name>.toml.example`, a hub menu entry `menu.d/NNN-<name>.json`, and a static dashboard `www/<name>/`. A central **hub** dashboard aggregates everything.
> - Hard constraints: the API process is unprivileged (`secubox` user, NoNewPrivileges) — it CANNOT apt-install, run lxc/systemctl, or edit /etc/nginx; privileged actions must go through a separate root path. Security model is CSPN/ANSSI-oriented: append-only audit log, double-buffer/4R for sensitive config writes (shadow→validate→atomic-swap→rollback), AppArmor, no `waf_bypass` (all public exposure routes through an HAProxy→mitmproxy WAF chain). Installs take minutes (apt + LXC debootstrap) so lifecycle ops must be asynchronous with progress.
>
> **Design deliverables:**
> 1. **Catalog/data model:** how to merge apt-available dpkg-installed into a categorized, tiered, searchable catalog; the per-module state machine (available→installed→enabled→running, plus update-available/error/tier-locked) and health model (service/LXC/socket/resources).
> 2. **Manifest schema v2:** an additive `appstore:` block (icon, summary, long_description, screenshots, lxc:bool, exposure: none|optional|required, default_ports, resources, conflicts, homepage) that keeps the 128 existing manifests valid.
> 3. **REST API:** read endpoints (`/catalog`, `/module/{name}`, `/health`, `/jobs/{id}`) and mutating endpoints (install, uninstall, enable, disable, restart, prefs GET/PUT, expose) — request/response shapes.
> 4. **Privilege bridge + async jobs:** compare (a) a narrow sudoers rule to a root `secubox-appstorectl <verb> <name>` vs (b) a root job-queue worker consuming a spool dir; pick one for CSPN auditability; define the job/progress model and the install pipeline stages (download→install→provision-lxc→enable→health-check).
> 5. **Web UI component tree:** CatalogGrid/AppCard, Category/Tier/Search/State filters, AppDetailDrawer (description, screenshots, ActionBar, schema-driven PrefsForm, HealthPanel, LogViewer, JobProgress), and a cross-module "Live Care" health board with bulk restart/repair/update-all.
> 6. **Prefs editing:** how to render a schema-driven form from `conf/<name>.toml.example` (or a declared JSON-schema), validate, and write via the double-buffer/4R path.
> 7. **Decisions:** new `secubox-appstore` package vs extend the hub; poll vs SSE for job progress; tier hard-block vs upsell; LXC uninstall data-retention.
>
> Produce: an architecture diagram (ASCII), the manifest v2 schema, the full API table, the privilege/async design, the UI component tree, and a 45 step incremental build plan where each step ships something usable. Headline apps to use as worked examples: lyrion (LXC music), peertube (LXC video), nextcloud (cloud), webmail/mail, kbin/gotosocial (fediverse).
---
## 10. GPT prompt — COMPLETE (architectural research notes) · copy-paste
> **Role.** You are a distributed-systems architect. Write **architectural research notes / a design sketch** (not production code) for the **"SecuBox App Store + Module Composer"** — a web UI and control plane to discover, install, enable/disable (at multiple granularities), configure, profile, monitor, and **federate over a P2P mesh** every module of a Debian security appliance. Prefer extending what already exists over inventing new systems. Be concrete, opinionated, and implementable; call out trade-offs and pick.
>
> **Platform ground truth (design WITH this):**
> - SecuBox-DEB: Debian bookworm appliance. ~128 "modules"; each is a Debian package `secubox-<name>` from a signed apt repo (`apt.secubox.in`); `dpkg` = installed truth.
> - Each module already ships a manifest `debian/secubox.yaml` = `{name, category, tier, description, depends, api:{socket,health}, ui:{path}}`. Categories: media, email, ai, iot, communication, publishing, network, security, system, vpn, dashboard, misc. Tiers: lite|standard|pro|all (the board has a tier; higher-tier modules are locked).
> - Module anatomy: a FastAPI control-plane `api/main.py` (mounted by a central **aggregator** at `/api/v1/<name>/` over a unix socket, running as **unprivileged user `secubox`, NoNewPrivileges**), an nginx vhost fragment, an optional `sbin/<name>ctl` CLI, an optional `lib/<name>/install-lxc.sh` that provisions the app in an **LXC** (9 LXC apps today: lyrion, peertube, photoprism, jellyfin, grafana, mqtt, yacy, rustdesk, zigbee), a `conf/<name>.toml.example`, a hub menu entry `menu.d/NNN-<name>.json`, and a static dashboard `www/<name>/`. A central **hub** aggregates everything.
> - **Hard constraints:** the API process is unprivileged — it CANNOT apt-install, run lxc/systemctl, or edit /etc/nginx; privileged actions go through a separate root path. CSPN/ANSSI security: append-only audit log; **double-buffer/4R** for sensitive config writes (shadow→validate→atomic-swap→rollback); AppArmor; **no `waf_bypass`** (all public exposure routes through an HAProxy→mitmproxy WAF chain). Installs take minutes (apt + LXC debootstrap) → lifecycle ops must be **asynchronous with progress**.
> - **Mesh ("gondwana"):** the appliance is one of several nodes (today: gk2=master/`10.10.0.1`, c3box=`.2`, amd64=`.3`) on a WireGuard mesh `10.10.0.0/24:51822` owned by module `secubox-p2p`. The mesh has node identity (wg pubkey + node-id + DDNS name `<boxname>.secubox.in`) and a planned **distributed directory** (replicated peers/services/name records, DNS-like) plus per-node service naming `<service>.<boxname>.secubox.in` routed via the hub.
>
> **Design the following (produce research notes for each):**
> 1. **Catalog & state.** Merge apt-available dpkg-installed into a categorized, tiered, searchable catalog. Per-module state machine: available→installed→enabled→running, plus update-available/error/tier-locked. Health model: service active / LXC state / socket up / last `/health` / mem-disk.
> 2. **Manifest schema v2 (additive, keeps 128 manifests valid):** an `appstore:` block (icon, summary, long_description, screenshots, `lxc:bool`, `exposure: none|optional|required`, default_ports, resources, conflicts, homepage) **and a `components:` list** declaring toggleable sub-features (mapped to the module's own config flags / sub-services).
> 3. **Granular control — four levels, each independently toggleable:** L0 module (install/enable/disable whole package+LXC); L1 component (sub-features within a running module); L2 navbar/menu (show/hide/reorder `menu.d` entries, independent of whether the module runs); L3 appearance (theme/palette/layout/branding). Define the data model + API for each.
> 4. **Profiles.** A named, versioned bundle of `{enabled modules, per-module component flags, navbar layout+order, appearance, exposure}`. Built-in presets (Minimal, Media Center, Security Ops, Home/Family, Headless/Kiosk) that can align to tier; custom create/clone/edit/export/import. **Apply = one atomic transaction via double-buffer/4R** with one-click rollback. Scope: a per-board active profile AND optional per-user profiles (master users see different navbars/apps — ties to identity). Storage + API (`/profiles`, `apply`, `rollback`, `active`).
> 5. **Lifecycle API + privilege bridge + async jobs.** REST read endpoints (`/catalog`, `/module/{name}`, `/health`, `/jobs/{id}`) and mutating ones (install, uninstall, enable, disable, restart, prefs GET/PUT, expose). The unprivileged API must reach root work: compare (a) a narrow sudoers rule to a root `secubox-appstorectl <verb> <name>` vs (b) a root **job-queue worker** consuming a spool dir; pick one for CSPN auditability. Define the job/progress model and install pipeline stages (download→install→provision-lxc→enable→health-check).
> 6. **Prefs editing.** Render a schema-driven form from `conf/<name>.toml.example` (or a declared JSON-schema); validate; write via the 4R double-buffer path.
> 7. **P2P distribution (rides the mesh).** Make `apt.secubox.in` **P2P-mirrored**: each node holds a GPG-signed mirror synced over the wg-mesh (rsync/content-addressed deltas); install source preference local→mesh-peer→upstream; **survives the master being offline**; never trust an unsigned peer mirror (verify `InRelease`). Make the **catalog federated**: each node advertises held package versions + running services (this is the distributed directory's first consumer); the UI shows a mesh-wide view ("install here" vs "running on c3box").
> 8. **Multi-service agents (serve all network clients).** Each node runs its modules as agents serving its LAN clients; the mesh makes any service reachable from any node so clients are served from the nearest node (service mirroring + access redundancy). Design a thin agent/orchestration layer: health/who-runs-what gossip, failover (dead service → route to a mesh replica), placement advice (install on the best-suited node), client access via `<service>.<boxname>.secubox.in`. App store = control plane; mesh agents = data plane of one distributed appliance.
> 9. **Web UI component tree.** CatalogGrid/AppCard; Category/Tier/Search/State filters; AppDetailDrawer (description, screenshots, ActionBar, schema-driven PrefsForm, component toggles, HealthPanel, LogViewer, JobProgress); a Profiles manager; a cross-module **Live Care** board (what's down/degraded, bulk restart/repair/update-all, mesh-wide). Palette: C3BOX (cosmos-black/gold-hermetic/cyber-cyan; Cinzel + JetBrains Mono).
> 10. **Key decisions to resolve:** new `secubox-appstore` package vs extend the hub; job progress transport (poll vs SSE/WebSocket); tier hard-block vs upsell; LXC uninstall data-retention; mesh-sync protocol for the repo and the directory.
>
> **Output format (research notes):** an ASCII architecture diagram (UI ↔ appstore API ↔ root worker ↔ apt/lxc/systemd, plus the mesh: mirror sync + federated catalog + agents); the manifest v2 + `components:` schema; the full REST API table; the profile/4R model; the P2P mirror + federated-catalog + agent protocol; the privilege/async-job design; the UI component tree; and a phased, each-step-shippable build plan. Use these as worked examples throughout: **lyrion** (LXC music), **peertube** (LXC video), **nextcloud** (cloud), **webmail/mail**, **kbin/gotosocial** (fediverse).

View File

@ -0,0 +1,238 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""
SecuBox-Deb :: secubox-appstore :: catalog API (Phase A read-only)
Serves a categorized, tiered, searchable catalog of SecuBox modules by
merging the baked manifest catalog (generated at build from every module's
debian/secubox.yaml) with live runtime state (dpkg installed/version +
systemctl active). Runs unprivileged (user `secubox`): all state queries are
read-only. Install/enable/prefs/profiles are later phases (a root worker).
"""
import os
import json
import time
import subprocess
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
CATALOG_FILE = Path(os.environ.get(
"APPSTORE_CATALOG", "/usr/share/secubox/appstore/catalog.json"))
TIER_RANK = {"all": 0, "lite": 1, "standard": 2, "pro": 3}
_STATE_TTL = 30.0
_state_cache = {"ts": 0.0, "data": {}}
app = FastAPI(title="secubox-appstore", version="0.1.0",
root_path="/api/v1/appstore")
def board_tier() -> str:
"""Best-effort board tier; defaults to 'pro' (unlock all) when unset."""
t = os.environ.get("SECUBOX_TIER")
if t:
return t.strip()
try:
for line in open("/etc/secubox/secubox.conf", encoding="utf-8"):
s = line.strip()
if s.lower().startswith("tier") and "=" in s:
return s.split("=", 1)[1].strip().strip('"').strip("'")
except Exception:
pass
return "pro"
def load_catalog() -> list:
try:
return json.loads(CATALOG_FILE.read_text(encoding="utf-8")).get("modules", [])
except Exception:
return []
def _dpkg_state() -> dict:
out = {}
try:
r = subprocess.run(
["dpkg-query", "-W", "-f=${Package}\t${db:Status-Abbrev}\t${Version}\n", "secubox-*"],
capture_output=True, text=True, timeout=10)
for line in r.stdout.splitlines():
p = line.split("\t")
if len(p) >= 3:
out[p[0]] = {"installed": p[1].strip().startswith("ii"), "version": p[2].strip()}
except Exception:
pass
return out
def _svc_active(names: list) -> dict:
out = {}
if not names:
return out
try:
units = [f"{n}.service" for n in names]
r = subprocess.run(["systemctl", "is-active", *units],
capture_output=True, text=True, timeout=10)
for n, st in zip(names, r.stdout.splitlines()):
out[n] = (st.strip() == "active")
except Exception:
pass
return out
def compute_state(force: bool = False) -> dict:
now = time.time()
if not force and _state_cache["data"] and (now - _state_cache["ts"] < _STATE_TTL):
return _state_cache["data"]
catalog = load_catalog()
dpkg = _dpkg_state()
installed_names = [m["name"] for m in catalog if dpkg.get(m["name"], {}).get("installed")]
active = _svc_active(installed_names)
brank = TIER_RANK.get(board_tier(), 2)
result = {}
for m in catalog:
name = m["name"]
d = dpkg.get(name, {})
installed = bool(d.get("installed"))
running = bool(active.get(name))
tier = m.get("tier", "lite")
tier_locked = (tier != "all") and (TIER_RANK.get(tier, 1) > brank)
if not installed:
state = "tier-locked" if tier_locked else "available"
elif running:
state = "running"
else:
state = "installed"
result[name] = {
**m,
"installed": installed,
"running": running,
"version": d.get("version"),
"tier_locked": tier_locked,
"state": state,
}
_state_cache["ts"] = now
_state_cache["data"] = result
return result
@app.get("/health")
async def health():
return {"ok": True, "module": "appstore",
"catalog_count": len(load_catalog()), "board_tier": board_tier()}
@app.get("/categories")
async def categories():
st = compute_state()
cats: dict = {}
for m in st.values():
cats[m["category"]] = cats.get(m["category"], 0) + 1
return {
"categories": [{"name": k, "count": v} for k, v in sorted(cats.items())],
"tiers": ["lite", "standard", "pro", "all"],
"states": ["available", "installed", "running", "tier-locked"],
"board_tier": board_tier(),
}
@app.get("/catalog")
async def catalog(category: Optional[str] = None, tier: Optional[str] = None,
state: Optional[str] = None, q: Optional[str] = None):
st = compute_state()
items = list(st.values())
if category:
items = [m for m in items if m["category"] == category]
if tier:
items = [m for m in items if m["tier"] == tier]
if state:
items = [m for m in items if m["state"] == state]
if q:
ql = q.lower()
items = [m for m in items
if ql in m["name"].lower() or ql in (m.get("description") or "").lower()]
items.sort(key=lambda m: (m["category"], m["name"]))
return {"modules": items, "count": len(items), "total": len(st), "board_tier": board_tier()}
@app.get("/module/{name}")
async def module(name: str):
st = compute_state()
if name not in st:
alt = f"secubox-{name}"
if alt in st:
name = alt
else:
raise HTTPException(status_code=404, detail=f"unknown module {name!r}")
return dict(st[name])
# ── Lifecycle + config (Phase B/C) ──────────────────────────────────────────
# The API runs as the unprivileged `secubox` user; all privileged work goes
# through the validated root helper /usr/sbin/secubox-appstorectl via a narrow
# sudoers rule (start/stop/restart/enable/disable + write-config only).
ACTIONS = {"start", "stop", "restart", "enable", "disable"}
APPSTORECTL = "/usr/sbin/secubox-appstorectl"
class ConfigIn(BaseModel):
content: str
def _resolve(name: str, st: dict) -> str:
if name in st:
return name
alt = f"secubox-{name}"
if alt in st:
return alt
raise HTTPException(status_code=404, detail=f"unknown module {name!r}")
def _appstorectl(args, input_text=None):
try:
r = subprocess.run(["sudo", "-n", APPSTORECTL, *args], input=input_text,
capture_output=True, text=True, timeout=90)
return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip()
except Exception as e: # noqa: BLE001
return 1, "", str(e)
def _config_path(name: str) -> Path:
short = name[len("secubox-"):] if name.startswith("secubox-") else name
return Path(f"/etc/secubox/{short}.toml")
@app.post("/module/{name}/action/{verb}")
async def module_action(name: str, verb: str):
name = _resolve(name, compute_state())
if verb not in ACTIONS:
raise HTTPException(status_code=400, detail=f"unknown action {verb!r}")
rc, out, err = _appstorectl([verb, name])
if rc != 0:
raise HTTPException(status_code=500, detail=f"{verb} failed: {err or out}")
new = compute_state(force=True).get(name, {})
return {"status": "ok", "action": verb, "module": name,
"state": new.get("state"), "running": new.get("running"), "message": out}
@app.get("/module/{name}/config")
async def get_config(name: str):
name = _resolve(name, compute_state())
p = _config_path(name)
try:
return {"module": name, "path": str(p), "exists": True,
"readable": True, "content": p.read_text(encoding="utf-8")}
except FileNotFoundError:
return {"module": name, "path": str(p), "exists": False, "readable": True, "content": ""}
except PermissionError:
return {"module": name, "path": str(p), "exists": True, "readable": False, "content": ""}
@app.put("/module/{name}/config")
async def put_config(name: str, body: ConfigIn):
name = _resolve(name, compute_state())
rc, out, err = _appstorectl(["write-config", name], input_text=body.content)
if rc != 0:
raise HTTPException(status_code=400, detail=f"config write failed: {err or out}")
return {"status": "ok", "module": name, "message": out}

View File

@ -0,0 +1,50 @@
secubox-appstore (0.2.2-1~bookworm1) bookworm; urgency=medium
* fix(menu): install the navbar entry to /usr/share/secubox/menu.d (the
dir the hub reads) instead of /etc/secubox/menu.d, and place it in the
'root' navbar section (order 10) so the App Store is prominent.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 20:00:00 +0200
secubox-appstore (0.2.1-1~bookworm1) bookworm; urgency=medium
* fix: service needs NoNewPrivileges=no so the API can sudo the root
helper (the narrow sudoers rule + helper validation are the boundary);
postinst now try-restarts the service on upgrade so new code loads.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 19:30:00 +0200
secubox-appstore (0.2.0-1~bookworm1) bookworm; urgency=medium
* Phase B/C + navbar integration.
- UI: integrated shared sidebar (nav#sidebar + sidebar.js + crt-engine),
per-service quick actions (start/stop/restart/enable/disable) and a
config editor drawer (view/edit the module TOML, shows dependencies).
- api: POST /module/{name}/action/{verb}; GET/PUT /module/{name}/config.
- sbin/secubox-appstorectl: validated root helper (secubox-* units +
/etc/secubox/<name>.toml only; TOML-validates writes). Narrow sudoers
rule lets the unprivileged API invoke it.
- postinst rebuilds the hub menu cache so the App Store shows in the navbar.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 19:00:00 +0200
secubox-appstore (0.1.1-1~bookworm1) bookworm; urgency=medium
* fix(nginx): serve the catalog API from secubox-routes.d/ (the
authoritative /api/v1/ include) instead of secubox.d/, so it wins over
the generic /api/ -> aggregator catch-all. UI stays in secubox.d/.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 18:00:00 +0200
secubox-appstore (0.1.0-1~bookworm1) bookworm; urgency=medium
* Initial release — App Store Phase A (read-only catalog).
- api/main.py: GET /catalog (category/tier/state/q filters), /module/{name},
/categories, /health; merges the baked manifest catalog with live dpkg +
systemctl state (available / installed / running / tier-locked).
- catalog.json generated at build from every module's debian/secubox.yaml.
- www/appstore: categorized, tiered, searchable grid UI (read-only).
- Served by secubox-appstore.service on /run/secubox/appstore.sock; nginx
routes /api/v1/appstore/ + static /appstore/. No aggregator dependency.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 17:30:00 +0200

View File

@ -0,0 +1,15 @@
Source: secubox-appstore
Section: admin
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13), python3
Standards-Version: 4.6.2
Package: secubox-appstore
Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, sudo
Description: SecuBox App Store — module catalog & lifecycle (Phase A)
Categorized, tiered, searchable catalog of SecuBox modules with live
install/run state, served as a hub web UI. Phase A is read-only; install,
enable/disable, preferences and profiles arrive in later phases via a
privileged worker.

View File

@ -0,0 +1,19 @@
#!/bin/sh
set -e
case "$1" in
configure)
install -d -m 0755 /usr/share/secubox/appstore /var/log/secubox /var/lib/secubox 2>/dev/null || true
# surface the App Store in the hub navbar (menu cache rebuild)
rm -f /var/cache/secubox/menu.json 2>/dev/null || true
# validate the sudoers drop-in (never leave a broken one)
visudo -cf /etc/sudoers.d/secubox-appstore >/dev/null 2>&1 || rm -f /etc/sudoers.d/secubox-appstore
systemctl daemon-reload 2>/dev/null || true
systemctl enable --now secubox-appstore.service 2>/dev/null || true
systemctl try-restart secubox-appstore.service 2>/dev/null || true
if systemctl is-active --quiet nginx 2>/dev/null; then
nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true
fi
;;
esac
#DEBHELPER#
exit 0

View File

@ -0,0 +1,9 @@
#!/bin/sh
set -e
case "$1" in
remove|deconfigure)
systemctl stop secubox-appstore.service 2>/dev/null || true
;;
esac
#DEBHELPER#
exit 0

View File

@ -0,0 +1,43 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_install:
# API module (uvicorn api.main:app)
install -d $(CURDIR)/debian/secubox-appstore/usr/lib/secubox/appstore/api
cp -r $(CURDIR)/api/* $(CURDIR)/debian/secubox-appstore/usr/lib/secubox/appstore/api/
# Web UI
install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/www/appstore
cp -r $(CURDIR)/www/appstore/* $(CURDIR)/debian/secubox-appstore/usr/share/secubox/www/appstore/
# nginx route + static
install -d $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox.d
install -m 644 $(CURDIR)/nginx/appstore.conf $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox.d/
# Hub menu entry
install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/menu.d
install -m 644 $(CURDIR)/menu.d/580-appstore.json $(CURDIR)/debian/secubox-appstore/usr/share/secubox/menu.d/
# systemd service
install -d $(CURDIR)/debian/secubox-appstore/usr/lib/systemd/system
install -m 644 $(CURDIR)/debian/secubox-appstore.service $(CURDIR)/debian/secubox-appstore/usr/lib/systemd/system/
# Authoritative API route (secubox-routes.d)
install -d $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox-routes.d
install -m 644 $(CURDIR)/nginx/appstore-routes.conf $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox-routes.d/
# Privileged controller + narrow sudoers rule
install -d $(CURDIR)/debian/secubox-appstore/usr/sbin
install -m 755 $(CURDIR)/sbin/secubox-appstorectl $(CURDIR)/debian/secubox-appstore/usr/sbin/
install -d $(CURDIR)/debian/secubox-appstore/etc/sudoers.d
install -m 440 $(CURDIR)/debian/secubox-appstore.sudoers $(CURDIR)/debian/secubox-appstore/etc/sudoers.d/secubox-appstore
# Generate the catalog index from every sibling module's debian/secubox.yaml
install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/appstore
python3 $(CURDIR)/scripts/gen-appstore-catalog.py \
$(CURDIR)/debian/secubox-appstore/usr/share/secubox/appstore/catalog.json
# Runtime dir
install -d $(CURDIR)/debian/secubox-appstore/run/secubox

View File

@ -0,0 +1,27 @@
[Unit]
Description=SecuBox App Store — module catalog API
After=network.target secubox-core.service
Requires=secubox-core.service
[Service]
Type=simple
User=secubox
Group=secubox
RuntimeDirectory=secubox
RuntimeDirectoryPreserve=yes
ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/appstore.sock --workers 1
WorkingDirectory=/usr/lib/secubox/appstore
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
# sudo->secubox-appstorectl bridge requires new privileges; the narrow
# sudoers rule + helper validation are the security boundary.
NoNewPrivileges=no
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,3 @@
# secubox-appstore: allow the unprivileged API user to drive module lifecycle
# and config writes ONLY through the validated root helper.
secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-appstorectl

View File

@ -0,0 +1,15 @@
# debian/secubox.yaml
name: secubox-appstore
category: system
tier: all
description: "SecuBox App Store — module catalog, install/enable, profiles"
depends:
- secubox-core
api:
socket: /run/secubox/appstore.sock
health: /api/v1/appstore/health
ui:
path: /appstore/

View File

@ -0,0 +1,10 @@
{
"id": "appstore",
"name": "App Store",
"icon": "🛍️",
"path": "/appstore/",
"category": "root",
"order": 10,
"description": "Install, enable & configure SecuBox modules",
"requires": ["secubox-appstore"]
}

View File

@ -0,0 +1,9 @@
# /etc/nginx/secubox-routes.d/appstore.conf — Installed by secubox-appstore.
# App Store catalog API, served by the standalone secubox-appstore.service
# over /run/secubox/appstore.sock (no aggregator dependency).
location /api/v1/appstore/ {
rewrite ^/api/v1/appstore/(.*)$ /$1 break;
proxy_pass http://unix:/run/secubox/appstore.sock;
include /etc/nginx/snippets/secubox-proxy.conf;
proxy_intercept_errors on;
}

View File

@ -0,0 +1,9 @@
# /etc/nginx/secubox.d/appstore.conf — Installed by secubox-appstore.
# Static App Store UI. The /api/v1/appstore/ route lives in
# secubox-routes.d/appstore.conf (the authoritative API include).
location /appstore/ {
alias /usr/share/secubox/www/appstore/;
index index.html;
try_files $uri $uri/ /appstore/index.html;
}

View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# SecuBox-Deb :: secubox-appstore :: privileged module controller.
# Invoked by the unprivileged secubox-appstore API via a narrow sudoers
# rule. ONLY operates on secubox-*.service units and /etc/secubox/<name>.toml.
set -euo pipefail
action="${1:-}"
module="${2:-}"
# Strict allow-list: module name must be secubox-<slug>, nothing else.
case "$module" in
secubox-[a-z0-9]*) : ;;
*) echo "appstorectl: invalid module name" >&2; exit 2 ;;
esac
unit="${module}.service"
short="${module#secubox-}"
case "$action" in
start|stop|restart|enable|disable)
exec systemctl "$action" "$unit"
;;
write-config)
dest="/etc/secubox/${short}.toml"
tmp="$(mktemp /etc/secubox/.${short}.toml.XXXXXX)"
trap 'rm -f "$tmp"' EXIT
cat > "$tmp"
# Reject anything that is not valid TOML.
python3 -c 'import tomllib,sys; tomllib.load(open(sys.argv[1],"rb"))' "$tmp" \
|| { echo "appstorectl: invalid TOML, refusing to write" >&2; exit 3; }
# Preserve the conventional ownership/perms for SecuBox config.
chown root:secubox "$tmp"
chmod 0640 "$tmp"
mv -f "$tmp" "$dest"
trap - EXIT
echo "wrote $dest"
;;
*)
echo "appstorectl: unknown action '$action'" >&2; exit 2 ;;
esac

View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""SecuBox-Deb :: secubox-appstore :: catalog generator.
Scan every module's debian/secubox.yaml in the monorepo and emit a flat
catalog.json (the App Store's "available" set). Run at package build time
from packages/secubox-appstore (siblings live at ../*/debian/secubox.yaml).
Dependency-free: parses the small flat manifests without PyYAML.
"""
import json, sys, glob, os, re
def parse_manifest(path):
m = {"depends": []}
in_depends = False
for raw in open(path, encoding="utf-8", errors="replace"):
line = raw.rstrip("\n")
if not line.strip() or line.lstrip().startswith("#"):
continue
# top-level key: value (no leading space)
mt = re.match(r"^([a-z_]+):\s*(.*)$", line)
if mt:
key, val = mt.group(1), mt.group(2).strip()
in_depends = (key == "depends")
if key in ("name", "category", "tier", "description") and val:
m[key] = val.strip().strip('"').strip("'")
continue
# list item under depends:
li = re.match(r"^\s+-\s+(.*)$", line)
if li and in_depends:
m["depends"].append(li.group(1).strip().strip('"').strip("'"))
return m
def main(out):
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
catalog = []
for path in sorted(glob.glob(os.path.join(base, "*", "debian", "secubox.yaml"))):
# skip generated build-tree copies (debian/<pkg>/...)
if "/debian/" in path and path.count("/debian/") > 1:
continue
m = parse_manifest(path)
if not m.get("name"):
continue
catalog.append({
"name": m["name"],
"category": m.get("category", "misc"),
"tier": m.get("tier", "lite"),
"description": m.get("description", ""),
"depends": m.get("depends", []),
})
# de-dup by name (keep first)
seen, uniq = set(), []
for c in catalog:
if c["name"] in seen:
continue
seen.add(c["name"]); uniq.append(c)
data = {"version": 1, "modules": uniq, "count": len(uniq)}
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"wrote {out}: {len(uniq)} modules")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "catalog.json")

View File

@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox · App Store</title>
<link rel="stylesheet" href="/shared/design-tokens.css">
<link rel="stylesheet" href="/shared/crt-light.css">
<style>
.as-bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:14px 0}
.as-bar input,.as-bar select{background:var(--panel,#13131c);border:1px solid var(--line,#2a2a3a);
color:var(--text,#e8e6d9);padding:8px 10px;border-radius:6px;font-family:inherit;font-size:13px}
.as-bar input{min-width:220px}
.as-stat{color:var(--text-muted,#6b6b7a);font-size:12px;margin-left:auto}
.as-stat b{color:var(--cyber-cyan,#00d4ff)}
.as-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px}
.as-card{background:var(--panel,#13131c);border:1px solid var(--line,#2a2a3a);border-radius:10px;
padding:14px;display:flex;flex-direction:column;gap:8px}
.as-card:hover{border-color:var(--gold-hermetic,#c9a84c)}
.as-top{display:flex;align-items:center;gap:10px}
.as-ico{font-size:24px}
.as-name{font-weight:700;font-size:14px;word-break:break-word}
.as-desc{color:var(--text-muted,#6b6b7a);font-size:12px;min-height:30px;line-height:1.4}
.as-badges{display:flex;gap:6px;flex-wrap:wrap}
.pill{font-size:10px;padding:2px 8px;border-radius:20px;border:1px solid var(--line,#2a2a3a);text-transform:uppercase;letter-spacing:.4px}
.pill.cat{color:var(--cyber-cyan,#00d4ff)} .pill.tier{color:var(--gold-hermetic,#c9a84c)}
.pill.s-running{color:var(--matrix-green,#00ff41);border-color:#1d5a2a}
.pill.s-installed{color:var(--cyber-cyan,#00d4ff)}
.pill.s-available{color:var(--text-muted,#6b6b7a)}
.pill.s-tier-locked{color:var(--cinnabar,#e63946);border-color:#5a1d23}
.as-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto}
.as-btn{background:transparent;border:1px solid var(--line,#2a2a3a);color:var(--text,#e8e6d9);
padding:5px 10px;border-radius:6px;font-family:inherit;font-size:11px;cursor:pointer}
.as-btn:hover{border-color:var(--gold-hermetic,#c9a84c)}
.as-btn.danger{color:var(--cinnabar,#e63946)} .as-btn:disabled{opacity:.4;cursor:not-allowed}
.as-over{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;z-index:1000;align-items:center;justify-content:center}
.as-over.on{display:flex}
.as-modal{background:var(--panel,#13131c);border:1px solid var(--line,#2a2a3a);border-radius:12px;
width:min(720px,92vw);max-height:88vh;overflow:auto;padding:18px}
.as-modal h2{font-family:'Cinzel',serif;color:var(--gold-hermetic,#c9a84c);margin:.2em 0}
.as-modal textarea{width:100%;min-height:300px;background:#0a0a0f;color:var(--text,#e8e6d9);
border:1px solid var(--line,#2a2a3a);border-radius:8px;font-family:'JetBrains Mono',monospace;font-size:12px;padding:10px}
.as-row{display:flex;gap:8px;justify-content:flex-end;margin-top:10px;align-items:center}
.as-msg{font-size:12px;margin-right:auto;color:var(--text-muted,#6b6b7a)}
.as-deps{color:var(--text-muted,#6b6b7a);font-size:12px;margin:6px 0}
</style>
</head>
<body class="crt-light">
<nav class="sidebar" id="sidebar"></nav>
<script src="/shared/sidebar.js"></script>
<main class="main">
<header class="header">
<div class="header-title">
<h1>🛍️ App Store</h1>
<span class="badge" id="board-badge">tier —</span>
</div>
</header>
<div class="as-bar">
<input id="q" type="search" placeholder="Search modules…" oninput="render()">
<select id="category" onchange="render()"><option value="">All categories</option></select>
<select id="tier" onchange="render()"><option value="">All tiers</option>
<option>lite</option><option>standard</option><option>pro</option><option>all</option></select>
<select id="state" onchange="render()"><option value="">All states</option>
<option value="running">running</option><option value="installed">installed</option>
<option value="available">available</option><option value="tier-locked">tier-locked</option></select>
<span class="as-stat" id="stat">loading…</span>
</div>
<div class="as-grid" id="grid"></div>
</main>
<!-- Config / detail drawer -->
<div class="as-over" id="over">
<div class="as-modal">
<h2 id="m-title">module</h2>
<div class="as-deps" id="m-deps"></div>
<div class="as-actions" id="m-actions" style="margin-bottom:10px"></div>
<label style="font-size:12px;color:var(--text-muted,#6b6b7a)">Config (<span id="m-path"></span>)</label>
<textarea id="m-config" spellcheck="false"></textarea>
<div class="as-row">
<span class="as-msg" id="m-msg"></span>
<button class="as-btn" onclick="closeModal()">Close</button>
<button class="as-btn" id="m-save" onclick="saveConfig()">Save config</button>
</div>
</div>
</div>
<script>
const API='/api/v1/appstore';
const ICONS={media:'🎬',email:'✉️',ai:'🧠',iot:'🛰️',communication:'💬',publishing:'📰',
network:'🌐',security:'🛡️',system:'⚙️',vpn:'🔒',dashboard:'📊',monitoring:'📈',misc:'🧩'};
let ALL=[], CUR=null;
async function jget(p){try{const r=await fetch(API+p);if(!r.ok)throw 0;return await r.json();}catch(e){return null;}}
async function jsend(p,m,b){try{const r=await fetch(API+p,{method:m,headers:{'Content-Type':'application/json'},
body:b?JSON.stringify(b):undefined});const j=await r.json().catch(()=>({}));return{ok:r.ok,j};}catch(e){return{ok:false,j:{detail:String(e)}};}}
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':s;return d.innerHTML;}
async function load(){
const cats=await jget('/categories');
if(cats&&cats.categories){const sel=document.getElementById('category');
cats.categories.forEach(c=>{const o=document.createElement('option');o.value=c.name;o.textContent=`${c.name} (${c.count})`;sel.appendChild(o);});}
const data=await jget('/catalog');
ALL=(data&&Array.isArray(data.modules))?data.modules:[];
document.getElementById('board-badge').textContent='tier '+((cats&&cats.board_tier)||'?');
render();
}
function actionsFor(m){
if(!m.installed) return `<button class="as-btn" disabled title="Install arrives in a later phase">Install</button>`;
const b=[];
if(m.running){ b.push(`<button class="as-btn" onclick="act('${m.name}','restart')">Restart</button>`);
b.push(`<button class="as-btn danger" onclick="act('${m.name}','stop')">Stop</button>`); }
else { b.push(`<button class="as-btn" onclick="act('${m.name}','start')">Start</button>`); }
b.push(`<button class="as-btn" onclick="act('${m.name}','enable')">Enable</button>`);
b.push(`<button class="as-btn danger" onclick="act('${m.name}','disable')">Disable</button>`);
b.push(`<button class="as-btn" onclick="openModal('${m.name}')">Config</button>`);
return b.join('');
}
function render(){
const q=document.getElementById('q').value.toLowerCase();
const cat=document.getElementById('category').value, tier=document.getElementById('tier').value, state=document.getElementById('state').value;
let items=ALL.filter(m=>{
if(cat&&m.category!==cat)return false; if(tier&&m.tier!==tier)return false; if(state&&m.state!==state)return false;
if(q&&!(m.name.toLowerCase().includes(q)||(m.description||'').toLowerCase().includes(q)))return false; return true;});
const running=ALL.filter(m=>m.state==='running').length, installed=ALL.filter(m=>m.installed).length;
document.getElementById('stat').innerHTML=`showing <b>${items.length}</b>/${ALL.length} · installed <b>${installed}</b> · running <b>${running}</b>`;
const grid=document.getElementById('grid');
if(!items.length){grid.innerHTML='<div style="color:var(--text-muted,#6b6b7a);padding:30px">No modules match.</div>';return;}
grid.innerHTML=items.map(m=>{
const ico=ICONS[m.category]||ICONS.misc, label=m.name.replace(/^secubox-/,''), st=m.state||'available';
return `<div class="as-card">
<div class="as-top"><span class="as-ico">${ico}</span><span class="as-name">${esc(label)}</span></div>
<div class="as-desc">${esc(m.description||'')}</div>
<div class="as-badges"><span class="pill cat">${esc(m.category)}</span>
<span class="pill tier">${esc(m.tier)}</span><span class="pill s-${st}">${st}</span>
${m.version?`<span class="pill">v${esc(m.version)}</span>`:''}</div>
<div class="as-actions">${actionsFor(m)}</div>
</div>`;}).join('');
}
async function act(name,verb){
const r=await jsend(`/module/${name}/action/${verb}`,'POST');
if(!r.ok){alert(`${verb} failed: ${(r.j&&r.j.detail)||'error'}`);return;}
const data=await jget('/catalog'); ALL=(data&&Array.isArray(data.modules))?data.modules:ALL; render();
if(CUR&&CUR===name) renderModalActions();
}
async function openModal(name){
CUR=name; document.getElementById('m-title').textContent=name.replace(/^secubox-/,'');
const m=ALL.find(x=>x.name===name)||{};
document.getElementById('m-deps').innerHTML='Depends: '+((m.depends&&m.depends.length)?m.depends.map(esc).join(', '):'—');
renderModalActions();
document.getElementById('m-msg').textContent='loading config…';
document.getElementById('m-config').value='';
document.getElementById('over').classList.add('on');
const cfg=await jget(`/module/${name}/config`);
const ta=document.getElementById('m-config'), save=document.getElementById('m-save');
document.getElementById('m-path').textContent=(cfg&&cfg.path)||'';
if(cfg&&cfg.readable===false){ta.value='';ta.disabled=true;save.disabled=true;document.getElementById('m-msg').textContent='config not readable by appstore';}
else if(cfg&&!cfg.exists){ta.value='';ta.disabled=false;save.disabled=false;document.getElementById('m-msg').textContent='no config file yet — saving creates it';}
else{ta.value=(cfg&&cfg.content)||'';ta.disabled=false;save.disabled=false;document.getElementById('m-msg').textContent='';}
}
function renderModalActions(){const m=ALL.find(x=>x.name===CUR)||{};document.getElementById('m-actions').innerHTML=actionsFor(m);}
function closeModal(){document.getElementById('over').classList.remove('on');CUR=null;}
async function saveConfig(){
if(!CUR)return; const content=document.getElementById('m-config').value;
document.getElementById('m-msg').textContent='saving…';
const r=await jsend(`/module/${CUR}/config`,'PUT',{content});
document.getElementById('m-msg').textContent=r.ok?'saved ✓ (restart the service to apply)':('save failed: '+((r.j&&r.j.detail)||'error'));
}
document.getElementById('over').addEventListener('click',e=>{if(e.target.id==='over')closeModal();});
document.addEventListener('DOMContentLoaded',load);
</script>
<script src="/shared/crt-engine.js"></script>
</body>
</html>

View File

@ -0,0 +1,15 @@
# debian/secubox.yaml
name: secubox-lyrion
category: media
tier: lite
description: "Lyrion Music Server (LMS / Squeezebox) in a Debian LXC"
depends:
- secubox-core
api:
socket: /run/secubox/aggregator.sock
health: /api/v1/lyrion/health
ui:
path: /lyrion/

View File

@ -5,9 +5,17 @@
"""
SecuBox-Deb :: LiveHosts aggregator
Polls the HAProxy admin socket once per minute, ring-buffers per-frontend
request deltas over 60 minutes, and emits a sanitized top-N rollup of the
hostnames being served.
Counts requests per vhost over the last `window_minutes` by reading the
per-vhost nginx access logs (/var/log/nginx/<vhost>_access.log) and parsing
their combined-format timestamps. Emits a top-N rollup plus the total request
count in the window (used as the denominator for the WAF block rate).
Earlier versions polled the HAProxy admin socket per frontend; that only works
when every vhost has its own HAProxy frontend. This deployment funnels all
vhosts through a single `http-in` frontend ( mitmproxy/WAF nginx), so the
authoritative per-vhost signal is the nginx logs. Requires the service user to
be able to read /var/log/nginx (the `adm` group).
"""
from __future__ import annotations
@ -15,22 +23,25 @@ import asyncio
import collections
import json
import logging
import socket
from datetime import datetime, timezone
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
log = logging.getLogger("secubox.live_hosts")
CACHE_PATH = Path("/var/cache/secubox/metrics/live-hosts.json")
DEFAULT_LOG_DIR = "/var/log/nginx"
# nginx combined log timestamp: [29/Jun/2026:17:27:38 +0200]
TS_RE = re.compile(r"\[(\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\]")
TS_FMT = "%d/%b/%Y:%H:%M:%S %z"
# Bound the per-file read so a huge log can't stall the collector. 60 min of a
# busy vhost fits comfortably; older lines beyond this are out of window anyway.
TAIL_BYTES = 3_000_000
class LiveHostsAggregator:
def __init__(self, cfg: dict):
self.cfg = cfg
self._buckets: collections.deque[dict[str, int]] = collections.deque(maxlen=60)
self._prev_totals: dict[str, int] = {}
self._payload: dict = {"enabled": False, "entries": []}
self._refreshed = False
@ -44,13 +55,14 @@ class LiveHostsAggregator:
return json.loads(CACHE_PATH.read_text())
except Exception:
pass
return {"enabled": False, "window_minutes": self.cfg["window_minutes"], "entries": []}
return {"enabled": False, "window_minutes": self.cfg.get("window_minutes", 60),
"entries": [], "total_requests": 0}
async def run_forever(self) -> None:
while True:
try:
self._payload = await self.refresh_once()
except Exception as e:
except Exception as e: # noqa: BLE001
log.warning("refresh_once raised: %s", e)
await asyncio.sleep(60)
@ -58,18 +70,15 @@ class LiveHostsAggregator:
if not self.cfg.get("enabled"):
self._refreshed = True
return self._disabled_payload()
totals = await asyncio.to_thread(self._read_haproxy_stats)
if totals is None:
self._refreshed = True
return self._disabled_payload()
kept = self._filter_frontends(totals)
self._delta_and_buffer(kept)
entries = self._aggregate()
counts, total = await asyncio.to_thread(self._read_nginx_hosts)
top_n = int(self.cfg.get("top_n", 5))
entries = [{"host": h, "count": c} for h, c in counts.most_common(top_n) if c > 0]
payload = {
"enabled": True,
"window_minutes": self.cfg["window_minutes"],
"window_minutes": int(self.cfg.get("window_minutes", 60)),
"generated_at": datetime.now(timezone.utc).isoformat(),
"entries": entries,
"total_requests": total,
}
self._persist(payload)
self._refreshed = True
@ -77,91 +86,46 @@ class LiveHostsAggregator:
# -- helpers --------------------------------------------
def _filter_frontends(self, totals: dict[str, int]) -> dict[str, int]:
flt = self.cfg.get("frontend_filter", "*")
out: dict[str, int] = {}
for name, n in totals.items():
if name.startswith("_"):
continue
if "." not in name:
continue
if flt != "*" and flt not in name:
continue
out[name] = n
return out
def _delta_and_buffer(self, totals: dict[str, int]) -> None:
if not self._prev_totals:
self._buckets.append({k: 0 for k in totals})
self._prev_totals = dict(totals)
return
bucket: dict[str, int] = {}
for host, cur in totals.items():
prev = self._prev_totals.get(host)
if prev is None or cur < prev:
bucket[host] = 0
else:
bucket[host] = cur - prev
self._buckets.append(bucket)
self._prev_totals = dict(totals)
def _aggregate(self) -> list[dict]:
totals: dict[str, int] = collections.Counter()
for bucket in self._buckets:
for host, n in bucket.items():
totals[host] += n
entries = [{"host": h, "count": c} for h, c in totals.items() if c > 0]
entries.sort(key=lambda e: (-e["count"], e["host"]))
return entries[: self.cfg["top_n"]]
def _read_haproxy_stats(self) -> Optional[dict[str, int]]:
sock_path = self.cfg["haproxy_socket"]
if not Path(sock_path).exists():
return None
def _read_nginx_hosts(self) -> tuple[collections.Counter, int]:
log_dir = Path(self.cfg.get("log_dir", DEFAULT_LOG_DIR))
window = int(self.cfg.get("window_minutes", 60))
cutoff = datetime.now(timezone.utc) - timedelta(minutes=window)
counts: collections.Counter = collections.Counter()
total = 0
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(2.0)
s.connect(sock_path)
s.sendall(b"show stat\n")
chunks = []
while True:
data = s.recv(8192)
if not data:
break
chunks.append(data)
blob = b"".join(chunks).decode("utf-8", errors="replace")
except Exception as e:
log.warning("haproxy socket read failed: %s", e)
return None
return self._parse_show_stat(blob)
@staticmethod
def _parse_show_stat(blob: str) -> dict[str, int]:
"""Extract {frontend_name: req_tot} from `show stat` CSV output."""
out: dict[str, int] = {}
lines = blob.splitlines()
if not lines:
return out
header = lines[0].lstrip("# ").split(",")
try:
pxname_i = header.index("pxname")
svname_i = header.index("svname")
req_tot_i = header.index("req_tot")
except ValueError:
return out
for line in lines[1:]:
if not line or line.startswith("#"):
continue
cols = line.split(",")
if len(cols) <= max(pxname_i, svname_i, req_tot_i):
continue
if cols[svname_i] != "FRONTEND":
continue
files = sorted(log_dir.glob("*_access.log"))
except Exception as e: # noqa: BLE001
log.warning("cannot list %s: %s", log_dir, e)
return counts, 0
for f in files:
try:
out[cols[pxname_i]] = int(cols[req_tot_i] or "0")
except ValueError:
size = f.stat().st_size
if size == 0:
continue
host = f.name[: -len("_access.log")]
with open(f, "rb") as fh:
if size > TAIL_BYTES:
fh.seek(size - TAIL_BYTES)
fh.readline() # drop the partial first line
blob = fh.read().decode("utf-8", errors="replace")
except PermissionError:
log.warning("no read permission on %s (add service user to 'adm')", f)
continue
return out
except Exception as e: # noqa: BLE001
log.warning("read %s failed: %s", f, e)
continue
for line in blob.splitlines():
m = TS_RE.search(line)
if not m:
continue
try:
ts = datetime.strptime(m.group(1), TS_FMT)
except ValueError:
continue
if ts >= cutoff:
counts[host] += 1
total += 1
return counts, total
def _persist(self, payload: dict) -> None:
try:
@ -169,13 +133,14 @@ class LiveHostsAggregator:
tmp = CACHE_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload))
tmp.replace(CACHE_PATH)
except Exception as e:
except Exception as e: # noqa: BLE001
log.warning("persist failed: %s", e)
def _disabled_payload(self) -> dict:
return {
"enabled": False,
"window_minutes": self.cfg["window_minutes"],
"window_minutes": int(self.cfg.get("window_minutes", 60)),
"generated_at": datetime.now(timezone.utc).isoformat(),
"entries": [],
"total_requests": 0,
}

View File

@ -536,6 +536,38 @@ async def get_firewall_stats(auth: None = Depends(require_jwt)):
# For the global health banner with smart doctor advisor
# ═══════════════════════════════════════════════════════════════════════════════
def _count_waf_blocks(window_minutes: int = 60) -> int:
"""Count Go-sbxwaf block events in the last window from the live threat
log (/var/log/secubox/waf/waf-threats.log, JSONL with ISO timestamps).
Bounded tail read so a large log can't stall the summary."""
from datetime import datetime, timedelta, timezone
p = Path("/var/log/secubox/waf/waf-threats.log")
try:
size = p.stat().st_size
except Exception:
return 0
if size == 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(minutes=window_minutes)
n = 0
try:
with open(p, "rb") as fh:
if size > 3_000_000:
fh.seek(size - 3_000_000)
fh.readline()
for line in fh.read().decode("utf-8", errors="replace").splitlines():
try:
e = json.loads(line)
ts = datetime.fromisoformat(e["timestamp"])
if ts >= cutoff:
n += 1
except Exception:
continue
except Exception:
pass
return n
def build_health_summary() -> dict:
"""Build aggregated health summary for the health banner."""
@ -626,16 +658,16 @@ def build_health_summary() -> dict:
except Exception:
pass
# Get WAF blocked percentage (estimate from recent logs)
# WAF block rate from the live sbxwaf threat log over the last hour,
# as a share of total traffic (legit nginx requests + blocks).
blocked_pct = 0
waf_blocks_1h = _count_waf_blocks(60)
try:
waf_log = Path('/var/log/mitmproxy/threats.jsonl')
if waf_log.exists():
# Count threats in last hour
result = run_cmd(['wc', '-l', str(waf_log)])
threat_count = int(result.split()[0]) if result else 0
# Rough estimate: 1000 requests/hour baseline
blocked_pct = min(100, threat_count // 10)
lh = live_hosts_agg.current()
total_req = int(lh.get("total_requests", 0)) if isinstance(lh, dict) else 0
denom = total_req + waf_blocks_1h
if denom > 0:
blocked_pct = round(waf_blocks_1h / denom * 100, 1)
except Exception:
pass
@ -683,6 +715,7 @@ def build_health_summary() -> dict:
"modules": modules,
"waf": {
"blocked_pct": blocked_pct,
"blocks_1h": waf_blocks_1h,
"active": waf_stats.get("mitmproxy_running", False)
},
"crowdsec": {

View File

@ -1,3 +1,18 @@
secubox-metrics (1.0.5-1~bookworm1) bookworm; urgency=medium
* fix(metrics): real data for the portal/dashboard.
- live_hosts: source per-vhost request counts from the nginx per-vhost
access logs (windowed) instead of HAProxy frontends — this topology
funnels all vhosts through one frontend, so HAProxy had no per-vhost
signal. Adds total_requests to the payload.
- health/summary WAF block rate now reads the live sbxwaf threat log
(/var/log/secubox/waf/waf-threats.log) over the last hour as a share
of total traffic, replacing the stale /var/log/mitmproxy path + the
count//10 heuristic; adds waf.blocks_1h.
- postinst: add the service user to 'adm' to read /var/log/nginx.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 21:30:00 +0200
secubox-metrics (1.0.4-1~bookworm1) bookworm; urgency=medium
* #494: postinst no longer chowns the shared /run/secubox parent

View File

@ -20,6 +20,8 @@ case "$1" in
# Add secubox to haproxy group for admin-socket access (issue #92)
if getent group haproxy >/dev/null; then
usermod -aG haproxy secubox || true
# adm group: read /var/log/nginx for per-vhost live-hosts metric
usermod -aG adm secubox || true
fi
# Persistent cache dir for live-panel rollups

View File

@ -26,6 +26,8 @@ except ImportError:
async def require_jwt():
return {"sub": "admin"}
from . import mesh
app = FastAPI(
title="SecuBox P2P API",
description="P2P network hub with peer discovery and master-link enrollment",
@ -634,6 +636,10 @@ async def get_status():
peers = [p for p in peers if not p.get("is_local") and p.get("id") != local_id]
online_peers = [p for p in peers if p.get("status") == "online"]
# Mesh view is driven by the wg-mesh transport (wg_mesh.json), which p2p
# now owns (Gondwana Phase 1). The web UI reads total_peers/active_peers.
wg_peer_count = len(mesh.peer_nodes(get_wg_mesh_config()))
# Get master-link status
ml_config = get_ml_config()
@ -643,8 +649,10 @@ async def get_status():
"hostname": get_hostname(),
"lan_ip": get_lan_ip(),
"wan_ip": get_wan_ip(),
"peer_count": len(peers),
"online_peers": len(online_peers),
"peer_count": wg_peer_count or len(peers),
"online_peers": wg_peer_count or len(online_peers),
"total_peers": wg_peer_count or len(peers),
"active_peers": wg_peer_count or len(online_peers),
"service_count": len(services) if isinstance(services, list) else 0,
"threat_count": len(threats) if isinstance(threats, dict) else 0,
"master_link": {
@ -698,18 +706,22 @@ async def get_self():
@app.get("/peers")
async def list_peers():
"""List all known peers (public read)."""
"""List all known peers (public read).
The wg-mesh transport (wg_mesh.json) is the source of truth for the mesh
view (Gondwana Phase 1); fall back to the legacy peers.json registry only
when there are no wg-mesh peers configured.
"""
init_dirs()
nodes = mesh.peer_nodes(get_wg_mesh_config())
if nodes:
return {"peers": nodes, "count": len(nodes)}
peers_data = load_json(PEERS_FILE, {"peers": []})
peers = peers_data.get("peers", []) if isinstance(peers_data, dict) else peers_data
# A node is not its own peer: never insert/persist the local node here.
# (Older versions did, which inflated peer_count and listed "<host> (local)"
# as a phantom peer.) Drop any self entry a prior version may have saved.
# Use /discover/self for the local node's announcement payload instead.
local_id = get_node_id()
peers = [p for p in peers if not p.get("is_local") and p.get("id") != local_id]
return {"peers": peers, "count": len(peers)}
@ -859,15 +871,39 @@ async def unregister_service(name: str, user: dict = Depends(require_jwt)):
@app.get("/mesh")
async def get_mesh_status():
"""Get mesh network topology (public read)."""
init_dirs()
peers_data = load_json(PEERS_FILE, {"peers": []})
peers = peers_data.get("peers", []) if isinstance(peers_data, dict) else peers_data
"""Get mesh network topology (public read).
nodes = []
links = []
Derived from the wg-mesh transport (wg_mesh.json) that p2p owns
(Gondwana Phase 1): the local node is the center, each wg peer is a node,
links go from local to each peer (hub-and-spoke). Falls back to the
legacy peers.json registry when no wg-mesh peers are configured.
"""
init_dirs()
wg = get_wg_mesh_config()
peer_views = mesh.peer_nodes(wg)
local_id = get_node_id()
if peer_views:
local_addr = (wg.get("address") or "").split("/")[0]
local_node = {
"id": local_id,
"name": get_hostname(),
"address": local_addr,
"status": "online",
"is_local": True,
}
nodes = [local_node]
links = []
for pv in peer_views:
nodes.append({**pv, "is_local": False})
links.append({"source": local_id, "target": pv["id"], "status": pv.get("status", "online")})
return {"nodes": nodes, "links": links, "total_nodes": len(nodes), "local_node": local_id}
# Legacy fallback (peers.json registry)
peers_data = load_json(PEERS_FILE, {"peers": []})
peers = peers_data.get("peers", []) if isinstance(peers_data, dict) else peers_data
nodes = []
links = []
for peer in peers:
node = {
"id": peer.get('id', ''),
@ -877,21 +913,9 @@ async def get_mesh_status():
"is_local": peer.get('is_local', False) or peer.get('id') == local_id
}
nodes.append(node)
# Create links from local node to all peers
if not node["is_local"]:
links.append({
"source": local_id,
"target": peer.get('id'),
"status": peer.get('status', 'unknown')
})
return {
"nodes": nodes,
"links": links,
"total_nodes": len(nodes),
"local_node": local_id
}
links.append({"source": local_id, "target": peer.get('id'), "status": peer.get('status', 'unknown')})
return {"nodes": nodes, "links": links, "total_nodes": len(nodes), "local_node": local_id}
# ============== Profiles ==============
@ -975,9 +999,9 @@ async def remove_threat(ip: str, user: dict = Depends(require_jwt)):
# ============== WireGuard Mesh ==============
WG_MESH_CONFIG = P2P_DIR / "wg_mesh.json"
WG_INTERFACE = "wg-mesh"
WG_PORT = 51820
WG_NETWORK = "10.100.0.0/24"
WG_INTERFACE = mesh.MESH_INTERFACE
WG_PORT = mesh.MESH_PORT
WG_NETWORK = mesh.MESH_NETWORK
def get_wg_mesh_config() -> Dict:
@ -1055,11 +1079,16 @@ async def init_wireguard(user: dict = Depends(require_jwt)):
config["private_key"] = private_key
config["public_key"] = public_key
# Assign IP from network (based on node ID)
node_id = get_node_id()
ip_suffix = int(hashlib.md5(node_id.encode()).hexdigest()[:2], 16) % 253 + 1
network_prefix = WG_NETWORK.rsplit('.', 2)[0]
config["address"] = f"{network_prefix}.{ip_suffix}/24"
# Assign mesh IP: .1 for the master role, else allocate from the pool.
p2p_cfg = mesh.load_p2p_config(CONFIG_FILE)
if p2p_cfg["role"] == "master":
addr = "10.10.0.1"
else:
taken = [p.get("allowed_ips", "") for p in config.get("peers", [])]
addr = mesh.allocate_mesh_ip(WG_NETWORK, taken)
config["address"] = f"{addr}/24"
config["role"] = p2p_cfg["role"]
config["ddns"] = mesh.ddns_name(get_hostname())
save_json(WG_MESH_CONFIG, config)
@ -1075,7 +1104,7 @@ async def init_wireguard(user: dict = Depends(require_jwt)):
async def add_wireguard_peer(
public_key: str,
endpoint: str,
allowed_ips: str = "10.100.0.0/24",
allowed_ips: str = "10.10.0.0/24",
user: dict = Depends(require_jwt)
):
"""Add a WireGuard mesh peer."""
@ -1107,40 +1136,21 @@ async def enable_wireguard(user: dict = Depends(require_jwt)):
if not config.get("private_key"):
raise HTTPException(status_code=400, detail="WireGuard not initialized")
# Create interface config
wg_conf = f"""[Interface]
PrivateKey = {config['private_key']}
Address = {config.get('address', '10.100.0.1/24')}
ListenPort = {config.get('listen_port', WG_PORT)}
"""
for peer in config.get("peers", []):
wg_conf += f"""
[Peer]
PublicKey = {peer['public_key']}
Endpoint = {peer['endpoint']}
AllowedIPs = {peer.get('allowed_ips', '10.100.0.0/24')}
PersistentKeepalive = 25
"""
bad = mesh.subnet_overlap(config.get("network", WG_NETWORK))
if bad:
raise HTTPException(status_code=409,
detail=f"mesh network overlaps reserved subnet {bad!r}; refusing")
# Write config and bring up interface
conf_path = Path(f"/etc/wireguard/{WG_INTERFACE}.conf")
conf_path.parent.mkdir(parents=True, exist_ok=True)
conf_path.write_text(wg_conf)
conf_path.chmod(0o600)
try:
subprocess.run(["wg-quick", "down", WG_INTERFACE], capture_output=True, timeout=10)
except:
pass
result = subprocess.run(["wg-quick", "up", WG_INTERFACE], capture_output=True, text=True, timeout=10)
if result.returncode != 0:
raise HTTPException(status_code=500, detail=f"Failed to start WireGuard: {result.stderr}")
if not config.get("address"):
raise HTTPException(status_code=400,
detail="WireGuard not initialized (no address); run /wireguard/init first")
# Provisioning (wg-quick, /etc/wireguard write) is delegated to the root
# CLI sbx-mesh-up; this unprivileged endpoint only marks the intent.
config["enabled"] = True
save_json(WG_MESH_CONFIG, config)
return {"status": "ok", "message": "WireGuard mesh enabled"}
return {"status": "ok", "message": "mesh marked enabled; run 'sbx-mesh-up' as root to provision the interface"}
# ============== Remote Announcers ==============
@ -1744,6 +1754,7 @@ async def ml_join(req: JoinRequest, request: Request):
join_request["approved_at"] = now.isoformat()
join_request["approved_by"] = config.get("fingerprint", get_node_id())
join_request["depth"] = peer_depth
_assign_mesh_ip(join_request)
# Mark token as used
ml_token_mark_used(token_hash, req.fingerprint)
@ -1774,6 +1785,12 @@ async def ml_join(req: JoinRequest, request: Request):
}
def _assign_mesh_ip(join_request: Dict) -> None:
"""Allocate the next free mesh IP, deduping against persisted peers."""
taken = [p.get("mesh_ip", "") for p in load_json(PEERS_FILE, {"peers": []}).get("peers", [])]
join_request["mesh_ip"] = mesh.allocate_mesh_ip(mesh.MESH_NETWORK, taken)
def _add_approved_peer(join_request: Dict):
"""Add approved peer to peer list."""
peers_data = load_json(PEERS_FILE, {"peers": []})
@ -1790,6 +1807,7 @@ def _add_approved_peer(join_request: Dict):
"fingerprint": join_request["fingerprint"],
"name": join_request.get("hostname", "Peer"),
"address": join_request.get("address"),
"mesh_ip": join_request.get("mesh_ip"),
"depth": join_request.get("depth", 1),
"role": join_request.get("role", "peer"),
"added": datetime.utcnow().isoformat(),
@ -1830,6 +1848,10 @@ async def ml_approve(req: ApproveRequest, user: dict = Depends(require_jwt)):
if token_hash:
ml_token_mark_used(token_hash, req.fingerprint)
# Allocate mesh IP if not already set (handles manual-approve path)
if not join_request.get("mesh_ip"):
_assign_mesh_ip(join_request)
# Add to peers
_add_approved_peer(join_request)

View File

@ -0,0 +1,173 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""
SecuBox-Deb :: secubox-p2p :: mesh
Pure mesh logic no FastAPI, no privilege. Imported by api/main.py (state
endpoints, runs as user secubox) and by sbx-mesh-up (root provisioner).
"""
from __future__ import annotations
import ipaddress
import pathlib
import re
import tomllib
MESH_INTERFACE = "wg-mesh"
MESH_PORT = 51822
MESH_NETWORK = "10.10.0.0/24"
# Reserved subnets the mesh must never overlap (name -> CIDR).
RESERVED_SUBNETS = {
"br-lxc": "10.100.0.0/24",
"eye-br0": "10.55.0.0/24",
"lxcbr0": "10.0.3.0/24",
"wg-toolbox": "10.99.0.0/24",
}
def subnet_overlap(network: str) -> str | None:
"""Return the name of the first RESERVED_SUBNETS entry that overlaps
`network`, or None if `network` is clear."""
net = ipaddress.ip_network(network, strict=False)
for name, cidr in RESERVED_SUBNETS.items():
if net.overlaps(ipaddress.ip_network(cidr, strict=False)):
return name
return None
def load_p2p_config(path: pathlib.Path) -> dict:
"""Read the [wireguard] section of /etc/secubox/p2p.toml, with defaults."""
defaults = {
"interface": MESH_INTERFACE,
"listen_port": MESH_PORT,
"network": MESH_NETWORK,
"role": "satellite",
"master_endpoint": None,
}
try:
with open(path, "rb") as f:
wg = (tomllib.load(f) or {}).get("wireguard", {}) or {}
except (FileNotFoundError, tomllib.TOMLDecodeError):
wg = {}
out = dict(defaults)
for k in defaults:
if wg.get(k) is not None:
out[k] = wg[k]
return out
def allocate_mesh_ip(network: str, taken: list[str]) -> str:
"""Lowest free host >= .2 in `network` (.1 reserved for master)."""
taken_set = {t.split("/")[0] for t in taken}
net = ipaddress.ip_network(network, strict=False)
base = int(net.network_address)
for off in range(2, net.num_addresses - 1):
cand = str(ipaddress.ip_address(base + off))
if cand not in taken_set:
return cand
raise RuntimeError(f"mesh address pool {network} exhausted")
def parse_wg_conf(text: str) -> dict:
"""Extract Interface fields from a wg-quick config (first [Interface])."""
out = {"private_key": None, "address": None, "listen_port": None}
in_iface = False
for raw in text.splitlines():
line = raw.strip()
if line.startswith("["):
in_iface = line.lower() == "[interface]"
continue
if not in_iface or "=" not in line:
continue
key, val = (p.strip() for p in line.split("=", 1))
kl = key.lower()
if kl == "privatekey":
out["private_key"] = val
elif kl == "address":
out["address"] = val
elif kl == "listenport":
out["listen_port"] = int(val)
return out
def render_wg_conf(state: dict) -> str:
"""Render a wg-quick config from mesh state."""
lines = [
"# Managed by secubox-p2p (sbx-mesh-up) — do not edit by hand.",
"[Interface]",
f"PrivateKey = {state['private_key']}",
f"Address = {state['address']}",
f"ListenPort = {state.get('listen_port', MESH_PORT)}",
]
for peer in state.get("peers", []):
lines += ["", "[Peer]", f"PublicKey = {peer['public_key']}"]
if peer.get("endpoint"):
lines.append(f"Endpoint = {peer['endpoint']}")
lines.append(f"AllowedIPs = {peer.get('allowed_ips', MESH_NETWORK)}")
lines.append("PersistentKeepalive = 25")
return "\n".join(lines) + "\n"
def adopt_state(state: dict, existing_conf_text: str | None) -> dict:
"""Import the live wg-mesh private key so the public key is preserved.
Never overwrites a key already present in state."""
if state.get("private_key"):
return state
if not existing_conf_text:
return state
parsed = parse_wg_conf(existing_conf_text)
if parsed["private_key"]:
state["private_key"] = parsed["private_key"]
if not state.get("address") and parsed["address"]:
state["address"] = parsed["address"]
if parsed["listen_port"]:
state["listen_port"] = parsed["listen_port"]
return state
def ddns_name(hostname: str, domain: str = "secubox.in") -> str:
"""Return DDNS-safe hostname: lowercased, non-[a-z0-9-] replaced by -, .domain appended."""
slug = re.sub(r"[^a-z0-9-]", "-", hostname.lower())
slug = slug[:63] if slug else "node"
return f"{slug}.{domain}"
def _host_ip(allowed_ips: str) -> str:
"""Return the single host IP from an allowed-ips value, else "".
A peer's mesh address is recoverable only when its allowed-ips is a /32
host route (the master's view of a spoke). A /24 (a spoke's route to the
hub) is not a host address, so we return "" and rely on an explicit
mesh_ip field instead.
"""
first = (allowed_ips or "").split(",")[0].strip()
if first.endswith("/32"):
return first.split("/")[0]
return ""
def peer_nodes(state: dict) -> list:
"""Map wg_mesh.json peers to app-layer node dicts for the /peers + /status
API and the P2P web UI. The mesh transport (wg_mesh.json) is the source of
truth; the legacy peers.json registry is unused by the mesh view.
Each node carries the fields the web UI renders: id, name, address,
public_key, status, latency, last_seen. `status` is reported "online"
(the unprivileged service cannot read wg handshakes to probe liveness;
a privileged liveness probe is future work).
"""
out = []
for p in state.get("peers", []):
ip = p.get("mesh_ip") or _host_ip(p.get("allowed_ips", ""))
name = p.get("name") or ip or (p.get("public_key", "")[:12] or "peer")
out.append({
"id": name,
"name": name,
"address": ip,
"public_key": p.get("public_key", ""),
"status": "online",
"latency": None,
"last_seen": None,
})
return out

View File

@ -0,0 +1,19 @@
# Installed to /etc/secubox/p2p.toml.example by secubox-p2p.
# Copy to /etc/secubox/p2p.toml and edit per node.
[wireguard]
# Mesh transport. Do NOT change `network` to anything overlapping the LXC
# bridge (10.100.0.0/24) or other reserved subnets — sbx-mesh-up refuses.
interface = "wg-mesh"
listen_port = 51822
network = "10.10.0.0/24"
# "master" = this node holds the rendezvous role (publicly reachable).
# "satellite" = this node dials the rendezvous. Rendezvous is a ROLE — any
# node may hold it; today only gk2 is publicly reachable.
role = "satellite"
# Satellite only: where to reach the active rendezvous. Free-form host:port —
# a literal IP (pinned now) or a DDNS name (WireGuard re-resolves per
# handshake, so the rendezvous can change IP without reconfiguring peers).
master_endpoint = "82.67.100.75:51822"

View File

@ -1,3 +1,40 @@
secubox-p2p (1.7.8-1~bookworm1) bookworm; urgency=medium
* fix(webui): P2P dashboard rendered empty due to API-contract drift.
- JS: /peers returns {peers,count} (object) but loadPeers read .length
as an array -> peers never listed; same for /threats (a dict). Handle
both shapes. loadOverview reads status.service_count.
- JS: mesh graph was a hardcoded 'Peer 1-4' placeholder; now wired to
GET /mesh (local node center + real wg-mesh peers on a ring).
- api: GET /mesh now derives nodes/links from wg_mesh.json (local +
wg peers), matching /peers + /status; legacy peers.json fallback kept.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 16:30:00 +0200
secubox-p2p (1.7.7-1~bookworm1) bookworm; urgency=medium
* fix(webui): P2P dashboard was empty — /peers and /status read the unused
legacy peers.json, while the live mesh is in wg_mesh.json (Phase 1). Both
endpoints now derive the mesh view from wg_mesh.json via mesh.peer_nodes();
/status gains total_peers/active_peers (the fields the web UI reads).
Per-peer name + mesh_ip surface friendly node labels.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 15:30:00 +0200
secubox-p2p (1.7.6-1~bookworm1) bookworm; urgency=medium
* feat(gondwana P1): adopt secubox-p2p as the single mesh owner.
- api/mesh.py: pure mesh logic (subnet collision guard, p2p.toml
[wireguard] loader, master-assigned IP allocation, wg.conf
parse/render, key adoption, per-node DDNS name).
- WireGuard defaults fixed 10.100.0.0/24->10.10.0.0/24 (br-lxc
collision), 51820->51822. Role-aware addressing (.1 master).
- sbx-mesh-up: root provisioner (adopt live key -> guard -> render ->
wg-quick up); the service user cannot run wg-quick.
- Depends: wireguard-tools. Ships /etc/secubox/p2p.toml.example.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 14:00:00 +0200
secubox-p2p (1.7.5-1~bookworm1) bookworm; urgency=medium
* sbx-mesh-invite: re-own the master-link token store to secubox when run as

View File

@ -7,7 +7,7 @@ Standards-Version: 4.6.2
Package: secubox-p2p
Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, avahi-daemon, avahi-utils
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, avahi-daemon, avahi-utils, wireguard-tools
Breaks: secubox-master-link (<< 1.1)
Replaces: secubox-master-link (<< 1.1)
Description: SecuBox P2P - Peer-to-Peer Network Hub

View File

@ -33,5 +33,12 @@ override_dh_auto_install:
install -m 755 $(CURDIR)/scripts/sbx-mesh-join $(CURDIR)/debian/secubox-p2p/usr/bin/
install -m 755 $(CURDIR)/scripts/sbx-mesh-invite $(CURDIR)/debian/secubox-p2p/usr/bin/
# Install p2p.toml example
install -d $(CURDIR)/debian/secubox-p2p/etc/secubox
install -m 644 $(CURDIR)/conf/p2p.toml.example $(CURDIR)/debian/secubox-p2p/etc/secubox/
# Install root mesh provisioner CLI
install -m 755 $(CURDIR)/scripts/sbx-mesh-up $(CURDIR)/debian/secubox-p2p/usr/bin/
# Create runtime directory
install -d $(CURDIR)/debian/secubox-p2p/run/secubox

View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# SecuBox-Deb :: secubox-p2p :: sbx-mesh-up
# Root provisioner: adopt existing key -> collision guard -> render -> up.
# The secubox-p2p service runs as user `secubox` and cannot do this.
set -euo pipefail
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
STATE=/var/lib/secubox/p2p/wg_mesh.json
CONF=/etc/wireguard/wg-mesh.conf
python3 - "$STATE" "$CONF" <<'PY'
import json, sys, pathlib
sys.path.insert(0, "/usr/lib/secubox/p2p")
from api import mesh
state_path, conf_path = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
state = json.loads(state_path.read_text()) if state_path.exists() else {"peers": []}
# Adopt the live key if state has none (preserves the gk2<->c3box handshake).
existing = conf_path.read_text() if conf_path.exists() else None
state = mesh.adopt_state(state, existing)
net = state.get("network", mesh.MESH_NETWORK)
bad = mesh.subnet_overlap(net)
if bad:
sys.exit(f"REFUSING: mesh network {net} overlaps reserved subnet {bad!r}")
if not state.get("private_key"):
sys.exit("no private key in state and none to adopt; run /wireguard/init first")
conf_path.parent.mkdir(parents=True, exist_ok=True)
conf_path.write_text(mesh.render_wg_conf(state))
conf_path.chmod(0o600)
state_path.write_text(json.dumps(state, indent=2))
print(f"rendered {conf_path} (addr {state.get('address')}, peers {len(state.get('peers', []))})")
PY
wg-quick down wg-mesh 2>/dev/null || true
wg-quick up wg-mesh
wg show wg-mesh

View File

View File

@ -0,0 +1,5 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Ensures `from api import mesh` resolves from the package root during tests.
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))

View File

@ -0,0 +1,147 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
import pytest
from api import mesh
def test_mesh_defaults():
assert mesh.MESH_NETWORK == "10.10.0.0/24"
assert mesh.MESH_PORT == 51822
assert mesh.MESH_INTERFACE == "wg-mesh"
def test_subnet_overlap_detects_br_lxc():
assert mesh.subnet_overlap("10.100.0.0/24") == "br-lxc"
def test_subnet_overlap_detects_partial_supernet():
# a /16 that contains br-lxc must also be rejected
assert mesh.subnet_overlap("10.100.0.0/16") == "br-lxc"
def test_subnet_overlap_clean_mesh_subnet():
assert mesh.subnet_overlap("10.10.0.0/24") is None
def test_load_p2p_config_defaults_when_missing(tmp_path):
cfg = mesh.load_p2p_config(tmp_path / "nope.toml")
assert cfg["network"] == "10.10.0.0/24"
assert cfg["listen_port"] == 51822
assert cfg["interface"] == "wg-mesh"
assert cfg["role"] == "satellite"
assert cfg["master_endpoint"] is None
def test_load_p2p_config_reads_wireguard_section(tmp_path):
p = tmp_path / "p2p.toml"
p.write_text(
"[wireguard]\n"
'role = "master"\n'
'listen_port = 51822\n'
'network = "10.10.0.0/24"\n'
'master_endpoint = "82.67.100.75:51822"\n'
)
cfg = mesh.load_p2p_config(p)
assert cfg["role"] == "master"
assert cfg["master_endpoint"] == "82.67.100.75:51822"
def test_allocate_mesh_ip_first_free_is_2():
assert mesh.allocate_mesh_ip("10.10.0.0/24", []) == "10.10.0.2"
def test_allocate_mesh_ip_skips_taken_with_or_without_mask():
got = mesh.allocate_mesh_ip("10.10.0.0/24", ["10.10.0.2/24", "10.10.0.3"])
assert got == "10.10.0.4"
def test_allocate_mesh_ip_exhausted_raises():
taken = [f"10.10.0.{n}" for n in range(2, 255)]
with pytest.raises(RuntimeError):
mesh.allocate_mesh_ip("10.10.0.0/24", taken)
def test_parse_wg_conf_extracts_interface_fields():
text = (
"[Interface]\n"
"PrivateKey = ABC123=\n"
"Address = 10.10.0.1/24\n"
"ListenPort = 51822\n"
"[Peer]\nPublicKey = X=\n"
)
got = mesh.parse_wg_conf(text)
assert got == {"private_key": "ABC123=", "address": "10.10.0.1/24", "listen_port": 51822}
def test_render_wg_conf_master_with_roaming_peer():
state = {
"private_key": "PRIV=",
"address": "10.10.0.1/24",
"listen_port": 51822,
"peers": [{"public_key": "PUB2=", "allowed_ips": "10.10.0.2/32"}],
}
out = mesh.render_wg_conf(state)
assert "PrivateKey = PRIV=" in out
assert "ListenPort = 51822" in out
assert "AllowedIPs = 10.10.0.2/32" in out
assert "Endpoint" not in out # roaming peer => no Endpoint line
def test_render_wg_conf_satellite_with_endpoint_and_keepalive():
state = {
"private_key": "PRIV=", "address": "10.10.0.3/24", "listen_port": 51822,
"peers": [{"public_key": "GK2=", "endpoint": "82.67.100.75:51822", "allowed_ips": "10.10.0.0/24"}],
}
out = mesh.render_wg_conf(state)
assert "Endpoint = 82.67.100.75:51822" in out
assert "PersistentKeepalive = 25" in out
def test_ddns_name_basic():
assert mesh.ddns_name("gk2") == "gk2.secubox.in"
def test_ddns_name_sanitizes():
assert mesh.ddns_name("Secubox_Live!") == "secubox-live-.secubox.in"
def test_adopt_state_imports_existing_key_when_absent():
state = {"private_key": None, "peers": []}
conf = "[Interface]\nPrivateKey = LIVEKEY=\nAddress = 10.10.0.1/24\nListenPort = 51822\n"
out = mesh.adopt_state(state, conf)
assert out["private_key"] == "LIVEKEY="
assert out["address"] == "10.10.0.1/24"
assert out["listen_port"] == 51822
def test_adopt_state_never_overwrites_existing_key():
state = {"private_key": "KEEP=", "peers": []}
conf = "[Interface]\nPrivateKey = OTHER=\n"
out = mesh.adopt_state(state, conf)
assert out["private_key"] == "KEEP="
def test_ddns_name_empty_falls_back():
assert mesh.ddns_name("") == "node.secubox.in"
def test_host_ip_only_from_slash32():
assert mesh._host_ip("10.10.0.2/32") == "10.10.0.2"
assert mesh._host_ip("10.10.0.0/24") == ""
assert mesh._host_ip("") == ""
def test_peer_nodes_uses_name_and_mesh_ip():
state = {"peers": [
{"public_key": "AAA=", "name": "c3box", "mesh_ip": "10.10.0.2", "allowed_ips": "10.10.0.2/32"},
{"public_key": "BBB=", "allowed_ips": "10.10.0.3/32"}, # no name/mesh_ip -> derive ip
{"public_key": "CCC=", "name": "gk2", "mesh_ip": "10.10.0.1", "allowed_ips": "10.10.0.0/24"},
]}
nodes = mesh.peer_nodes(state)
assert [n["name"] for n in nodes] == ["c3box", "10.10.0.3", "gk2"]
assert [n["address"] for n in nodes] == ["10.10.0.2", "10.10.0.3", "10.10.0.1"]
assert all(n["status"] == "online" for n in nodes)
def test_peer_nodes_empty():
assert mesh.peer_nodes({"peers": []}) == []

View File

@ -737,7 +737,7 @@
document.getElementById('status-value').className = 'value ' + (status.online ? 'online' : 'offline');
document.getElementById('peer-count').textContent = status.total_peers || 0;
document.getElementById('active-peer-count').textContent = status.active_peers || 0;
document.getElementById('service-count').textContent = status.services || 0;
document.getElementById('service-count').textContent = status.service_count || status.services || 0;
}
const activity = await apiGet('/activity');
@ -758,9 +758,10 @@
// Load Peers
async function loadPeers() {
const peers = await apiGet('/peers');
const data = await apiGet('/peers');
const peers = Array.isArray(data) ? data : (data && Array.isArray(data.peers) ? data.peers : []);
const tbody = document.getElementById('peers-table');
if (peers && peers.length > 0) {
if (peers.length > 0) {
tbody.innerHTML = peers.map(peer => `
<tr>
<td>${escapeHtml(peer.id)}</td>
@ -781,9 +782,10 @@
// Load Services
async function loadServices() {
const services = await apiGet('/services');
const data = await apiGet('/services');
const services = Array.isArray(data) ? data : (data && Array.isArray(data.services) ? data.services : []);
const tbody = document.getElementById('services-table');
if (services && services.length > 0) {
if (services.length > 0) {
tbody.innerHTML = services.map(svc => `
<tr>
<td>${escapeHtml(svc.name)}</td>
@ -803,9 +805,18 @@
// Load Threats
async function loadThreats() {
const threats = await apiGet('/threats');
const data = await apiGet('/threats');
let threats = [];
if (Array.isArray(data)) threats = data;
else if (data && typeof data === 'object') threats = Object.entries(data).map(([ip, t]) => ({
type: (t && t.type) || 'threat',
severity: (t && t.severity) || 'info',
source: (t && t.source) || ip,
target: (t && t.target) || ip,
description: (t && (t.description || t.reason)) || 'shared via mesh'
}));
const container = document.getElementById('threats-list');
if (threats && threats.length > 0) {
if (threats.length > 0) {
container.innerHTML = threats.map(threat => `
<div class="threat-item ${threat.severity}">
<div class="threat-header">
@ -824,48 +835,50 @@
}
}
// Mesh Visualization
function initMesh() {
// Mesh Visualization (wired to /mesh — real nodes from the wg-mesh)
async function initMesh() {
const canvas = document.getElementById('mesh-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Set canvas size
canvas.width = canvas.parentElement.clientWidth;
canvas.height = canvas.parentElement.clientHeight;
// Light theme colors
const bgColor = '#f1f8f2';
const nodeColor = '#006622';
const lineColor = '#a7c4a0';
const textColor = '#1b4332';
// Draw placeholder mesh
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = lineColor;
ctx.fillStyle = nodeColor;
ctx.font = '14px Courier New';
ctx.textAlign = 'center';
// Center node
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
drawNode(ctx, centerX, centerY, 'LOCAL', true, nodeColor, textColor);
const data = await apiGet('/mesh');
const allNodes = (data && Array.isArray(data.nodes)) ? data.nodes : [];
const local = allNodes.find(n => n.is_local) || { name: 'LOCAL' };
const peers = allNodes.filter(n => !n.is_local);
// Placeholder peer nodes
const peers = [
{ x: centerX - 150, y: centerY - 100, name: 'Peer 1' },
{ x: centerX + 150, y: centerY - 100, name: 'Peer 2' },
{ x: centerX - 150, y: centerY + 100, name: 'Peer 3' },
{ x: centerX + 150, y: centerY + 100, name: 'Peer 4' }
];
peers.forEach(peer => {
drawConnection(ctx, centerX, centerY, peer.x, peer.y, lineColor);
drawNode(ctx, peer.x, peer.y, peer.name, false, nodeColor, textColor);
const radius = Math.max(60, Math.min(centerX, centerY) - 60);
peers.forEach((peer, i) => {
const angle = (Math.PI * 2 * i) / Math.max(peers.length, 1) - Math.PI / 2;
const px = centerX + radius * Math.cos(angle);
const py = centerY + radius * Math.sin(angle);
drawConnection(ctx, centerX, centerY, px, py, lineColor);
const label = (peer.name || peer.address || peer.id || 'peer') +
(peer.address && peer.name !== peer.address ? ' (' + peer.address + ')' : '');
drawNode(ctx, px, py, label, false, nodeColor, textColor);
});
const localLabel = (local.name || 'LOCAL') + (local.address ? ' (' + local.address + ')' : '');
drawNode(ctx, centerX, centerY, localLabel, true, nodeColor, textColor);
if (peers.length === 0) {
ctx.fillStyle = textColor;
ctx.fillText('No mesh peers', centerX, centerY + 70);
}
}
function drawNode(ctx, x, y, label, isCenter, nodeColor, textColor) {

View File

@ -1,3 +1,21 @@
secubox-portal (2.2.2-1~bookworm1) bookworm; urgency=medium
* fix(ui): 'Active bans' now reflects the live /crowdsec/decisions list
(includes CAPI community blocklist) instead of the local-only
health/summary.active_decisions; hero 'Bans active' kept consistent.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 21:00:00 +0200
secubox-portal (2.2.1-1~bookworm1) bookworm; urgency=medium
* fix(ui): reskin the portal to the standard SecuBox template — shared
sidebar (nav#sidebar + sidebar.js) replacing the bespoke top navbar,
design-tokens + crt-light C3BOX palette replacing the P31 green theme,
main.main layout + crt-engine. Dashboard content, element IDs and the
data-fetch JS are unchanged.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 20:30:00 +0200
secubox-portal (2.2.0-1~bookworm1) bookworm; urgency=medium
* portal: regenerate /portal/index.html as a public-facing operational

View File

@ -5,74 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox · Portal</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared/design-tokens.css">
<link rel="stylesheet" href="/shared/crt-light.css">
<style>
:root {
--p31-peak: #00dd44;
--p31-hot: #00ff55;
--p31-mid: #009933;
--p31-dim: #006622;
--p31-ghost: #003311;
--p31-decay: #ffb347;
--p31-decay-dim: #cc7722;
--tube-light: #e8f5e9;
--tube-pale: #c8e6c9;
--tube-soft: #a5d6a7;
--tube-dark: #1b1b1f;
--primary: var(--p31-peak);
--cyan: #00d4ff;
--red: #ff4466;
--yellow: var(--p31-decay);
--bloom-text: 0 0 2px var(--p31-peak), 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
--bloom-amber: 0 0 3px var(--p31-decay), 0 0 10px rgba(255,179,71,0.4);
--bloom-red: 0 0 3px var(--red), 0 0 10px rgba(255,68,102,0.4);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Courier Prime', 'Courier New', monospace;
background: var(--tube-light);
background-image: radial-gradient(ellipse at 50% 40%, rgba(51,255,102,0.025) 0%, transparent 70%);
color: var(--tube-dark);
min-height: 100vh;
padding-bottom: 3rem;
}
body::before {
content: "";
position: fixed; inset: 0;
background-image:
linear-gradient(rgba(0,221,68,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,221,68,0.02) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none; z-index: -1;
}
a { color: var(--p31-mid); text-decoration: none; }
a:hover { color: var(--p31-peak); text-shadow: var(--bloom-text); }
/* Navbar */
.navbar {
background: var(--tube-dark);
color: var(--p31-peak);
padding: 0.75rem 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 50;
border-bottom: 1px solid var(--p31-mid);
}
.navbar .brand {
font-weight: bold; font-size: 1.1rem; letter-spacing: 1px;
text-shadow: var(--bloom-text);
}
.navbar .brand a { color: var(--p31-peak); }
.navbar .nav-links { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.navbar .nav-links a {
color: var(--p31-mid); font-size: 0.9rem;
padding: 0.25rem 0.5rem; border-radius: 3px;
transition: all 0.15s;
}
.navbar .nav-links a:hover { color: var(--p31-peak); background: rgba(0,221,68,0.08); text-shadow: var(--bloom-text); }
.navbar .nav-meta { font-size: 0.75rem; color: var(--p31-dim); }
a { color: var(--cyber-cyan, #00d4ff); text-decoration: none; }
a:hover { color: var(--gold-hermetic, #c9a84c); }
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
@ -80,10 +18,9 @@
.hero {
margin-bottom: 2rem;
padding: 1.5rem 2rem;
background: var(--tube-pale);
border: 1px solid var(--p31-mid);
background: var(--panel, #13131c);
border: 1px solid var(--line, #2a2a3a);
border-radius: 4px;
box-shadow: 0 0 12px rgba(0,221,68,0.15);
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
@ -95,16 +32,16 @@
}
.hero-score .num {
font-size: 3.5rem; font-weight: bold; line-height: 1;
color: var(--p31-peak); text-shadow: var(--bloom-text);
color: var(--matrix-green, #00ff41);
}
.hero-score .label { font-size: 0.8rem; color: var(--p31-dim); margin-top: 0.25rem; letter-spacing: 1px; }
.hero-score.warn .num { color: var(--yellow); text-shadow: var(--bloom-amber); }
.hero-score.crit .num { color: var(--red); text-shadow: var(--bloom-red); }
.hero-score .label { font-size: 0.8rem; color: var(--text-muted, #6b6b7a); margin-top: 0.25rem; letter-spacing: 1px; }
.hero-score.warn .num { color: var(--gold-hermetic, #c9a84c); }
.hero-score.crit .num { color: var(--cinnabar, #e63946); }
.hero-info { font-size: 0.95rem; line-height: 1.6; }
.hero-info .kv { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.hero-info .k { color: var(--p31-dim); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; }
.hero-info .v { color: var(--tube-dark); font-weight: bold; }
.hero-refresh { font-size: 0.75rem; color: var(--p31-dim); text-align: right; }
.hero-info .k { color: var(--text-muted, #6b6b7a); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; }
.hero-info .v { color: var(--text, #e8e6d9); font-weight: bold; }
.hero-refresh { font-size: 0.75rem; color: var(--text-muted, #6b6b7a); text-align: right; }
/* Section grid */
.grid {
@ -114,101 +51,87 @@
margin-bottom: 1.5rem;
}
.card {
background: var(--tube-light);
border: 1px solid var(--p31-mid);
background: var(--panel, #13131c);
border: 1px solid var(--line, #2a2a3a);
border-radius: 4px;
padding: 1.25rem;
position: relative;
box-shadow: 0 0 8px rgba(0,221,68,0.08);
}
.card h3 {
font-size: 0.85rem; text-transform: uppercase; letter-spacing: 2px;
color: var(--p31-mid); margin-bottom: 1rem;
color: var(--gold-hermetic, #c9a84c); margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px dashed var(--p31-ghost);
text-shadow: var(--bloom-text);
border-bottom: 1px dashed var(--line, #2a2a3a);
}
.card .row { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 0.9rem; }
.card .row .k { color: var(--p31-dim); }
.card .row .v { color: var(--tube-dark); font-weight: bold; }
.card .row .v.ok { color: var(--p31-peak); text-shadow: var(--bloom-text); }
.card .row .v.warn { color: var(--yellow); text-shadow: var(--bloom-amber); }
.card .row .v.crit { color: var(--red); text-shadow: var(--bloom-red); }
.empty { color: var(--p31-dim); font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; }
.card .row .k { color: var(--text-muted, #6b6b7a); }
.card .row .v { color: var(--text, #e8e6d9); font-weight: bold; }
.card .row .v.ok { color: var(--matrix-green, #00ff41); }
.card .row .v.warn { color: var(--gold-hermetic, #c9a84c); }
.card .row .v.crit { color: var(--cinnabar, #e63946); }
.empty { color: var(--text-muted, #6b6b7a); font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; }
/* Bar */
.bar {
height: 4px; background: var(--p31-ghost); border-radius: 2px;
height: 4px; background: var(--cosmos-black, #0a0a0f); border-radius: 2px;
overflow: hidden; margin: 0.25rem 0;
}
.bar > span { display: block; height: 100%; background: var(--p31-peak); }
.bar.warn > span { background: var(--yellow); }
.bar.crit > span { background: var(--red); }
.bar > span { display: block; height: 100%; background: var(--matrix-green, #00ff41); }
.bar.warn > span { background: var(--gold-hermetic, #c9a84c); }
.bar.crit > span { background: var(--cinnabar, #e63946); }
/* Modules LED grid */
.modules { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; }
.module {
display: flex; flex-direction: column; align-items: center;
gap: 0.25rem; padding: 0.5rem; border-radius: 4px;
background: var(--tube-pale); border: 1px solid var(--p31-ghost);
background: var(--cosmos-black, #0a0a0f); border: 1px solid var(--line, #2a2a3a);
font-size: 0.7rem;
}
.module .led {
width: 10px; height: 10px; border-radius: 50%;
background: var(--p31-dim);
background: var(--text-muted, #6b6b7a);
}
.module.ok .led { background: var(--p31-peak); box-shadow: 0 0 8px var(--p31-peak); }
.module.warn .led { background: var(--yellow); box-shadow: 0 0 8px var(--yellow); }
.module.err .led { background: var(--red); box-shadow: 0 0 8px var(--red); }
.module .name { font-size: 0.65rem; text-transform: uppercase; color: var(--p31-dim); }
.module.ok .led { background: var(--matrix-green, #00ff41); box-shadow: 0 0 6px var(--matrix-green, #00ff41); }
.module.warn .led { background: var(--gold-hermetic, #c9a84c); box-shadow: 0 0 6px var(--gold-hermetic, #c9a84c); }
.module.err .led { background: var(--cinnabar, #e63946); box-shadow: 0 0 6px var(--cinnabar, #e63946); }
.module .name { font-size: 0.65rem; text-transform: uppercase; color: var(--text-muted, #6b6b7a); }
/* Tables — vhosts / ASNs */
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.25rem 0.5rem; }
th { font-size: 0.7rem; color: var(--p31-dim); text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px dashed var(--p31-ghost); }
td.num { text-align: right; color: var(--cyan); font-variant-numeric: tabular-nums; }
tr:hover td { background: rgba(0,221,68,0.04); }
th { font-size: 0.7rem; color: var(--text-muted, #6b6b7a); text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px dashed var(--line, #2a2a3a); }
td.num { text-align: right; color: var(--cyber-cyan, #00d4ff); font-variant-numeric: tabular-nums; }
tr:hover td { background: rgba(201,168,76,0.05); }
/* Big number tiles */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; }
.tile {
background: var(--tube-pale); border: 1px solid var(--p31-ghost);
background: var(--cosmos-black, #0a0a0f); border: 1px solid var(--line, #2a2a3a);
border-radius: 4px; padding: 0.75rem; text-align: center;
}
.tile .v { font-size: 1.75rem; font-weight: bold; color: var(--p31-peak); text-shadow: var(--bloom-text); }
.tile .v.warn { color: var(--yellow); text-shadow: var(--bloom-amber); }
.tile .v.crit { color: var(--red); text-shadow: var(--bloom-red); }
.tile .l { font-size: 0.7rem; text-transform: uppercase; color: var(--p31-dim); letter-spacing: 1px; }
.tile .v { font-size: 1.75rem; font-weight: bold; color: var(--matrix-green, #00ff41); }
.tile .v.warn { color: var(--gold-hermetic, #c9a84c); }
.tile .v.crit { color: var(--cinnabar, #e63946); }
.tile .l { font-size: 0.7rem; text-transform: uppercase; color: var(--text-muted, #6b6b7a); letter-spacing: 1px; }
.footer {
margin-top: 3rem; padding-top: 1.5rem;
border-top: 1px dashed var(--p31-ghost);
text-align: center; font-size: 0.8rem; color: var(--p31-dim);
border-top: 1px dashed var(--line, #2a2a3a);
text-align: center; font-size: 0.8rem; color: var(--text-muted, #6b6b7a);
}
.footer a { color: var(--p31-mid); }
.footer a { color: var(--cyber-cyan, #00d4ff); }
@media (max-width: 768px) {
.hero { grid-template-columns: 1fr; }
.modules { grid-template-columns: repeat(3, 1fr); }
.navbar { padding: 0.75rem 1rem; flex-direction: column; gap: 0.5rem; }
.navbar .nav-links { font-size: 0.8rem; }
}
</style>
</head>
<body>
<nav class="navbar">
<div class="brand"><a href="/portal/">🛡️ SecuBox</a></div>
<div class="nav-links">
<a href="/portal/">PORTAL</a>
<a href="/soc/">SOC</a>
<a href="/metablogizer/">METABLOGS</a>
<a href="/publish/">PUBLISH</a>
<a href="/cookies/">COOKIES</a>
<a href="https://github.com/CyberMind-FR/secubox-deb" rel="noopener">REPO</a>
</div>
<div class="nav-meta" id="nav-hostname"></div>
</nav>
<body class="crt-light">
<nav class="sidebar" id="sidebar"></nav>
<script src="/shared/sidebar.js"></script>
<main class="main">
<div class="container">
<!-- Hero -->
@ -295,7 +218,9 @@
</footer>
</div>
</main>
<script src="/shared/crt-engine.js"></script>
<script>
(function () {
'use strict';
@ -430,7 +355,12 @@
}
function renderBans(h, decisions) {
const ban = h && h.crowdsec && h.crowdsec.active_decisions;
// Prefer the live /crowdsec/decisions list (includes CAPI community
// blocklist), falling back to health/summary's local-only count.
const decList = decisions && Array.isArray(decisions.decisions) ? decisions.decisions
: (Array.isArray(decisions) ? decisions : null);
const ban = decList ? decList.filter(d => (d.type || 'ban') === 'ban').length
: (h && h.crowdsec ? h.crowdsec.active_decisions : null);
const alerts = h && h.crowdsec && h.crowdsec.alerts_today;
const wafPct = h && h.waf && h.waf.blocked_pct;
const wafCls = wafPct == null ? '' : (wafPct >= 25 ? 'crit' : wafPct >= 10 ? 'warn' : 'ok');
@ -440,6 +370,8 @@
$('t-waf').textContent = wafPct != null ? wafPct + '%' : '—';
$('t-waf').className = 'v ' + wafCls;
$('t-alerts').textContent = alerts != null ? alerts : '—';
// Keep the hero "Bans active" consistent with the real blocked-IP count.
if (ban != null && $('hero-bans')) $('hero-bans').textContent = ban;
}
function renderVhosts(d) {