mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-01 17:17:14 +00:00
Compare commits
No commits in common. "d05dcf615e4530c38f7898311ba0b193577d0335" and "7206350c34df4dab37ce7d7e97bd359680d5a6fc" have entirely different histories.
d05dcf615e
...
7206350c34
|
|
@ -1,165 +0,0 @@
|
|||
# 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 4–5 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).
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
# 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}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
systemctl stop secubox-appstore.service 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
[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
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
# 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/
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"id": "appstore",
|
||||
"name": "App Store",
|
||||
"icon": "🛍️",
|
||||
"path": "/appstore/",
|
||||
"category": "root",
|
||||
"order": 10,
|
||||
"description": "Install, enable & configure SecuBox modules",
|
||||
"requires": ["secubox-appstore"]
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# /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;
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# /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;
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
#!/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")
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
# 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/
|
||||
|
|
@ -5,17 +5,9 @@
|
|||
|
||||
"""
|
||||
SecuBox-Deb :: LiveHosts aggregator
|
||||
|
||||
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).
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -23,25 +15,22 @@ import asyncio
|
|||
import collections
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import socket
|
||||
from datetime import datetime, 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
|
||||
|
||||
|
|
@ -55,14 +44,13 @@ class LiveHostsAggregator:
|
|||
return json.loads(CACHE_PATH.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {"enabled": False, "window_minutes": self.cfg.get("window_minutes", 60),
|
||||
"entries": [], "total_requests": 0}
|
||||
return {"enabled": False, "window_minutes": self.cfg["window_minutes"], "entries": []}
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
self._payload = await self.refresh_once()
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
log.warning("refresh_once raised: %s", e)
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
|
@ -70,15 +58,18 @@ class LiveHostsAggregator:
|
|||
if not self.cfg.get("enabled"):
|
||||
self._refreshed = True
|
||||
return self._disabled_payload()
|
||||
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]
|
||||
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()
|
||||
payload = {
|
||||
"enabled": True,
|
||||
"window_minutes": int(self.cfg.get("window_minutes", 60)),
|
||||
"window_minutes": self.cfg["window_minutes"],
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"entries": entries,
|
||||
"total_requests": total,
|
||||
}
|
||||
self._persist(payload)
|
||||
self._refreshed = True
|
||||
|
|
@ -86,46 +77,91 @@ class LiveHostsAggregator:
|
|||
|
||||
# -- helpers --------------------------------------------
|
||||
|
||||
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
|
||||
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
|
||||
try:
|
||||
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:
|
||||
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
|
||||
try:
|
||||
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)
|
||||
out[cols[pxname_i]] = int(cols[req_tot_i] or "0")
|
||||
except ValueError:
|
||||
continue
|
||||
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
|
||||
return out
|
||||
|
||||
def _persist(self, payload: dict) -> None:
|
||||
try:
|
||||
|
|
@ -133,14 +169,13 @@ class LiveHostsAggregator:
|
|||
tmp = CACHE_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload))
|
||||
tmp.replace(CACHE_PATH)
|
||||
except Exception as e: # noqa: BLE001
|
||||
except Exception as e:
|
||||
log.warning("persist failed: %s", e)
|
||||
|
||||
def _disabled_payload(self) -> dict:
|
||||
return {
|
||||
"enabled": False,
|
||||
"window_minutes": int(self.cfg.get("window_minutes", 60)),
|
||||
"window_minutes": self.cfg["window_minutes"],
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"entries": [],
|
||||
"total_requests": 0,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -536,38 +536,6 @@ 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."""
|
||||
|
||||
|
|
@ -658,16 +626,16 @@ def build_health_summary() -> dict:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# WAF block rate from the live sbxwaf threat log over the last hour,
|
||||
# as a share of total traffic (legit nginx requests + blocks).
|
||||
# Get WAF blocked percentage (estimate from recent logs)
|
||||
blocked_pct = 0
|
||||
waf_blocks_1h = _count_waf_blocks(60)
|
||||
try:
|
||||
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)
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -715,7 +683,6 @@ 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": {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,3 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ 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",
|
||||
|
|
@ -636,10 +634,6 @@ 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()
|
||||
|
||||
|
|
@ -649,10 +643,8 @@ async def get_status():
|
|||
"hostname": get_hostname(),
|
||||
"lan_ip": get_lan_ip(),
|
||||
"wan_ip": get_wan_ip(),
|
||||
"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),
|
||||
"peer_count": len(peers),
|
||||
"online_peers": 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": {
|
||||
|
|
@ -706,22 +698,18 @@ async def get_self():
|
|||
|
||||
@app.get("/peers")
|
||||
async def list_peers():
|
||||
"""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.
|
||||
"""
|
||||
"""List all known peers (public read)."""
|
||||
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)}
|
||||
|
||||
|
||||
|
|
@ -871,39 +859,15 @@ 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).
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Get mesh network topology (public read)."""
|
||||
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 = []
|
||||
local_id = get_node_id()
|
||||
|
||||
for peer in peers:
|
||||
node = {
|
||||
"id": peer.get('id', ''),
|
||||
|
|
@ -913,9 +877,21 @@ 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 ==============
|
||||
|
|
@ -999,9 +975,9 @@ async def remove_threat(ip: str, user: dict = Depends(require_jwt)):
|
|||
# ============== WireGuard Mesh ==============
|
||||
|
||||
WG_MESH_CONFIG = P2P_DIR / "wg_mesh.json"
|
||||
WG_INTERFACE = mesh.MESH_INTERFACE
|
||||
WG_PORT = mesh.MESH_PORT
|
||||
WG_NETWORK = mesh.MESH_NETWORK
|
||||
WG_INTERFACE = "wg-mesh"
|
||||
WG_PORT = 51820
|
||||
WG_NETWORK = "10.100.0.0/24"
|
||||
|
||||
|
||||
def get_wg_mesh_config() -> Dict:
|
||||
|
|
@ -1079,16 +1055,11 @@ async def init_wireguard(user: dict = Depends(require_jwt)):
|
|||
config["private_key"] = private_key
|
||||
config["public_key"] = public_key
|
||||
|
||||
# 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())
|
||||
# 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"
|
||||
|
||||
save_json(WG_MESH_CONFIG, config)
|
||||
|
||||
|
|
@ -1104,7 +1075,7 @@ async def init_wireguard(user: dict = Depends(require_jwt)):
|
|||
async def add_wireguard_peer(
|
||||
public_key: str,
|
||||
endpoint: str,
|
||||
allowed_ips: str = "10.10.0.0/24",
|
||||
allowed_ips: str = "10.100.0.0/24",
|
||||
user: dict = Depends(require_jwt)
|
||||
):
|
||||
"""Add a WireGuard mesh peer."""
|
||||
|
|
@ -1136,21 +1107,40 @@ async def enable_wireguard(user: dict = Depends(require_jwt)):
|
|||
if not config.get("private_key"):
|
||||
raise HTTPException(status_code=400, detail="WireGuard not initialized")
|
||||
|
||||
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")
|
||||
# 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
|
||||
"""
|
||||
|
||||
if not config.get("address"):
|
||||
raise HTTPException(status_code=400,
|
||||
detail="WireGuard not initialized (no address); run /wireguard/init first")
|
||||
# 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}")
|
||||
|
||||
# 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": "mesh marked enabled; run 'sbx-mesh-up' as root to provision the interface"}
|
||||
return {"status": "ok", "message": "WireGuard mesh enabled"}
|
||||
|
||||
|
||||
# ============== Remote Announcers ==============
|
||||
|
|
@ -1754,7 +1744,6 @@ 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)
|
||||
|
|
@ -1785,12 +1774,6 @@ 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": []})
|
||||
|
|
@ -1807,7 +1790,6 @@ 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(),
|
||||
|
|
@ -1848,10 +1830,6 @@ 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,173 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,40 +1,3 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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, wireguard-tools
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, avahi-daemon, avahi-utils
|
||||
Breaks: secubox-master-link (<< 1.1)
|
||||
Replaces: secubox-master-link (<< 1.1)
|
||||
Description: SecuBox P2P - Peer-to-Peer Network Hub
|
||||
|
|
|
|||
|
|
@ -33,12 +33,5 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# 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]))
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
# 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": []}) == []
|
||||
|
|
@ -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.service_count || status.services || 0;
|
||||
document.getElementById('service-count').textContent = status.services || 0;
|
||||
}
|
||||
|
||||
const activity = await apiGet('/activity');
|
||||
|
|
@ -758,10 +758,9 @@
|
|||
|
||||
// Load Peers
|
||||
async function loadPeers() {
|
||||
const data = await apiGet('/peers');
|
||||
const peers = Array.isArray(data) ? data : (data && Array.isArray(data.peers) ? data.peers : []);
|
||||
const peers = await apiGet('/peers');
|
||||
const tbody = document.getElementById('peers-table');
|
||||
if (peers.length > 0) {
|
||||
if (peers && peers.length > 0) {
|
||||
tbody.innerHTML = peers.map(peer => `
|
||||
<tr>
|
||||
<td>${escapeHtml(peer.id)}</td>
|
||||
|
|
@ -782,10 +781,9 @@
|
|||
|
||||
// Load Services
|
||||
async function loadServices() {
|
||||
const data = await apiGet('/services');
|
||||
const services = Array.isArray(data) ? data : (data && Array.isArray(data.services) ? data.services : []);
|
||||
const services = await apiGet('/services');
|
||||
const tbody = document.getElementById('services-table');
|
||||
if (services.length > 0) {
|
||||
if (services && services.length > 0) {
|
||||
tbody.innerHTML = services.map(svc => `
|
||||
<tr>
|
||||
<td>${escapeHtml(svc.name)}</td>
|
||||
|
|
@ -805,18 +803,9 @@
|
|||
|
||||
// Load Threats
|
||||
async function loadThreats() {
|
||||
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 threats = await apiGet('/threats');
|
||||
const container = document.getElementById('threats-list');
|
||||
if (threats.length > 0) {
|
||||
if (threats && threats.length > 0) {
|
||||
container.innerHTML = threats.map(threat => `
|
||||
<div class="threat-item ${threat.severity}">
|
||||
<div class="threat-header">
|
||||
|
|
@ -835,50 +824,48 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Mesh Visualization (wired to /mesh — real nodes from the wg-mesh)
|
||||
async function initMesh() {
|
||||
// Mesh Visualization
|
||||
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;
|
||||
|
||||
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);
|
||||
drawNode(ctx, centerX, centerY, 'LOCAL', true, 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);
|
||||
// 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 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) {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,3 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -5,12 +5,74 @@
|
|||
<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="stylesheet" href="/shared/design-tokens.css">
|
||||
<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/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; }
|
||||
a { color: var(--cyber-cyan, #00d4ff); text-decoration: none; }
|
||||
a:hover { color: var(--gold-hermetic, #c9a84c); }
|
||||
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); }
|
||||
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
|
||||
|
||||
|
|
@ -18,9 +80,10 @@
|
|||
.hero {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem 2rem;
|
||||
background: var(--panel, #13131c);
|
||||
border: 1px solid var(--line, #2a2a3a);
|
||||
background: var(--tube-pale);
|
||||
border: 1px solid var(--p31-mid);
|
||||
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;
|
||||
|
|
@ -32,16 +95,16 @@
|
|||
}
|
||||
.hero-score .num {
|
||||
font-size: 3.5rem; font-weight: bold; line-height: 1;
|
||||
color: var(--matrix-green, #00ff41);
|
||||
color: var(--p31-peak); text-shadow: var(--bloom-text);
|
||||
}
|
||||
.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-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-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(--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; }
|
||||
.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; }
|
||||
|
||||
/* Section grid */
|
||||
.grid {
|
||||
|
|
@ -51,87 +114,101 @@
|
|||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel, #13131c);
|
||||
border: 1px solid var(--line, #2a2a3a);
|
||||
background: var(--tube-light);
|
||||
border: 1px solid var(--p31-mid);
|
||||
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(--gold-hermetic, #c9a84c); margin-bottom: 1rem;
|
||||
color: var(--p31-mid); margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px dashed var(--line, #2a2a3a);
|
||||
border-bottom: 1px dashed var(--p31-ghost);
|
||||
text-shadow: var(--bloom-text);
|
||||
}
|
||||
.card .row { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 0.9rem; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
/* Bar */
|
||||
.bar {
|
||||
height: 4px; background: var(--cosmos-black, #0a0a0f); border-radius: 2px;
|
||||
height: 4px; background: var(--p31-ghost); border-radius: 2px;
|
||||
overflow: hidden; margin: 0.25rem 0;
|
||||
}
|
||||
.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); }
|
||||
.bar > span { display: block; height: 100%; background: var(--p31-peak); }
|
||||
.bar.warn > span { background: var(--yellow); }
|
||||
.bar.crit > span { background: var(--red); }
|
||||
|
||||
/* 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(--cosmos-black, #0a0a0f); border: 1px solid var(--line, #2a2a3a);
|
||||
background: var(--tube-pale); border: 1px solid var(--p31-ghost);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.module .led {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--text-muted, #6b6b7a);
|
||||
background: 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); }
|
||||
.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); }
|
||||
|
||||
/* 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(--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); }
|
||||
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); }
|
||||
|
||||
/* Big number tiles */
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; }
|
||||
.tile {
|
||||
background: var(--cosmos-black, #0a0a0f); border: 1px solid var(--line, #2a2a3a);
|
||||
background: var(--tube-pale); border: 1px solid var(--p31-ghost);
|
||||
border-radius: 4px; padding: 0.75rem; text-align: center;
|
||||
}
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
.footer {
|
||||
margin-top: 3rem; padding-top: 1.5rem;
|
||||
border-top: 1px dashed var(--line, #2a2a3a);
|
||||
text-align: center; font-size: 0.8rem; color: var(--text-muted, #6b6b7a);
|
||||
border-top: 1px dashed var(--p31-ghost);
|
||||
text-align: center; font-size: 0.8rem; color: var(--p31-dim);
|
||||
}
|
||||
.footer a { color: var(--cyber-cyan, #00d4ff); }
|
||||
.footer a { color: var(--p31-mid); }
|
||||
@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 class="crt-light">
|
||||
<nav class="sidebar" id="sidebar"></nav>
|
||||
<script src="/shared/sidebar.js"></script>
|
||||
<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>
|
||||
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
|
||||
<!-- Hero -->
|
||||
|
|
@ -218,9 +295,7 @@
|
|||
</footer>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/shared/crt-engine.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
|
@ -355,12 +430,7 @@
|
|||
}
|
||||
|
||||
function renderBans(h, 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 ban = h && h.crowdsec && h.crowdsec.active_decisions;
|
||||
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');
|
||||
|
|
@ -370,8 +440,6 @@
|
|||
$('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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user