Compare commits

..

No commits in common. "master" and "v2.14.0" have entirely different histories.

41 changed files with 337 additions and 2800 deletions

View File

@ -1,869 +0,0 @@
# Gondwana Phase 1 — Mesh Substrate Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `secubox-p2p` the single, collision-free, multi-site WireGuard mesh owner with a persistent per-node identity, cutting over the live gk2↔c3box mesh with zero disruption and enrolling the amd64 node.
**Architecture:** Extract all pure mesh logic into a new privilege-free, FastAPI-free module `api/mesh.py` (unit-testable). The `secubox-p2p` FastAPI app (runs as user `secubox`) consumes `mesh.py` for state/read endpoints only. A new **root** CLI `sbx-mesh-up` performs the privileged provisioning (adopt existing key → collision-guard → render `wg-mesh.conf``wg-quick up`), because the service user cannot run `wg-quick`. Subnet moves `10.100.0.0/24 → 10.10.0.0/24`, port `51820 → 51822`.
**Tech Stack:** Python 3.11 (stdlib `tomllib`, `subprocess`, `ipaddress`), pytest, WireGuard (`wg`, `wg-quick`), Debian packaging (debhelper 13).
## Global Constraints
- Mesh subnet `10.10.0.0/24`, port `51822`, interface `wg-mesh` — exact values.
- Mesh subnet MUST NOT overlap `10.100.0.0/24` (br-lxc), `10.55.0.0/24` (eye-br0), `10.0.3.0/24` (lxcbr0), `10.99.0.0/24` (wg-toolbox) — provisioner refuses on overlap.
- gk2 = `10.10.0.1` (active rendezvous), c3box = `10.10.0.2`, amd64 = `10.10.0.3`.
- `master_endpoint` pinned `82.67.100.75:51822` (DDNS-ready, free-form host:port).
- Rendezvous is a **role** (`role="master"|"satellite"`) — never hardwire "gk2 is master".
- Private-key **adoption**: never regenerate a key when a valid `wg-mesh.conf`/state key exists (preserves gk2↔c3box handshake).
- Registry is **local-first/replicable** (forward-compat for the Phase 2/3 ledger).
- **no mass daemon restart on gk2**; **source-first** (every live change backported); **no Claude/AI references in commits**.
- Live boxes: gk2 `192.168.1.200` (master), amd64 live-USB `192.168.1.9` (satellite, `ssh root@…` pw `secubox`), c3box `192.168.1.94` (offline now).
- Service runs as `User=secubox`, `WorkingDirectory=/usr/lib/secubox/p2p`, `uvicorn api.main:app`. wg-quick/`/etc/wireguard`/nft need root → root CLI only.
---
## File structure
- Create `packages/secubox-p2p/api/mesh.py` — pure mesh logic (no FastAPI, no privilege).
- Create `packages/secubox-p2p/scripts/sbx-mesh-up` — root provisioning CLI.
- Create `packages/secubox-p2p/conf/p2p.toml.example``[wireguard]` config seed.
- Create `packages/secubox-p2p/tests/conftest.py` + `tests/test_mesh.py` — pytest.
- Modify `packages/secubox-p2p/api/main.py` — import `mesh`, fix defaults, wire endpoints + join allocation.
- Modify `packages/secubox-p2p/debian/rules` — install conf + `sbx-mesh-up`.
- Modify `packages/secubox-p2p/debian/control``Depends: wireguard-tools`.
- Modify `packages/secubox-p2p/debian/changelog` — version bump.
All `mesh.py` functions operate on an explicit `state: dict` (the parsed `wg_mesh.json`) and explicit paths, so tests pass `tmp_path` and never touch the real filesystem.
---
### Task 1: Pure mesh module — subnet collision guard
**Files:**
- Create: `packages/secubox-p2p/api/mesh.py`
- Test: `packages/secubox-p2p/tests/test_mesh.py`
- Create: `packages/secubox-p2p/tests/conftest.py`
**Interfaces:**
- Produces: `RESERVED_SUBNETS: dict[str,str]`; `subnet_overlap(network: str) -> str | None` (returns the *name* of the first reserved subnet that overlaps `network`, else `None`); `MESH_NETWORK = "10.10.0.0/24"`, `MESH_PORT = 51822`, `MESH_INTERFACE = "wg-mesh"`.
- [ ] **Step 1: Write the failing test**
```python
# packages/secubox-p2p/tests/test_mesh.py
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) # repo package root
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
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'api.mesh'`
- [ ] **Step 3: Write minimal implementation**
```python
# packages/secubox-p2p/api/mesh.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""
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
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
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -v`
Expected: PASS (4 tests)
- [ ] **Step 5: Create conftest + commit**
```python
# packages/secubox-p2p/tests/conftest.py
# 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]))
```
```bash
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/tests/
git commit -m "feat(p2p): mesh module with subnet collision guard"
```
---
### Task 2: p2p.toml config loader + [wireguard] section
**Files:**
- Modify: `packages/secubox-p2p/api/mesh.py`
- Create: `packages/secubox-p2p/conf/p2p.toml.example`
- Test: `packages/secubox-p2p/tests/test_mesh.py`
**Interfaces:**
- Consumes: Task 1 constants.
- Produces: `load_p2p_config(path: pathlib.Path) -> dict` — reads `[wireguard]` from a TOML file, returns a dict with keys `interface, listen_port, network, role, master_endpoint`, filling defaults (`MESH_*`, `role="satellite"`, `master_endpoint=None`) for anything absent/missing-file.
- [ ] **Step 1: Write the failing test**
```python
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"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k load_p2p_config -v`
Expected: FAIL — `AttributeError: module 'api.mesh' has no attribute 'load_p2p_config'`
- [ ] **Step 3: Write minimal implementation**
```python
# add to api/mesh.py
import tomllib
import pathlib
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
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k load_p2p_config -v`
Expected: PASS (2 tests)
- [ ] **Step 5: Create the example config + commit**
```toml
# packages/secubox-p2p/conf/p2p.toml.example
# 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"
```
```bash
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/conf/p2p.toml.example packages/secubox-p2p/tests/test_mesh.py
git commit -m "feat(p2p): /etc/secubox/p2p.toml [wireguard] loader + example"
```
---
### Task 3: Master-assigned mesh IP allocation
**Files:**
- Modify: `packages/secubox-p2p/api/mesh.py`
- Test: `packages/secubox-p2p/tests/test_mesh.py`
**Interfaces:**
- Consumes: Task 1/2.
- Produces: `allocate_mesh_ip(network: str, taken: list[str]) -> str` — returns the lowest free host address in `network`, starting at `.2` (`.1` is reserved for the master), skipping any address already in `taken` (each `taken` item may be `"10.10.0.2"` or `"10.10.0.2/24"`). Raises `RuntimeError` if the pool is exhausted.
- [ ] **Step 1: Write the failing test**
```python
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)]
import pytest
with pytest.raises(RuntimeError):
mesh.allocate_mesh_ip("10.10.0.0/24", taken)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k allocate -v`
Expected: FAIL — `AttributeError: ... 'allocate_mesh_ip'`
- [ ] **Step 3: Write minimal implementation**
```python
# add to api/mesh.py
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")
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k allocate -v`
Expected: PASS (3 tests)
- [ ] **Step 5: Commit**
```bash
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/tests/test_mesh.py
git commit -m "feat(p2p): master-assigned mesh IP allocation (.2+, .1=master)"
```
---
### Task 4: Parse + render wg-mesh.conf (adoption + provisioning)
**Files:**
- Modify: `packages/secubox-p2p/api/mesh.py`
- Test: `packages/secubox-p2p/tests/test_mesh.py`
**Interfaces:**
- Consumes: Task 1.
- Produces:
- `parse_wg_conf(text: str) -> dict` — extracts `{"private_key", "address", "listen_port"}` from a `wg-quick` `[Interface]` block (values absent → key maps to `None`).
- `render_wg_conf(state: dict) -> str` — builds a `wg-quick` config from a state dict with keys `private_key, address, listen_port, peers` (each peer: `public_key, endpoint(optional), allowed_ips`). Omits `Endpoint` when a peer has none (roaming spokes on the master).
- [ ] **Step 1: Write the failing test**
```python
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
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k "parse_wg or render_wg" -v`
Expected: FAIL — missing `parse_wg_conf` / `render_wg_conf`
- [ ] **Step 3: Write minimal implementation**
```python
# add to api/mesh.py
import re
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"
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k "parse_wg or render_wg" -v`
Expected: PASS (3 tests)
- [ ] **Step 5: Commit**
```bash
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/tests/test_mesh.py
git commit -m "feat(p2p): parse/render wg-mesh.conf (key adoption + provisioning)"
```
---
### Task 5: DDNS name in node identity
**Files:**
- Modify: `packages/secubox-p2p/api/mesh.py`
- Test: `packages/secubox-p2p/tests/test_mesh.py`
**Interfaces:**
- Produces: `ddns_name(hostname: str, domain: str = "secubox.in") -> str` — returns `"<hostname>.secubox.in"`, lowercased, with any non-`[a-z0-9-]` in `hostname` replaced by `-`.
- [ ] **Step 1: Write the failing test**
```python
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"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k ddns -v`
Expected: FAIL — missing `ddns_name`
- [ ] **Step 3: Write minimal implementation**
```python
# add to api/mesh.py
def ddns_name(hostname: str, domain: str = "secubox.in") -> str:
slug = re.sub(r"[^a-z0-9-]", "-", hostname.lower())
return f"{slug}.{domain}"
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k ddns -v`
Expected: PASS (2 tests)
- [ ] **Step 5: Commit**
```bash
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/tests/test_mesh.py
git commit -m "feat(p2p): per-node DDNS identity name helper"
```
---
### Task 6: Wire mesh.py into api/main.py (defaults + endpoints + join allocation)
**Files:**
- Modify: `packages/secubox-p2p/api/main.py:977-1099` (WG constants, init, peer), `:1058-1062` (hash allocation), `:1746-1752` (join depth/peer).
**Interfaces:**
- Consumes: `api.mesh` (Tasks 15).
- Produces: `/wireguard` status reports `network=10.10.0.0/24, listen_port=51822` and a `ddns` field; `/wireguard/init` assigns `.1` for `role=master` else a master-allocated address; join records a `mesh_ip`.
- [ ] **Step 1: Replace the WG constants (main.py:977-980)**
```python
# was: WG_PORT = 51820 ; WG_NETWORK = "10.100.0.0/24"
from api import mesh
WG_MESH_CONFIG = P2P_DIR / "wg_mesh.json"
WG_INTERFACE = mesh.MESH_INTERFACE
WG_PORT = mesh.MESH_PORT
WG_NETWORK = mesh.MESH_NETWORK
```
- [ ] **Step 2: Make `/wireguard/init` role-aware + master-allocated (replace main.py:1058-1062)**
```python
# 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())
```
- [ ] **Step 3: Default peer allowed_ips to the mesh subnet (main.py:1078)**
```python
allowed_ips: str = "10.10.0.0/24",
```
- [ ] **Step 4: Add a guarded refusal to `/wireguard/enable` (insert after main.py:1108)**
```python
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")
```
- [ ] **Step 5: Record `mesh_ip` on approved join (insert in `ml_join` auto-approve block, main.py:1746)**
```python
join_request["depth"] = peer_depth
_taken = [p.get("address", "") for p in
load_json(PEERS_FILE, {"peers": []}).get("peers", [])]
join_request["mesh_ip"] = mesh.allocate_mesh_ip(mesh.MESH_NETWORK, _taken)
```
- [ ] **Step 6: Smoke-test the import + app load**
Run: `cd packages/secubox-p2p && python3 -c "import sys; sys.path.insert(0,'.'); from api import main; print('ok', main.WG_NETWORK, main.WG_PORT)"`
Expected: `ok 10.10.0.0/24 51822`
- [ ] **Step 7: Commit**
```bash
git add packages/secubox-p2p/api/main.py
git commit -m "feat(p2p): adopt mesh.py — 10.10.0.0/24:51822, role-aware addressing, collision guard"
```
---
### Task 7: Root provisioning CLI `sbx-mesh-up`
**Files:**
- Create: `packages/secubox-p2p/scripts/sbx-mesh-up`
- Test: `packages/secubox-p2p/tests/test_mesh.py` (logic already covered; this task adds an idempotency test for `adopt_state`)
- Modify: `packages/secubox-p2p/api/mesh.py` (add `adopt_state`)
**Interfaces:**
- Consumes: Tasks 15.
- Produces: `adopt_state(state: dict, existing_conf_text: str | None) -> dict` — if `state` has no `private_key` but `existing_conf_text` parses one, import `private_key`/`address`/`listen_port` into `state` (so the public key is preserved); never overwrite an existing `private_key`. Returns the updated state. `sbx-mesh-up` (root) ties it together.
- [ ] **Step 1: Write the failing test**
```python
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"
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="
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k adopt -v`
Expected: FAIL — missing `adopt_state`
- [ ] **Step 3: Implement `adopt_state` in api/mesh.py**
```python
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
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd packages/secubox-p2p && python3 -m pytest tests/test_mesh.py -k adopt -v`
Expected: PASS (2 tests)
- [ ] **Step 5: Write the root CLI**
```bash
# packages/secubox-p2p/scripts/sbx-mesh-up
#!/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
PKG=/usr/lib/secubox/p2p
python3 - "$STATE" "$CONF" <<'PY'
import json, sys, subprocess, 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
```
- [ ] **Step 6: Lint the script**
Run: `bash -n packages/secubox-p2p/scripts/sbx-mesh-up && echo OK`
Expected: `OK`
- [ ] **Step 7: Commit**
```bash
chmod +x packages/secubox-p2p/scripts/sbx-mesh-up
git add packages/secubox-p2p/api/mesh.py packages/secubox-p2p/scripts/sbx-mesh-up packages/secubox-p2p/tests/test_mesh.py
git commit -m "feat(p2p): root sbx-mesh-up provisioner (adopt key, guard, render, up)"
```
---
### Task 8: Packaging — ship config + CLI, depend on wireguard-tools, bump
**Files:**
- Modify: `packages/secubox-p2p/debian/rules`, `debian/control`, `debian/changelog`
**Interfaces:**
- Consumes: Tasks 17 artifacts.
- Produces: installed `/etc/secubox/p2p.toml.example`, `/usr/bin/sbx-mesh-up`, runtime dep `wireguard-tools`, version `1.7.6`.
- [ ] **Step 1: Add install lines to `override_dh_auto_install` (debian/rules, before "Create runtime directory")**
```makefile
# 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/
```
- [ ] **Step 2: Add `wireguard-tools` to Depends (debian/control)**
```
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, avahi-daemon, avahi-utils, wireguard-tools
```
- [ ] **Step 3: Add changelog entry (top of debian/changelog)**
```
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
```
- [ ] **Step 4: Build the package (arch:all)**
Run: `cd packages/secubox-p2p && dpkg-buildpackage -us -uc -b 2>&1 | tail -5`
Expected: `dpkg-deb: building package 'secubox-p2p' in '../secubox-p2p_1.7.6-1~bookworm1_all.deb'.`
- [ ] **Step 5: Commit**
```bash
git add packages/secubox-p2p/debian/
git commit -m "build(p2p): ship p2p.toml.example + sbx-mesh-up, dep wireguard-tools, 1.7.6"
```
---
### Task 9: Cutover on gk2 — adopt + master, handshake preserved
**Files:** none (live operation; uses Task 8's `.deb`).
**Interfaces:** Consumes the built `secubox-p2p_1.7.6` deb.
- [ ] **Step 1: Snapshot the live wg-mesh public key BEFORE**
Run: `ssh root@192.168.1.200 'wg show wg-mesh public-key; wg show wg-mesh latest-handshakes'`
Record the public key and that c3box's handshake is recent.
- [ ] **Step 2: Install the new package on gk2 (single unit, no mass restart)**
Run: `scp ../secubox-p2p_1.7.6-1~bookworm1_all.deb root@192.168.1.200:/tmp/ && ssh root@192.168.1.200 'dpkg -i /tmp/secubox-p2p_1.7.6-1~bookworm1_all.deb && systemctl try-restart secubox-p2p'`
Expected: unpacked + configured; only `secubox-p2p` restarts.
- [ ] **Step 3: Write gk2's p2p.toml as master**
Run:
```bash
ssh root@192.168.1.200 'cat > /etc/secubox/p2p.toml <<EOF
[wireguard]
interface = "wg-mesh"
listen_port = 51822
network = "10.10.0.0/24"
role = "master"
EOF
chown secubox:secubox /etc/secubox/p2p.toml'
```
- [ ] **Step 4: Run the provisioner — it must ADOPT the live key**
Run: `ssh root@192.168.1.200 'sbx-mesh-up'`
Expected: `rendered /etc/wireguard/wg-mesh.conf (addr 10.10.0.1/24, peers 1)` then `wg show wg-mesh` output.
- [ ] **Step 5: Verify the public key is UNCHANGED and c3box still configured**
Run: `ssh root@192.168.1.200 'wg show wg-mesh public-key'`
Expected: **identical** to Step 1's key. (If different, adoption failed — restore `/etc/wireguard/wg-mesh.conf.pre` and stop.)
- [ ] **Step 6: Confirm `/wireguard` API truth now matches reality**
Run: `ssh root@192.168.1.200 'curl -s --unix-socket /run/secubox/p2p.sock http://x/wireguard'`
Expected: JSON with `"network":"10.10.0.0/24","listen_port":51822` and `status.running=true`.
- [ ] **Step 7: Commit a note (no code) — record cutover done**
No commit; proceed to Task 10. (Source already carries the change from Task 8.)
---
### Task 10: Freebox forward + enroll amd64 (.3) + verify mesh
**Files:** none (live operation + operator action).
- [ ] **Step 1: OPERATOR ACTION — add Freebox UDP 51822 → 192.168.1.200**
Manual: Freebox OS → Ports → add `UDP 51822 → 192.168.1.200:51822`.
Verify from outside is optional now (amd64 is on the LAN); required when a node goes remote.
- [ ] **Step 2: Install the new package on amd64 (.9)**
Run: `scp ../secubox-p2p_1.7.6-1~bookworm1_all.deb root@192.168.1.9:/tmp/ && ssh root@192.168.1.9 'dpkg -i /tmp/secubox-p2p_1.7.6-1~bookworm1_all.deb'`
Expected: configured.
- [ ] **Step 3: Write amd64's p2p.toml as satellite + init identity**
Run:
```bash
ssh root@192.168.1.9 'cat > /etc/secubox/p2p.toml <<EOF
[wireguard]
interface = "wg-mesh"
listen_port = 51822
network = "10.10.0.0/24"
role = "satellite"
master_endpoint = "82.67.100.75:51822"
EOF
chown secubox:secubox /etc/secubox/p2p.toml
curl -s --unix-socket /run/secubox/p2p.sock -X POST http://x/wireguard/init -H "Authorization: Bearer $(cat /etc/secubox/secrets/*jwt* 2>/dev/null | head -1)"'
```
Expected: JSON with `public_key` and `address` (allocated; will be `.3` once gk2 assigns — see Step 4 note).
- [ ] **Step 4: Register amd64 as a peer on gk2 (.3) and on amd64 (gk2)**
Run (capture amd64 pubkey, then add on gk2; add gk2 on amd64):
```bash
AMD_PUB=$(ssh root@192.168.1.9 'wg show wg-mesh public-key 2>/dev/null || python3 -c "import json;print(json.load(open(\"/var/lib/secubox/p2p/wg_mesh.json\"))[\"public_key\"])"')
GK2_PUB=$(ssh root@192.168.1.200 'wg show wg-mesh public-key')
# gk2: add amd64 as roaming spoke .3/32 (edit state, re-provision)
ssh root@192.168.1.200 "python3 -c \"import json;p='/var/lib/secubox/p2p/wg_mesh.json';d=json.load(open(p));d.setdefault('peers',[]).append({'public_key':'$AMD_PUB','allowed_ips':'10.10.0.3/32'});json.dump(d,open(p,'w'),indent=2)\" && sbx-mesh-up"
# amd64: set address .3 + gk2 peer, provision
ssh root@192.168.1.9 "python3 -c \"import json;p='/var/lib/secubox/p2p/wg_mesh.json';d=json.load(open(p));d['address']='10.10.0.3/24';d['peers']=[{'public_key':'$GK2_PUB','endpoint':'82.67.100.75:51822','allowed_ips':'10.10.0.0/24'}];json.dump(d,open(p,'w'),indent=2)\" && sbx-mesh-up"
```
Expected: both `wg show wg-mesh` list each other.
- [ ] **Step 5: Verify handshakes + inter-node reachability**
Run:
```bash
ssh root@192.168.1.9 'ping -c2 -W2 10.10.0.1' # amd64 -> gk2
ssh root@192.168.1.200 'ping -c2 -W2 10.10.0.3' # gk2 -> amd64
ssh root@192.168.1.9 'wg show wg-mesh latest-handshakes'
```
Expected: pings succeed; handshake with gk2 is recent.
- [ ] **Step 6: Verify threatmesh reachability over the mesh**
Run: `ssh root@192.168.1.9 'curl -s -m4 -o /dev/null -w "%{http_code}\n" http://10.10.0.1:8780/api/v1/threatmesh/mesh/ingest -X POST -H "Content-Type: application/json" -d "{}"'`
Expected: a HTTP code (e.g. `400/422/200`) — **not** a timeout/`000` — proving spoke→hub service reachability over wg-mesh.
- [ ] **Step 7: Final source sync check**
Confirm the live `/etc/secubox/p2p.toml` contents and `sbx-mesh-up` behavior match the packaged source (Task 8). If any live tweak was needed, backport it to `conf/p2p.toml.example` or `scripts/sbx-mesh-up` and commit:
```bash
git add -A packages/secubox-p2p/
git commit -m "fix(p2p): backport gondwana P1 cutover tweaks from gk2/amd64"
```
---
## Self-review
**Spec coverage:**
- §2 addressing (10.10.0.0/24, master-assigned, .1/.2/.3) → Tasks 1,3,6,9,10. ✓
- §2 collision guard → Tasks 1,6,7. ✓
- §3 identity (persistent keypair, node-id, DDNS name, live-USB persistence) → Tasks 5,6; persistence is `/var/lib/secubox/p2p` on amd64 partition (Task 10 writes there). ✓
- §4 topology (master roaming peers, satellite endpoint+keepalive, hub routing) → Tasks 4,6,10. ✓
- §5 secubox-p2p changes (config, adoption, provisioning, guard, join wiring) → Tasks 2,4,6,7. ✓
- §6 cutover (gk2 adopt+master, Freebox, amd64 .3, verify, backport) → Tasks 9,10. ✓
- §7 failure modes (key-regen guarded by adopt_state; collision guard; keepalive) → Tasks 7,1,4. ✓
**Placeholder scan:** no TBD/TODO; every code step shows full code; verification steps show exact commands + expected output. ✓
**Type consistency:** `mesh.MESH_NETWORK/MESH_PORT/MESH_INTERFACE`, `subnet_overlap`, `load_p2p_config`, `allocate_mesh_ip`, `parse_wg_conf`, `render_wg_conf`, `ddns_name`, `adopt_state` used consistently across Tasks 110. ✓
**Known limitation (documented, not a gap):** inter-satellite (c3box↔amd64) traffic relies on gk2 hub routing; with c3box offline this is unverifiable now — Step 6 verifies spoke→hub, which is the testable subset. Direct spoke-to-spoke verification waits for c3box online.

View File

@ -1,204 +0,0 @@
# Gondwana Phase 1 — Mesh Transport + Node Identity (Substrate)
**Date:** 2026-06-29
**Status:** Design approved — pending spec review → implementation plan
**Scope:** Phase 1 of the gondwana program (substrate only). Phases 24 are
out of scope here and get their own spec → plan → build cycles.
---
## 1. Context & problem
SecuBox now runs on three nodes that should form one mesh ("gondwana"):
| Node | Role | Address | Notes |
|-------|-----------------|----------------------|-------|
| gk2 | master / public hub | 192.168.1.200 (WAN via Freebox, public 82.67.100.75) | only node with a stable public ingress |
| c3box | reference (mochabin) | 192.168.1.94 (currently offline) | satellite |
| amd64 | live-USB | 192.168.1.9 | satellite, ephemeral medium |
Goal of the wider program: **service mirroring/redundancy, redundant access,
and (above all) shared protections** across nodes, with a **zero-trust
GK-HAM ZKP** trust model (#762) as the target.
That program layers on a transport+identity substrate that does not cleanly
exist today. Two concrete defects block everything else:
1. **Two half-systems.** The live mesh is a hand-rolled `wg-quick` interface
(`wg-mesh`, `10.10.0.0/24`, UDP `51822`, gk2=`.1` ↔ c3box=`.2`) created
*outside* `secubox-p2p`. Meanwhile `secubox-p2p` has its own WireGuard
provisioning code that is dormant and reports `enabled=false, 0 peers`.
2. **Subnet collision.** `secubox-p2p`'s WireGuard default network is
`10.100.0.0/24`**identical to the `br-lxc` LXC bridge**. If the p2p
layer ever brought up its interface on the default, it would collide with
every LXC (Lyrion, mail, mqtt, grafana, …). This is a primary reason the
MirrorNet layer never took over the mesh.
Phase 1 makes "the live mesh" and "the MirrorNet layer" the **same thing**,
on a collision-free subnet, reachable multi-site, with a persistent
per-node identity that Phase 2 (ZKP/did:plc) will wrap.
### Decisions locked during brainstorming
- **Topology:** multi-site distributed (nodes on different sites/links).
- **Trust target:** zero-trust GK-HAM (#762) — but implemented in Phase 2;
Phase 1 keeps the existing plain-auth join behind the same interface.
- **Rendezvous:** gk2 exposed via a **dedicated Freebox UDP `51822 → .200`**
forward (separate from the toolbox VPN on 51820).
- **Rendezvous is a ROLE, not a hardwired hub (revised 2026-06-29).** Any
node may hold the rendezvous role; the *active* rendezvous is whichever
node is currently publicly reachable. Today only gk2 has a public ingress,
so gk2 is the active rendezvous — but config/code must not hardwire "gk2
is the master." Each node also carries a **DDNS name as part of its
identity** (`<boxname>.secubox.in`), so reachability is name-based and the
rendezvous can float later without reconfiguring peers. Phase 1 builds
only this forward-compatibility; availability-based failover between
multiple rendezvous nodes is Phase 4 (hub HA), and the shared state moving
to a distributed ledger is Phase 2/3 (see §8).
- **Approach:** make `secubox-p2p` the mesh owner (vs. keep-wg-quick, vs. new
daemon). "Owner" = the component that provisions WireGuard and holds the
peer registry; the registry is **local-first/replicable**, not a
gk2-exclusive source of truth, so it can migrate to the Phase-2/3 ledger.
---
## 2. Addressing model
- **Mesh subnet: `10.10.0.0/24`** (keep the interim subnet; already live and
collision-free).
- **Hard collision guard:** the mesh subnet MUST NOT overlap `br-lxc`
(10.100.0.0/24), `eye-br0` (10.55.0.0/24), `lxcbr0` (10.0.3.0/24), or
`wg-toolbox` (10.99.0.0/24). The provisioner refuses to enable on overlap.
- **Allocation: master-assigned, deterministic.** gk2 = `10.10.0.1` (fixed
master). Satellites are assigned the next free `.2.254` *by gk2 at join*
and recorded in gk2's peer registry. (Replaces the current
hash-from-node-id scheme, which can silently collide.) c3box stays `.2`,
amd64 becomes `.3`.
## 3. Identity model
- Each node owns a persistent **WireGuard keypair + stable `node-id`** under
`/var/lib/secubox/p2p/`:
- `wg_mesh.json` — holds the private key, `0600 secubox:secubox`.
- `node.id` — stable node identifier.
- `(pubkey, node-id)` **is** the Phase-1 identity; Phase 2 GK-HAM ZKP /
did:plc wraps it rather than replacing it.
- **Live-USB caveat (amd64):** identity is persisted on the persistence
partition so it survives reboot. If absent, the node re-enrolls fresh and
gk2 dedupes the stale peer entry by hostname.
## 4. Topology & routing — hub-and-spoke via gk2
- **gk2 (hub):** listens `:51822`; public `Endpoint = <gk2-public>:51822`.
One `[Peer]` per satellite with `AllowedIPs = 10.10.0.<n>/32` and **no**
Endpoint (learned from each satellite's handshake → roaming; nomadic amd64
works with no reconfig).
- **Satellites (spokes):** a single `[Peer]` = gk2, `AllowedIPs =
10.10.0.0/24`, `PersistentKeepalive = 25` (holds the NAT hole open).
- **Inter-satellite traffic** (e.g. threatmesh gossip c3box↔amd64) routes
**through gk2**: spoke → `10.10.0.0/24` → gk2 → forward → other spoke.
gk2 already has `ip_forward=1` and nftables `forward policy accept`, so the
hairpin needs no new rule.
- Same-LAN nodes may later get direct peer entries as an optimization; the
uniform baseline is hub-routed (correct behind any NAT).
---
## 5. secubox-p2p changes (the single reconciling change)
- **Config** — new `/etc/secubox/p2p.toml [wireguard]`:
`interface="wg-mesh"`, `listen_port=51822`, `network="10.10.0.0/24"`,
`role="master"|"satellite"`, `master_endpoint="<gk2-public>:51822"`
(satellites only). Code defaults change `51820→51822` and
`10.100.0.0/24→10.10.0.0/24`.
- **`master_endpoint` is a free-form host:port** — it accepts either a
DDNS hostname (future-proofing against a changing WAN IP) or a literal
IP. WireGuard re-resolves a hostname on each handshake, so a DDNS name
survives IP changes with no reconfig. **Current deployment pins the
literal public IP: `82.67.100.75:51822`**; switching to a DDNS name is a
one-line config change later.
- **Adoption (critical for zero cutover):** on enable, if
`/etc/wireguard/wg-mesh.conf` already exists with the same subnet/port,
**import its existing private key** into `wg_mesh.json` so the public key
is unchanged → the gk2↔c3box handshake survives. Never regenerate a key
when a valid one exists.
- **Provisioning:** `/wireguard/enable` (re)writes a standard `wg-quick`
`wg-mesh.conf` from config + peer registry and `wg-quick up`s it
idempotently. `/wireguard/peer` adds/removes a `[Peer]`.
- **Collision guard:** refuse to enable if `network` overlaps the bridges in
§2.
- **Join wiring:** `master-link/join` assigns the next free `10.10.0.x`,
returns it plus gk2's pubkey/endpoint, and adds the peer on both ends.
Plain-auth for now; Phase 2 swaps in ZKP behind this same interface.
---
## 6. Cutover plan — zero disruption, in order
1. **gk2:** import the live `wg-mesh` private key into p2p state; set
`role=master`, `10.10.0.0/24:51822`; switch to p2p-managed. Generated conf
≡ current conf → **c3box handshake preserved**.
2. **Freebox:** add UDP `51822 → 192.168.1.200` (operator action; until then
satellites join only from the LAN).
3. **amd64 (.9):** generate identity → gk2 issues join (`.3`) → peer added
both sides → satellite brings up `wg-mesh` with `Endpoint=<gk2-public>:51822`.
4. **Verify:** handshakes on all three; `10.10.0.1 ↔ .2 ↔ .3` ping through
the hub; threatmesh `:8780` reachable spoke-to-spoke.
5. **Backport:** every step lands in source (p2p.toml defaults, provisioning,
guard) — no live-only drift.
---
## 7. Failure modes & mitigations
| Failure | Mitigation |
|---------|------------|
| Key regenerated on adopt → breaks c3box | Import-or-keep existing privkey; never regen if a valid key exists |
| Subnet regression (overlap br-lxc etc.) | Collision guard refuses to start |
| gk2 (hub) down | Already-handshaked spokes keep roaming on last endpoint; *new* joins blocked (accepted for Phase 1; Phase 4 adds HA) |
| amd64 live-USB wiped | Re-enroll fresh; gk2 dedupes stale peer by hostname |
| NAT hole closes | `PersistentKeepalive=25` on spokes |
---
## 8. Out of scope (later phases)
- **Cross-cutting — Distributed directory (DNS-structured ledger, requested
2026-06-29).** Shared mesh state (peers, services, threat-intel, name
records) migrates from per-node JSON registries to a replicated,
append-only, hierarchically-named directory every node holds — a
blockchain/DID-style ledger "like DNS." This is the concrete form of the
CLAUDE.md `did:plc` + "Chain of Hamiltonians → HamCoin" intent. It is the
data-plane substrate for Phases 24 (identity records in P2, threat
records in P3, name records in P4). Phase 1 keeps the registry
**local-first/replicable** specifically so it can be backed by this ledger
later without reworking the transport.
- **Phase 2** — GK-HAM ZKP enrollment (#762): hamiltonian ZKP join, did:plc
identity, auto-discover / magic-invite over wg. Each node's
`(pubkey, node-id, boxname)` from Phase 1 becomes its ledger identity
record.
- **Phase 3** — Zero-trust protection sharing: signed threatmesh gossip,
N-source consensus, peer-identity-gated ingestion, WAF-rule sharing.
- **Phase 4** — Service mirroring + access redundancy: service replication,
multi-endpoint failover (DNS / HAProxy), hub HA.
- **Auto-registration + per-node naming (requested 2026-06-29):** each
node registers itself with the central `secubox.in` and automatically
gets vhosts published as `<service>.<boxname>.secubox.in`. Architecture
that falls out of Phase 1: DNS for `*.<boxname>.secubox.in` resolves to
**gk2's public IP** (the only public ingress; satellites are behind
NAT); gk2's HAProxy/mitmproxy routes by `Host:` **over the wg-mesh** to
the owning node's service. Consumes the Phase-1 node identity
(`boxname`/`node-id`) + mesh transport. **Open question for Phase 4
design:** how `*.secubox.in` DNS records are authored — gk2 as an
authoritative zone vs. a registrar/provider API. Must keep the
no-waf_bypass rule (every published vhost routes through
mitmproxy_inspector).
## 9. Success criteria (Phase 1)
1. `secubox-p2p` reports the mesh as enabled with the real peers (no more
`enabled=false, 0 peers`); `/wireguard` truth matches `wg show wg-mesh`.
2. No subnet overlaps any bridge; collision guard proven to refuse a bad
subnet.
3. gk2↔c3box handshake uninterrupted across cutover (same keys).
4. amd64 (`.3`) joins via the master flow and reaches `.1` and `.2`.
5. All changes present in source; a fresh install reproduces the topology.

View File

@ -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 45 step incremental build plan where each step ships something usable. Headline apps to use as worked examples: lyrion (LXC music), peertube (LXC video), nextcloud (cloud), webmail/mail, kbin/gotosocial (fediverse).
---
## 10. GPT prompt — COMPLETE (architectural research notes) · copy-paste
> **Role.** You are a distributed-systems architect. Write **architectural research notes / a design sketch** (not production code) for the **"SecuBox App Store + Module Composer"** — a web UI and control plane to discover, install, enable/disable (at multiple granularities), configure, profile, monitor, and **federate over a P2P mesh** every module of a Debian security appliance. Prefer extending what already exists over inventing new systems. Be concrete, opinionated, and implementable; call out trade-offs and pick.
>
> **Platform ground truth (design WITH this):**
> - SecuBox-DEB: Debian bookworm appliance. ~128 "modules"; each is a Debian package `secubox-<name>` from a signed apt repo (`apt.secubox.in`); `dpkg` = installed truth.
> - Each module already ships a manifest `debian/secubox.yaml` = `{name, category, tier, description, depends, api:{socket,health}, ui:{path}}`. Categories: media, email, ai, iot, communication, publishing, network, security, system, vpn, dashboard, misc. Tiers: lite|standard|pro|all (the board has a tier; higher-tier modules are locked).
> - Module anatomy: a FastAPI control-plane `api/main.py` (mounted by a central **aggregator** at `/api/v1/<name>/` over a unix socket, running as **unprivileged user `secubox`, NoNewPrivileges**), an nginx vhost fragment, an optional `sbin/<name>ctl` CLI, an optional `lib/<name>/install-lxc.sh` that provisions the app in an **LXC** (9 LXC apps today: lyrion, peertube, photoprism, jellyfin, grafana, mqtt, yacy, rustdesk, zigbee), a `conf/<name>.toml.example`, a hub menu entry `menu.d/NNN-<name>.json`, and a static dashboard `www/<name>/`. A central **hub** aggregates everything.
> - **Hard constraints:** the API process is unprivileged — it CANNOT apt-install, run lxc/systemctl, or edit /etc/nginx; privileged actions go through a separate root path. CSPN/ANSSI security: append-only audit log; **double-buffer/4R** for sensitive config writes (shadow→validate→atomic-swap→rollback); AppArmor; **no `waf_bypass`** (all public exposure routes through an HAProxy→mitmproxy WAF chain). Installs take minutes (apt + LXC debootstrap) → lifecycle ops must be **asynchronous with progress**.
> - **Mesh ("gondwana"):** the appliance is one of several nodes (today: gk2=master/`10.10.0.1`, c3box=`.2`, amd64=`.3`) on a WireGuard mesh `10.10.0.0/24:51822` owned by module `secubox-p2p`. The mesh has node identity (wg pubkey + node-id + DDNS name `<boxname>.secubox.in`) and a planned **distributed directory** (replicated peers/services/name records, DNS-like) plus per-node service naming `<service>.<boxname>.secubox.in` routed via the hub.
>
> **Design the following (produce research notes for each):**
> 1. **Catalog & state.** Merge apt-available dpkg-installed into a categorized, tiered, searchable catalog. Per-module state machine: available→installed→enabled→running, plus update-available/error/tier-locked. Health model: service active / LXC state / socket up / last `/health` / mem-disk.
> 2. **Manifest schema v2 (additive, keeps 128 manifests valid):** an `appstore:` block (icon, summary, long_description, screenshots, `lxc:bool`, `exposure: none|optional|required`, default_ports, resources, conflicts, homepage) **and a `components:` list** declaring toggleable sub-features (mapped to the module's own config flags / sub-services).
> 3. **Granular control — four levels, each independently toggleable:** L0 module (install/enable/disable whole package+LXC); L1 component (sub-features within a running module); L2 navbar/menu (show/hide/reorder `menu.d` entries, independent of whether the module runs); L3 appearance (theme/palette/layout/branding). Define the data model + API for each.
> 4. **Profiles.** A named, versioned bundle of `{enabled modules, per-module component flags, navbar layout+order, appearance, exposure}`. Built-in presets (Minimal, Media Center, Security Ops, Home/Family, Headless/Kiosk) that can align to tier; custom create/clone/edit/export/import. **Apply = one atomic transaction via double-buffer/4R** with one-click rollback. Scope: a per-board active profile AND optional per-user profiles (master users see different navbars/apps — ties to identity). Storage + API (`/profiles`, `apply`, `rollback`, `active`).
> 5. **Lifecycle API + privilege bridge + async jobs.** REST read endpoints (`/catalog`, `/module/{name}`, `/health`, `/jobs/{id}`) and mutating ones (install, uninstall, enable, disable, restart, prefs GET/PUT, expose). The unprivileged API must reach root work: compare (a) a narrow sudoers rule to a root `secubox-appstorectl <verb> <name>` vs (b) a root **job-queue worker** consuming a spool dir; pick one for CSPN auditability. Define the job/progress model and install pipeline stages (download→install→provision-lxc→enable→health-check).
> 6. **Prefs editing.** Render a schema-driven form from `conf/<name>.toml.example` (or a declared JSON-schema); validate; write via the 4R double-buffer path.
> 7. **P2P distribution (rides the mesh).** Make `apt.secubox.in` **P2P-mirrored**: each node holds a GPG-signed mirror synced over the wg-mesh (rsync/content-addressed deltas); install source preference local→mesh-peer→upstream; **survives the master being offline**; never trust an unsigned peer mirror (verify `InRelease`). Make the **catalog federated**: each node advertises held package versions + running services (this is the distributed directory's first consumer); the UI shows a mesh-wide view ("install here" vs "running on c3box").
> 8. **Multi-service agents (serve all network clients).** Each node runs its modules as agents serving its LAN clients; the mesh makes any service reachable from any node so clients are served from the nearest node (service mirroring + access redundancy). Design a thin agent/orchestration layer: health/who-runs-what gossip, failover (dead service → route to a mesh replica), placement advice (install on the best-suited node), client access via `<service>.<boxname>.secubox.in`. App store = control plane; mesh agents = data plane of one distributed appliance.
> 9. **Web UI component tree.** CatalogGrid/AppCard; Category/Tier/Search/State filters; AppDetailDrawer (description, screenshots, ActionBar, schema-driven PrefsForm, component toggles, HealthPanel, LogViewer, JobProgress); a Profiles manager; a cross-module **Live Care** board (what's down/degraded, bulk restart/repair/update-all, mesh-wide). Palette: C3BOX (cosmos-black/gold-hermetic/cyber-cyan; Cinzel + JetBrains Mono).
> 10. **Key decisions to resolve:** new `secubox-appstore` package vs extend the hub; job progress transport (poll vs SSE/WebSocket); tier hard-block vs upsell; LXC uninstall data-retention; mesh-sync protocol for the repo and the directory.
>
> **Output format (research notes):** an ASCII architecture diagram (UI ↔ appstore API ↔ root worker ↔ apt/lxc/systemd, plus the mesh: mirror sync + federated catalog + agents); the manifest v2 + `components:` schema; the full REST API table; the profile/4R model; the P2P mirror + federated-catalog + agent protocol; the privilege/async-job design; the UI component tree; and a phased, each-step-shippable build plan. Use these as worked examples throughout: **lyrion** (LXC music), **peertube** (LXC video), **nextcloud** (cloud), **webmail/mail**, **kbin/gotosocial** (fediverse).

View File

@ -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}

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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/

View File

@ -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"]
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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")

View File

@ -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>

View File

@ -14,10 +14,7 @@ import asyncio
import os
import time
from pathlib import Path
try:
from . import netstats # uvicorn `api.main:app` / aggregator import
except ImportError: # standalone with api/ on sys.path (collector)
import netstats # #758 — shared collector/reader module
import netstats # #758 — shared collector/reader module
app = FastAPI(title="secubox-hub", version="1.7.0", root_path="/api/v1/hub")

View File

@ -1,15 +1,3 @@
secubox-hub (1.5.1-1~bookworm1) bookworm; urgency=medium
* fix(#758): hub crash-loop — `import netstats` could not resolve.
netstats.py ships in the api/ package, but the service runs
`uvicorn api.main:app` with WorkingDirectory=/usr/lib/secubox/hub,
so the bare top-level import failed with ModuleNotFoundError and the
unit restarted ~9000 times. main.py now does `from . import netstats`
with a fallback to the top-level import (kept for the collector, which
adds api/ to sys.path explicitly). No API change.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 10:30:00 +0200
secubox-hub (1.5.0-1~bookworm1) bookworm; urgency=medium
* feat(#758): nft-based network-stats collector — root oneshot+timer samples

View File

@ -1,21 +1,3 @@
secubox-lyrion (1.1.1-1~bookworm1) bookworm; urgency=medium
* fix(slimproto): DNAT was bound to a hardcoded "lan0" interface, which
is DOWN on a SecuBox deployed behind another router (e.g. gk2 behind a
Freebox, where LAN clients arrive on the uplink eth2). Result: 0
players could reach the LMS — the prerouting rule never matched.
install-lxc.sh ensure_slimproto_dnat() now generates an
interface-agnostic rule (iifname != br-lxc) so it matches players on
any LAN/Wi-Fi/uplink interface; SECUBOX_LAN_IFACE still pins a single
interface when set. Forward chain is policy accept and conntrack
rewrites the reply (LXC gateway = host), so DNAT alone suffices.
NOTE: hardware players may still need the Wi-Fi AP bridged or a manual
LMS-server IP — LMS advertises its LXC IP (10.100.0.100) for streaming,
which is not L2-reachable from the LAN; the DNAT only fixes slimproto
control reachability.
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 12:00:00 +0200
secubox-lyrion (1.1.0-1~bookworm1) bookworm; urgency=medium
* /lyrion/ on the canonical hub vhost is now a SecuBox admin webui,

View File

@ -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/

View File

@ -67,38 +67,21 @@ ensure_masquerade() {
# systemd nftables.service loads everything in that directory.
ensure_slimproto_dnat() {
local nft_file="/etc/nftables.d/secubox-lyrion-dnat.nft"
# The DNAT must catch players arriving on whatever interface faces the
# LAN. That differs per topology: a SecuBox behind another router (e.g.
# gk2 behind a Freebox) sees players on its uplink (eth2); a router-mode
# SecuBox sees them on its LAN bridge (lan0 / br-lan). Hardcoding "lan0"
# silently broke gk2 (lan0 is DOWN there). Rather than guess the iface,
# DNAT :3483 from EVERY interface except the LXC bridge itself — safe
# because :3483 is never port-forwarded from the internet. An operator
# can still pin a single interface with SECUBOX_LAN_IFACE.
local match comment_iface
if [ -n "${SECUBOX_LAN_IFACE:-}" ]; then
match="iifname \"${SECUBOX_LAN_IFACE}\""
comment_iface="${SECUBOX_LAN_IFACE}"
else
match="iifname != \"${LXC_BRIDGE}\""
comment_iface="!${LXC_BRIDGE}"
fi
log "Ensuring slimproto DNAT (${comment_iface}):3483 → ${LXC_IP}:3483 ..."
local lan_iface="${SECUBOX_LAN_IFACE:-lan0}"
log "Ensuring slimproto DNAT ${lan_iface}:3483 → ${LXC_IP}:3483 ..."
install -d -m 0755 /etc/nftables.d
cat > "$nft_file" <<NFT
# /etc/nftables.d/secubox-lyrion-dnat.nft
# DNAT slimproto (TCP+UDP :3483) from the LAN to the Lyrion LXC.
# DNAT slimproto (TCP+UDP :3483) from LAN to the Lyrion LXC.
# Lets WiFi/LAN players (Squeezelite, iPeng, …) reach the LMS server
# without bridging the LXC network into the LAN. The forward chain runs
# policy accept on SecuBox, and conntrack rewrites the reply (the LXC's
# gateway is the host), so DNAT alone is sufficient — no forward rule.
# without bridging the LXC network into the LAN.
# Generated by secubox-lyrion install-lxc.sh (#248).
table inet secubox-lyrion {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
${match} tcp dport 3483 dnat ip to ${LXC_IP}:3483 comment "lyrion-slimproto-tcp"
${match} udp dport 3483 dnat ip to ${LXC_IP}:3483 comment "lyrion-slimproto-udp"
iifname "${lan_iface}" tcp dport 3483 dnat ip to ${LXC_IP}:3483 comment "lyrion-slimproto-tcp"
iifname "${lan_iface}" udp dport 3483 dnat ip to ${LXC_IP}:3483 comment "lyrion-slimproto-udp"
}
}
NFT

View File

@ -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,
}

View File

@ -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": {

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -7,7 +7,7 @@ Standards-Version: 4.6.2
Package: secubox-p2p
Architecture: all
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, avahi-daemon, avahi-utils, 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

View File

@ -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

View File

@ -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

View File

@ -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]))

View File

@ -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": []}) == []

View File

@ -737,7 +737,7 @@
document.getElementById('status-value').className = 'value ' + (status.online ? 'online' : 'offline');
document.getElementById('peer-count').textContent = status.total_peers || 0;
document.getElementById('active-peer-count').textContent = status.active_peers || 0;
document.getElementById('service-count').textContent = status.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) {

View File

@ -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

View File

@ -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) {