mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 22:54:31 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d05dcf615e | |||
| 877fb9e19a | |||
| 5e4c0d2dac | |||
| de15937ccf | |||
| e51a310010 | |||
| 6034dfb0c3 | |||
| 8f46bcb93b | |||
| 58f1f1a2c8 | |||
| 66301f4307 | |||
| a9f349a57d | |||
| a1ec2601c8 | |||
| a0f9c7811f | |||
| b8fce891de | |||
| 7effe5fb1a | |||
| b080612396 | |||
| 96c048860d | |||
| 7e75efffd2 | |||
| 910b87fd3a | |||
| a3ec30ed96 | |||
| 8ef46e086b | |||
| a949b2e495 | |||
| 6a662a165c | |||
| 29897b40bc | |||
| d70db5ea7e | |||
| 7206350c34 | |||
| 29ac8c311c | |||
| 74959276b6 | |||
| d61d585f91 | |||
| 3fa951017b | |||
| 9c7cd79e58 | |||
| 658ae8a368 |
|
|
@ -0,0 +1,869 @@
|
|||
# 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 1–5).
|
||||
- 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 1–5.
|
||||
- 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 1–7 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 1–10. ✓
|
||||
|
||||
**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.
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
# 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 2–4 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 2–4 (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.
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
# SecuBox App Store / Module Manager — Architecture Sketch
|
||||
|
||||
**Date:** 2026-06-29 · **Status:** brainstorm sketch (pre-spec) · **Author intent:** "appstore categorized + live prefs/components care; lyrion/peertube/nextcloud/webmail/kbin are modules of this toolbox system; implement shortly."
|
||||
|
||||
---
|
||||
|
||||
## 1. Core idea
|
||||
|
||||
A single web UI to **discover → install → enable/disable → configure → monitor** every SecuBox module, like an app store but fused with a live "components care" panel (prefs + health + restart/repair). It is a *front-end + lifecycle layer over manifests that already exist* — not a new packaging system.
|
||||
|
||||
**Foundation already present (don't reinvent):**
|
||||
- **128 modules** each ship `debian/secubox.yaml` = `{name, category, tier, description, depends, api:{socket,health}, ui:{path}}` — this IS the catalog entry.
|
||||
- Categories in use: `media, email, ai, iot, communication, publishing, network, security, system, vpn, dashboard, misc`. Tiers: `lite | standard | pro | all`.
|
||||
- Module anatomy: `api/main.py` (FastAPI control-plane), `nginx/*.conf`, `sbin/<mod>ctl` (29), `lib/<mod>/install-lxc.sh` (9 LXC-backed: lyrion, peertube, photoprism, jellyfin-ish, grafana, mqtt, yacy, rustdesk, zigbee), `conf/<mod>.toml.example`, `menu.d/NNN-<mod>.json` (129), `www/<mod>/` (150).
|
||||
- The **aggregator** mounts each module API at `/api/v1/<name>/`; the **hub** is the central dashboard; **menu.d** drives the menu.
|
||||
- apt repo `apt.secubox.in` is the package source; `dpkg` is installed-state truth.
|
||||
|
||||
## 2. State model (per module)
|
||||
|
||||
`available` (in apt, not installed) → `installed` → `enabled` (service on + menu/nginx wired) → `running` (healthy). Plus `update-available`, `error`, and `tier-locked` (board tier < module tier). Health = {service active?, LXC state (for LXC apps), socket up?, last /health, mem/disk}.
|
||||
|
||||
## 3. Backend — `secubox-appstore` module (FastAPI, on the aggregator, runs as `secubox`)
|
||||
|
||||
Read/aggregate endpoints (safe, no privilege):
|
||||
- `GET /catalog?category=&tier=&q=` — merged list: apt-available ∪ dpkg-installed, each with manifest + state + version + update flag.
|
||||
- `GET /module/{name}` — manifest + live state + health + prefs schema + screenshots.
|
||||
- `GET /module/{name}/health` — proxy module `/health` + LXC/service/socket/resources.
|
||||
- `GET /jobs/{id}` — async job status (install/uninstall progress).
|
||||
|
||||
Mutating endpoints (delegate to a root helper — see §4):
|
||||
- `POST /module/{name}/install` · `/uninstall`
|
||||
- `POST /module/{name}/enable` · `/disable`
|
||||
- `POST /module/{name}/restart`
|
||||
- `GET/PUT /module/{name}/prefs` — read/write the module TOML (schema-validated; sensitive configs go through the CSPN double-buffer/4R: shadow → validate → atomic swap → rollback).
|
||||
- `POST /module/{name}/expose` — toggle public exposure (HAProxy ACL + mitmproxy route, **never waf_bypass**) — later phase.
|
||||
|
||||
## 4. Privilege & async model (the hard part)
|
||||
|
||||
The FastAPI runs as `secubox` and **cannot** apt-install, run `install-lxc.sh`, `lxc-*`, `systemctl`, or edit `/etc/nginx`. Mirror the proven `sbx-mesh-up`/`<mod>ctl` pattern:
|
||||
- A **root helper `secubox-appstorectl`** does the privileged verbs (`install <name>` = apt-get install + run the module's `install-lxc.sh` if LXC-backed; `enable/disable` = systemctl + menu.d toggle + nginx reload; `uninstall` = apt purge [+ optional LXC destroy]).
|
||||
- FastAPI invokes it via a **narrow sudoers rule** (only `secubox-appstorectl <verb> <name>`), or hands jobs to a **privileged oneshot/queue** (a root `secubox-appstore-worker` consuming a job dir).
|
||||
- Installs take **minutes** (apt + debootstrap LXC) → **async job model**: enqueue → return job id → UI polls `/jobs/{id}` with a progress stream (stage: download → install → provision-lxc → enable → health-check).
|
||||
- Every action → append-only **audit log** (`/var/log/secubox/audit.log`), CSPN requirement.
|
||||
|
||||
## 5. Manifest schema extension (`secubox.yaml` v2 — additive, back-compatible)
|
||||
|
||||
Add optional appstore fields, defaulted so the 128 existing manifests still parse:
|
||||
```yaml
|
||||
appstore:
|
||||
icon: "lyrion.svg" # or emoji/glyph
|
||||
summary: "Squeezebox / LMS music server"
|
||||
long_description: | # markdown
|
||||
screenshots: ["1.png","2.png"]
|
||||
lxc: true # LXC-backed (drives install-lxc.sh step)
|
||||
exposure: optional # none | optional | required (HAProxy/mitmproxy)
|
||||
default_ports: [9000, 3483]
|
||||
resources: { ram_mb: 512, disk_mb: 2048 }
|
||||
conflicts: [] # mutually-exclusive modules
|
||||
homepage: "https://lyrion.org"
|
||||
```
|
||||
|
||||
## 6. Web UI — components
|
||||
|
||||
- **CatalogGrid** + **AppCard** (icon, name, summary, category/tier badge, state pill, primary action toggle).
|
||||
- **CategoryFilter** (chips from `category`), **TierFilter**, **SearchBox** (name/desc), **State filter** (installed/enabled/available/updates).
|
||||
- **AppDetailDrawer**: description + screenshots; **ActionBar** (Install/Enable/Disable/Restart/Uninstall, tier-locked → upsell); **PrefsForm** (schema-driven from `conf/*.toml.example`); **HealthPanel** (service/LXC/socket/resources, live); **LogViewer** (journalctl tail); **JobProgress** (install spinner with stages).
|
||||
- **Live Care view**: cross-module health board (what's down / degraded), bulk restart/repair, update-all.
|
||||
- Style: SecuBox C3BOX palette (cosmos-black/gold-hermetic/cyber-cyan, Cinzel/JetBrains Mono).
|
||||
|
||||
## 6b. Granular control + Profile system (requested 2026-06-29)
|
||||
|
||||
The store doesn't just install/remove whole modules — it **composes the appliance** at four granularity levels, and **Profiles** bundle a whole composition you can switch atomically.
|
||||
|
||||
**Granularity levels (each independently toggleable):**
|
||||
- **L0 — Module:** install / enable / disable the whole `secubox-<name>` (service + LXC).
|
||||
- **L1 — Component:** sub-features *within* a running module (e.g. lyrion: streaming on / plugins off; nextcloud: files on / calendar off; a security module: detection on / active-response off). Driven by a `components:` list the module declares in its manifest, mapped to the module's own config flags / sub-services.
|
||||
- **L2 — Navbar / menu:** show/hide and reorder individual `menu.d` entries — UI surface independent of whether the module runs (hide an app from the menu without disabling it, or vice-versa).
|
||||
- **L3 — Appearance:** theme / palette / layout / branding (the C3BOX themes), per-board or per-profile.
|
||||
|
||||
**Profiles** = a named, versioned bundle of `{enabled modules, per-module component flags, navbar layout+order, appearance/theme, exposure settings}`.
|
||||
- **Built-in presets:** *Minimal*, *Media Center* (lyrion/peertube/jellyfin/photoprism + lean navbar), *Security Ops* (waf/crowdsec/dpi/soc forward), *Home/Family* (nextcloud/mail/homeassistant), *Headless/Kiosk*. Presets can align with the board **tier**.
|
||||
- **Custom profiles:** create / clone / edit / export / import.
|
||||
- **Switch = one atomic transaction** via the CSPN double-buffer/4R (shadow → validate → swap → rollback) — flipping a profile reconfigures modules+navbar+appearance together, with one-click rollback if it breaks.
|
||||
- **Scope:** a per-board *active profile* (the appliance's persona) and optionally **per-user** profiles (master users `gk2`/`admin` see different navbars/apps) — ties into identity.
|
||||
- **Storage:** `/etc/secubox/profiles/<name>.toml` + an `active` pointer + 4R snapshots; API: `GET /profiles`, `GET/PUT /profiles/{name}`, `POST /profiles/{name}/apply` (atomic), `POST /profiles/{name}/rollback`, `GET /profiles/active`.
|
||||
|
||||
## 6c. P2P distribution + multi-service agents (requested 2026-06-29)
|
||||
|
||||
The app store rides the **gondwana mesh** — both its *package source* and its *running services* are federated across nodes, so the fleet is redundant and clients are served from the nearest node.
|
||||
|
||||
**P2P-mirrored apt repo + federated catalog:**
|
||||
- `apt.secubox.in` (today only on gk2) becomes **P2P-mirrored**: each mesh node holds a signed mirror, synced over the wg-mesh (`10.10.0.0/24`) with rsync/content-addressed deltas. A node/client installs from the **nearest reachable mirror** (local → mesh peer → upstream gk2/public), so installs are fast and **survive gk2 being offline** (access redundancy). Mirrors stay trustworthy via GPG (verify `InRelease`); never trust an unsigned peer mirror.
|
||||
- The **catalog is federated**: each node advertises which package versions it holds and which services it runs; the store shows a **mesh-wide view** ("install here" vs "already running on c3box"). This is exactly the gondwana **distributed directory** (peers/services/name records) — the app store is its first big consumer.
|
||||
|
||||
**Multi-service agents (serve all network clients):**
|
||||
- Each node runs its module services as **agents** that serve its LAN clients; the mesh makes any service reachable from any node (hub-routed today, direct later), so a client on c3box's LAN can use a service hosted on gk2 and vice-versa — **service mirroring + redundancy of access** (gondwana Phase 4).
|
||||
- A thin **agent/orchestration layer** gossips health + "who-runs-what", does **failover** (a dead service → route clients to a mesh replica), and advises **placement** (install a module on the best-suited node). Clients reach services by the per-node name `<service>.<boxname>.secubox.in`, routed over the mesh.
|
||||
- This makes the app store the *control plane* and the mesh agents the *data plane* of one distributed appliance, not 3 separate boxes.
|
||||
|
||||
## 7. Implementation plan (incremental, each shippable)
|
||||
|
||||
- **A — Catalog (read-only, safe):** manifest aggregator (parse 128 `secubox.yaml` + dpkg + apt state) → `GET /catalog` + `/module/{name}`. UI: CatalogGrid + filters + AppDetailDrawer (read-only). No privilege. *Delivers the store view immediately.*
|
||||
- **B — Health & prefs (read + careful write):** `/health` aggregation + `/prefs` GET; PUT prefs via the CSPN double-buffer path. UI: HealthPanel + PrefsForm + Live Care board.
|
||||
- **C — Lifecycle (privileged):** `secubox-appstorectl` root helper + sudoers + async job model; enable/disable first (low risk), then install/uninstall (LXC). UI: ActionBar + JobProgress + audit.
|
||||
- **D — Exposure & mesh (later):** per-app public exposure toggle (HAProxy+mitmproxy, no waf_bypass); gondwana mesh mirroring/redundancy (install once, run-anywhere) tying into the Phase-2/4 mesh work.
|
||||
- **E — Manifest v2 rollout:** add `appstore:` block to the headline apps first (lyrion, peertube, nextcloud, webmail/mail, kbin/gotosocial, jellyfin, photoprism), generators backfill the rest.
|
||||
|
||||
## 8. Open questions (to resolve in the spec / by GPT)
|
||||
|
||||
1. New `secubox-appstore` package vs. extend `secubox-hub`?
|
||||
2. Privilege bridge: narrow sudoers vs. a root job-queue worker? (CSPN favors the auditable queue.)
|
||||
3. Job/progress transport: poll `/jobs/{id}` vs. SSE/WebSocket.
|
||||
4. Prefs schema source: parse `*.toml.example` comments vs. a declared JSON-schema per module.
|
||||
5. Tier enforcement: hard block vs. show-and-upsell.
|
||||
6. Uninstall semantics for LXC apps: keep data volume vs. purge.
|
||||
|
||||
---
|
||||
|
||||
## 9. GPT architecture-design prompt (copy-paste)
|
||||
|
||||
> You are a senior systems architect. Design the **"SecuBox App Store / Module Manager"** for an existing Debian-based security-appliance platform. Output a concrete architecture: data model, REST API surface, UI component tree, the manifest schema, and the install/enable/prefs/health/async-job flows — with a privilege/security model. Be specific and implementable; prefer extending what exists over inventing new systems.
|
||||
>
|
||||
> **Platform context (ground truth — design WITH this, not around it):**
|
||||
> - SecuBox-DEB: Debian bookworm appliance. ~128 "modules", each a Debian package `secubox-<name>` from a signed apt repo (`apt.secubox.in`). `dpkg` = installed-state truth.
|
||||
> - Every module already ships a manifest `debian/secubox.yaml`: `{name, category, tier, description, depends, api:{socket,health}, ui:{path}}`. Categories: media, email, ai, iot, communication, publishing, network, security, system, vpn, dashboard, misc. Tiers: lite|standard|pro|all (the board has a tier; modules above it are locked).
|
||||
> - Module anatomy: a FastAPI control-plane `api/main.py` (mounted by a central **aggregator** at `/api/v1/<name>/`, served over a unix socket, running as unprivileged user `secubox`), an nginx vhost fragment, an optional `sbin/<name>ctl` CLI, an optional `lib/<name>/install-lxc.sh` that provisions the app inside an **LXC container** (9 apps today: lyrion, peertube, photoprism, jellyfin, grafana, mqtt, yacy, rustdesk, zigbee), a `conf/<name>.toml.example`, a hub menu entry `menu.d/NNN-<name>.json`, and a static dashboard `www/<name>/`. A central **hub** dashboard aggregates everything.
|
||||
> - Hard constraints: the API process is unprivileged (`secubox` user, NoNewPrivileges) — it CANNOT apt-install, run lxc/systemctl, or edit /etc/nginx; privileged actions must go through a separate root path. Security model is CSPN/ANSSI-oriented: append-only audit log, double-buffer/4R for sensitive config writes (shadow→validate→atomic-swap→rollback), AppArmor, no `waf_bypass` (all public exposure routes through an HAProxy→mitmproxy WAF chain). Installs take minutes (apt + LXC debootstrap) so lifecycle ops must be asynchronous with progress.
|
||||
>
|
||||
> **Design deliverables:**
|
||||
> 1. **Catalog/data model:** how to merge apt-available ∪ dpkg-installed into a categorized, tiered, searchable catalog; the per-module state machine (available→installed→enabled→running, plus update-available/error/tier-locked) and health model (service/LXC/socket/resources).
|
||||
> 2. **Manifest schema v2:** an additive `appstore:` block (icon, summary, long_description, screenshots, lxc:bool, exposure: none|optional|required, default_ports, resources, conflicts, homepage) that keeps the 128 existing manifests valid.
|
||||
> 3. **REST API:** read endpoints (`/catalog`, `/module/{name}`, `/health`, `/jobs/{id}`) and mutating endpoints (install, uninstall, enable, disable, restart, prefs GET/PUT, expose) — request/response shapes.
|
||||
> 4. **Privilege bridge + async jobs:** compare (a) a narrow sudoers rule to a root `secubox-appstorectl <verb> <name>` vs (b) a root job-queue worker consuming a spool dir; pick one for CSPN auditability; define the job/progress model and the install pipeline stages (download→install→provision-lxc→enable→health-check).
|
||||
> 5. **Web UI component tree:** CatalogGrid/AppCard, Category/Tier/Search/State filters, AppDetailDrawer (description, screenshots, ActionBar, schema-driven PrefsForm, HealthPanel, LogViewer, JobProgress), and a cross-module "Live Care" health board with bulk restart/repair/update-all.
|
||||
> 6. **Prefs editing:** how to render a schema-driven form from `conf/<name>.toml.example` (or a declared JSON-schema), validate, and write via the double-buffer/4R path.
|
||||
> 7. **Decisions:** new `secubox-appstore` package vs extend the hub; poll vs SSE for job progress; tier hard-block vs upsell; LXC uninstall data-retention.
|
||||
>
|
||||
> Produce: an architecture diagram (ASCII), the manifest v2 schema, the full API table, the privilege/async design, the UI component tree, and a 4–5 step incremental build plan where each step ships something usable. Headline apps to use as worked examples: lyrion (LXC music), peertube (LXC video), nextcloud (cloud), webmail/mail, kbin/gotosocial (fediverse).
|
||||
|
||||
---
|
||||
|
||||
## 10. GPT prompt — COMPLETE (architectural research notes) · copy-paste
|
||||
|
||||
> **Role.** You are a distributed-systems architect. Write **architectural research notes / a design sketch** (not production code) for the **"SecuBox App Store + Module Composer"** — a web UI and control plane to discover, install, enable/disable (at multiple granularities), configure, profile, monitor, and **federate over a P2P mesh** every module of a Debian security appliance. Prefer extending what already exists over inventing new systems. Be concrete, opinionated, and implementable; call out trade-offs and pick.
|
||||
>
|
||||
> **Platform ground truth (design WITH this):**
|
||||
> - SecuBox-DEB: Debian bookworm appliance. ~128 "modules"; each is a Debian package `secubox-<name>` from a signed apt repo (`apt.secubox.in`); `dpkg` = installed truth.
|
||||
> - Each module already ships a manifest `debian/secubox.yaml` = `{name, category, tier, description, depends, api:{socket,health}, ui:{path}}`. Categories: media, email, ai, iot, communication, publishing, network, security, system, vpn, dashboard, misc. Tiers: lite|standard|pro|all (the board has a tier; higher-tier modules are locked).
|
||||
> - Module anatomy: a FastAPI control-plane `api/main.py` (mounted by a central **aggregator** at `/api/v1/<name>/` over a unix socket, running as **unprivileged user `secubox`, NoNewPrivileges**), an nginx vhost fragment, an optional `sbin/<name>ctl` CLI, an optional `lib/<name>/install-lxc.sh` that provisions the app in an **LXC** (9 LXC apps today: lyrion, peertube, photoprism, jellyfin, grafana, mqtt, yacy, rustdesk, zigbee), a `conf/<name>.toml.example`, a hub menu entry `menu.d/NNN-<name>.json`, and a static dashboard `www/<name>/`. A central **hub** aggregates everything.
|
||||
> - **Hard constraints:** the API process is unprivileged — it CANNOT apt-install, run lxc/systemctl, or edit /etc/nginx; privileged actions go through a separate root path. CSPN/ANSSI security: append-only audit log; **double-buffer/4R** for sensitive config writes (shadow→validate→atomic-swap→rollback); AppArmor; **no `waf_bypass`** (all public exposure routes through an HAProxy→mitmproxy WAF chain). Installs take minutes (apt + LXC debootstrap) → lifecycle ops must be **asynchronous with progress**.
|
||||
> - **Mesh ("gondwana"):** the appliance is one of several nodes (today: gk2=master/`10.10.0.1`, c3box=`.2`, amd64=`.3`) on a WireGuard mesh `10.10.0.0/24:51822` owned by module `secubox-p2p`. The mesh has node identity (wg pubkey + node-id + DDNS name `<boxname>.secubox.in`) and a planned **distributed directory** (replicated peers/services/name records, DNS-like) plus per-node service naming `<service>.<boxname>.secubox.in` routed via the hub.
|
||||
>
|
||||
> **Design the following (produce research notes for each):**
|
||||
> 1. **Catalog & state.** Merge apt-available ∪ dpkg-installed into a categorized, tiered, searchable catalog. Per-module state machine: available→installed→enabled→running, plus update-available/error/tier-locked. Health model: service active / LXC state / socket up / last `/health` / mem-disk.
|
||||
> 2. **Manifest schema v2 (additive, keeps 128 manifests valid):** an `appstore:` block (icon, summary, long_description, screenshots, `lxc:bool`, `exposure: none|optional|required`, default_ports, resources, conflicts, homepage) **and a `components:` list** declaring toggleable sub-features (mapped to the module's own config flags / sub-services).
|
||||
> 3. **Granular control — four levels, each independently toggleable:** L0 module (install/enable/disable whole package+LXC); L1 component (sub-features within a running module); L2 navbar/menu (show/hide/reorder `menu.d` entries, independent of whether the module runs); L3 appearance (theme/palette/layout/branding). Define the data model + API for each.
|
||||
> 4. **Profiles.** A named, versioned bundle of `{enabled modules, per-module component flags, navbar layout+order, appearance, exposure}`. Built-in presets (Minimal, Media Center, Security Ops, Home/Family, Headless/Kiosk) that can align to tier; custom create/clone/edit/export/import. **Apply = one atomic transaction via double-buffer/4R** with one-click rollback. Scope: a per-board active profile AND optional per-user profiles (master users see different navbars/apps — ties to identity). Storage + API (`/profiles`, `apply`, `rollback`, `active`).
|
||||
> 5. **Lifecycle API + privilege bridge + async jobs.** REST read endpoints (`/catalog`, `/module/{name}`, `/health`, `/jobs/{id}`) and mutating ones (install, uninstall, enable, disable, restart, prefs GET/PUT, expose). The unprivileged API must reach root work: compare (a) a narrow sudoers rule to a root `secubox-appstorectl <verb> <name>` vs (b) a root **job-queue worker** consuming a spool dir; pick one for CSPN auditability. Define the job/progress model and install pipeline stages (download→install→provision-lxc→enable→health-check).
|
||||
> 6. **Prefs editing.** Render a schema-driven form from `conf/<name>.toml.example` (or a declared JSON-schema); validate; write via the 4R double-buffer path.
|
||||
> 7. **P2P distribution (rides the mesh).** Make `apt.secubox.in` **P2P-mirrored**: each node holds a GPG-signed mirror synced over the wg-mesh (rsync/content-addressed deltas); install source preference local→mesh-peer→upstream; **survives the master being offline**; never trust an unsigned peer mirror (verify `InRelease`). Make the **catalog federated**: each node advertises held package versions + running services (this is the distributed directory's first consumer); the UI shows a mesh-wide view ("install here" vs "running on c3box").
|
||||
> 8. **Multi-service agents (serve all network clients).** Each node runs its modules as agents serving its LAN clients; the mesh makes any service reachable from any node so clients are served from the nearest node (service mirroring + access redundancy). Design a thin agent/orchestration layer: health/who-runs-what gossip, failover (dead service → route to a mesh replica), placement advice (install on the best-suited node), client access via `<service>.<boxname>.secubox.in`. App store = control plane; mesh agents = data plane of one distributed appliance.
|
||||
> 9. **Web UI component tree.** CatalogGrid/AppCard; Category/Tier/Search/State filters; AppDetailDrawer (description, screenshots, ActionBar, schema-driven PrefsForm, component toggles, HealthPanel, LogViewer, JobProgress); a Profiles manager; a cross-module **Live Care** board (what's down/degraded, bulk restart/repair/update-all, mesh-wide). Palette: C3BOX (cosmos-black/gold-hermetic/cyber-cyan; Cinzel + JetBrains Mono).
|
||||
> 10. **Key decisions to resolve:** new `secubox-appstore` package vs extend the hub; job progress transport (poll vs SSE/WebSocket); tier hard-block vs upsell; LXC uninstall data-retention; mesh-sync protocol for the repo and the directory.
|
||||
>
|
||||
> **Output format (research notes):** an ASCII architecture diagram (UI ↔ appstore API ↔ root worker ↔ apt/lxc/systemd, plus the mesh: mirror sync + federated catalog + agents); the manifest v2 + `components:` schema; the full REST API table; the profile/4R model; the P2P mirror + federated-catalog + agent protocol; the privilege/async-job design; the UI component tree; and a phased, each-step-shippable build plan. Use these as worked examples throughout: **lyrion** (LXC music), **peertube** (LXC video), **nextcloud** (cloud), **webmail/mail**, **kbin/gotosocial** (fediverse).
|
||||
0
packages/secubox-appstore/api/__init__.py
Normal file
0
packages/secubox-appstore/api/__init__.py
Normal file
238
packages/secubox-appstore/api/main.py
Normal file
238
packages/secubox-appstore/api/main.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
"""
|
||||
SecuBox-Deb :: secubox-appstore :: catalog API (Phase A — read-only)
|
||||
|
||||
Serves a categorized, tiered, searchable catalog of SecuBox modules by
|
||||
merging the baked manifest catalog (generated at build from every module's
|
||||
debian/secubox.yaml) with live runtime state (dpkg installed/version +
|
||||
systemctl active). Runs unprivileged (user `secubox`): all state queries are
|
||||
read-only. Install/enable/prefs/profiles are later phases (a root worker).
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
CATALOG_FILE = Path(os.environ.get(
|
||||
"APPSTORE_CATALOG", "/usr/share/secubox/appstore/catalog.json"))
|
||||
TIER_RANK = {"all": 0, "lite": 1, "standard": 2, "pro": 3}
|
||||
_STATE_TTL = 30.0
|
||||
_state_cache = {"ts": 0.0, "data": {}}
|
||||
|
||||
app = FastAPI(title="secubox-appstore", version="0.1.0",
|
||||
root_path="/api/v1/appstore")
|
||||
|
||||
|
||||
def board_tier() -> str:
|
||||
"""Best-effort board tier; defaults to 'pro' (unlock all) when unset."""
|
||||
t = os.environ.get("SECUBOX_TIER")
|
||||
if t:
|
||||
return t.strip()
|
||||
try:
|
||||
for line in open("/etc/secubox/secubox.conf", encoding="utf-8"):
|
||||
s = line.strip()
|
||||
if s.lower().startswith("tier") and "=" in s:
|
||||
return s.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
except Exception:
|
||||
pass
|
||||
return "pro"
|
||||
|
||||
|
||||
def load_catalog() -> list:
|
||||
try:
|
||||
return json.loads(CATALOG_FILE.read_text(encoding="utf-8")).get("modules", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _dpkg_state() -> dict:
|
||||
out = {}
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["dpkg-query", "-W", "-f=${Package}\t${db:Status-Abbrev}\t${Version}\n", "secubox-*"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
for line in r.stdout.splitlines():
|
||||
p = line.split("\t")
|
||||
if len(p) >= 3:
|
||||
out[p[0]] = {"installed": p[1].strip().startswith("ii"), "version": p[2].strip()}
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _svc_active(names: list) -> dict:
|
||||
out = {}
|
||||
if not names:
|
||||
return out
|
||||
try:
|
||||
units = [f"{n}.service" for n in names]
|
||||
r = subprocess.run(["systemctl", "is-active", *units],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
for n, st in zip(names, r.stdout.splitlines()):
|
||||
out[n] = (st.strip() == "active")
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def compute_state(force: bool = False) -> dict:
|
||||
now = time.time()
|
||||
if not force and _state_cache["data"] and (now - _state_cache["ts"] < _STATE_TTL):
|
||||
return _state_cache["data"]
|
||||
catalog = load_catalog()
|
||||
dpkg = _dpkg_state()
|
||||
installed_names = [m["name"] for m in catalog if dpkg.get(m["name"], {}).get("installed")]
|
||||
active = _svc_active(installed_names)
|
||||
brank = TIER_RANK.get(board_tier(), 2)
|
||||
result = {}
|
||||
for m in catalog:
|
||||
name = m["name"]
|
||||
d = dpkg.get(name, {})
|
||||
installed = bool(d.get("installed"))
|
||||
running = bool(active.get(name))
|
||||
tier = m.get("tier", "lite")
|
||||
tier_locked = (tier != "all") and (TIER_RANK.get(tier, 1) > brank)
|
||||
if not installed:
|
||||
state = "tier-locked" if tier_locked else "available"
|
||||
elif running:
|
||||
state = "running"
|
||||
else:
|
||||
state = "installed"
|
||||
result[name] = {
|
||||
**m,
|
||||
"installed": installed,
|
||||
"running": running,
|
||||
"version": d.get("version"),
|
||||
"tier_locked": tier_locked,
|
||||
"state": state,
|
||||
}
|
||||
_state_cache["ts"] = now
|
||||
_state_cache["data"] = result
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"ok": True, "module": "appstore",
|
||||
"catalog_count": len(load_catalog()), "board_tier": board_tier()}
|
||||
|
||||
|
||||
@app.get("/categories")
|
||||
async def categories():
|
||||
st = compute_state()
|
||||
cats: dict = {}
|
||||
for m in st.values():
|
||||
cats[m["category"]] = cats.get(m["category"], 0) + 1
|
||||
return {
|
||||
"categories": [{"name": k, "count": v} for k, v in sorted(cats.items())],
|
||||
"tiers": ["lite", "standard", "pro", "all"],
|
||||
"states": ["available", "installed", "running", "tier-locked"],
|
||||
"board_tier": board_tier(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/catalog")
|
||||
async def catalog(category: Optional[str] = None, tier: Optional[str] = None,
|
||||
state: Optional[str] = None, q: Optional[str] = None):
|
||||
st = compute_state()
|
||||
items = list(st.values())
|
||||
if category:
|
||||
items = [m for m in items if m["category"] == category]
|
||||
if tier:
|
||||
items = [m for m in items if m["tier"] == tier]
|
||||
if state:
|
||||
items = [m for m in items if m["state"] == state]
|
||||
if q:
|
||||
ql = q.lower()
|
||||
items = [m for m in items
|
||||
if ql in m["name"].lower() or ql in (m.get("description") or "").lower()]
|
||||
items.sort(key=lambda m: (m["category"], m["name"]))
|
||||
return {"modules": items, "count": len(items), "total": len(st), "board_tier": board_tier()}
|
||||
|
||||
|
||||
@app.get("/module/{name}")
|
||||
async def module(name: str):
|
||||
st = compute_state()
|
||||
if name not in st:
|
||||
alt = f"secubox-{name}"
|
||||
if alt in st:
|
||||
name = alt
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"unknown module {name!r}")
|
||||
return dict(st[name])
|
||||
|
||||
|
||||
# ── Lifecycle + config (Phase B/C) ──────────────────────────────────────────
|
||||
# The API runs as the unprivileged `secubox` user; all privileged work goes
|
||||
# through the validated root helper /usr/sbin/secubox-appstorectl via a narrow
|
||||
# sudoers rule (start/stop/restart/enable/disable + write-config only).
|
||||
ACTIONS = {"start", "stop", "restart", "enable", "disable"}
|
||||
APPSTORECTL = "/usr/sbin/secubox-appstorectl"
|
||||
|
||||
|
||||
class ConfigIn(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
def _resolve(name: str, st: dict) -> str:
|
||||
if name in st:
|
||||
return name
|
||||
alt = f"secubox-{name}"
|
||||
if alt in st:
|
||||
return alt
|
||||
raise HTTPException(status_code=404, detail=f"unknown module {name!r}")
|
||||
|
||||
|
||||
def _appstorectl(args, input_text=None):
|
||||
try:
|
||||
r = subprocess.run(["sudo", "-n", APPSTORECTL, *args], input=input_text,
|
||||
capture_output=True, text=True, timeout=90)
|
||||
return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return 1, "", str(e)
|
||||
|
||||
|
||||
def _config_path(name: str) -> Path:
|
||||
short = name[len("secubox-"):] if name.startswith("secubox-") else name
|
||||
return Path(f"/etc/secubox/{short}.toml")
|
||||
|
||||
|
||||
@app.post("/module/{name}/action/{verb}")
|
||||
async def module_action(name: str, verb: str):
|
||||
name = _resolve(name, compute_state())
|
||||
if verb not in ACTIONS:
|
||||
raise HTTPException(status_code=400, detail=f"unknown action {verb!r}")
|
||||
rc, out, err = _appstorectl([verb, name])
|
||||
if rc != 0:
|
||||
raise HTTPException(status_code=500, detail=f"{verb} failed: {err or out}")
|
||||
new = compute_state(force=True).get(name, {})
|
||||
return {"status": "ok", "action": verb, "module": name,
|
||||
"state": new.get("state"), "running": new.get("running"), "message": out}
|
||||
|
||||
|
||||
@app.get("/module/{name}/config")
|
||||
async def get_config(name: str):
|
||||
name = _resolve(name, compute_state())
|
||||
p = _config_path(name)
|
||||
try:
|
||||
return {"module": name, "path": str(p), "exists": True,
|
||||
"readable": True, "content": p.read_text(encoding="utf-8")}
|
||||
except FileNotFoundError:
|
||||
return {"module": name, "path": str(p), "exists": False, "readable": True, "content": ""}
|
||||
except PermissionError:
|
||||
return {"module": name, "path": str(p), "exists": True, "readable": False, "content": ""}
|
||||
|
||||
|
||||
@app.put("/module/{name}/config")
|
||||
async def put_config(name: str, body: ConfigIn):
|
||||
name = _resolve(name, compute_state())
|
||||
rc, out, err = _appstorectl(["write-config", name], input_text=body.content)
|
||||
if rc != 0:
|
||||
raise HTTPException(status_code=400, detail=f"config write failed: {err or out}")
|
||||
return {"status": "ok", "module": name, "message": out}
|
||||
50
packages/secubox-appstore/debian/changelog
Normal file
50
packages/secubox-appstore/debian/changelog
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
secubox-appstore (0.2.2-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(menu): install the navbar entry to /usr/share/secubox/menu.d (the
|
||||
dir the hub reads) instead of /etc/secubox/menu.d, and place it in the
|
||||
'root' navbar section (order 10) so the App Store is prominent.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 20:00:00 +0200
|
||||
|
||||
secubox-appstore (0.2.1-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix: service needs NoNewPrivileges=no so the API can sudo the root
|
||||
helper (the narrow sudoers rule + helper validation are the boundary);
|
||||
postinst now try-restarts the service on upgrade so new code loads.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 19:30:00 +0200
|
||||
|
||||
secubox-appstore (0.2.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Phase B/C + navbar integration.
|
||||
- UI: integrated shared sidebar (nav#sidebar + sidebar.js + crt-engine),
|
||||
per-service quick actions (start/stop/restart/enable/disable) and a
|
||||
config editor drawer (view/edit the module TOML, shows dependencies).
|
||||
- api: POST /module/{name}/action/{verb}; GET/PUT /module/{name}/config.
|
||||
- sbin/secubox-appstorectl: validated root helper (secubox-* units +
|
||||
/etc/secubox/<name>.toml only; TOML-validates writes). Narrow sudoers
|
||||
rule lets the unprivileged API invoke it.
|
||||
- postinst rebuilds the hub menu cache so the App Store shows in the navbar.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 19:00:00 +0200
|
||||
|
||||
secubox-appstore (0.1.1-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(nginx): serve the catalog API from secubox-routes.d/ (the
|
||||
authoritative /api/v1/ include) instead of secubox.d/, so it wins over
|
||||
the generic /api/ -> aggregator catch-all. UI stays in secubox.d/.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 18:00:00 +0200
|
||||
|
||||
secubox-appstore (0.1.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Initial release — App Store Phase A (read-only catalog).
|
||||
- api/main.py: GET /catalog (category/tier/state/q filters), /module/{name},
|
||||
/categories, /health; merges the baked manifest catalog with live dpkg +
|
||||
systemctl state (available / installed / running / tier-locked).
|
||||
- catalog.json generated at build from every module's debian/secubox.yaml.
|
||||
- www/appstore: categorized, tiered, searchable grid UI (read-only).
|
||||
- Served by secubox-appstore.service on /run/secubox/appstore.sock; nginx
|
||||
routes /api/v1/appstore/ + static /appstore/. No aggregator dependency.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 17:30:00 +0200
|
||||
15
packages/secubox-appstore/debian/control
Normal file
15
packages/secubox-appstore/debian/control
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
Source: secubox-appstore
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Maintainer: Gerald KERMA <devel@cybermind.fr>
|
||||
Build-Depends: debhelper-compat (= 13), python3
|
||||
Standards-Version: 4.6.2
|
||||
|
||||
Package: secubox-appstore
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, sudo
|
||||
Description: SecuBox App Store — module catalog & lifecycle (Phase A)
|
||||
Categorized, tiered, searchable catalog of SecuBox modules with live
|
||||
install/run state, served as a hub web UI. Phase A is read-only; install,
|
||||
enable/disable, preferences and profiles arrive in later phases via a
|
||||
privileged worker.
|
||||
19
packages/secubox-appstore/debian/postinst
Executable file
19
packages/secubox-appstore/debian/postinst
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
case "$1" in
|
||||
configure)
|
||||
install -d -m 0755 /usr/share/secubox/appstore /var/log/secubox /var/lib/secubox 2>/dev/null || true
|
||||
# surface the App Store in the hub navbar (menu cache rebuild)
|
||||
rm -f /var/cache/secubox/menu.json 2>/dev/null || true
|
||||
# validate the sudoers drop-in (never leave a broken one)
|
||||
visudo -cf /etc/sudoers.d/secubox-appstore >/dev/null 2>&1 || rm -f /etc/sudoers.d/secubox-appstore
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl enable --now secubox-appstore.service 2>/dev/null || true
|
||||
systemctl try-restart secubox-appstore.service 2>/dev/null || true
|
||||
if systemctl is-active --quiet nginx 2>/dev/null; then
|
||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
9
packages/secubox-appstore/debian/prerm
Executable file
9
packages/secubox-appstore/debian/prerm
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
systemctl stop secubox-appstore.service 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
43
packages/secubox-appstore/debian/rules
Executable file
43
packages/secubox-appstore/debian/rules
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/make -f
|
||||
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_install:
|
||||
# API module (uvicorn api.main:app)
|
||||
install -d $(CURDIR)/debian/secubox-appstore/usr/lib/secubox/appstore/api
|
||||
cp -r $(CURDIR)/api/* $(CURDIR)/debian/secubox-appstore/usr/lib/secubox/appstore/api/
|
||||
|
||||
# Web UI
|
||||
install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/www/appstore
|
||||
cp -r $(CURDIR)/www/appstore/* $(CURDIR)/debian/secubox-appstore/usr/share/secubox/www/appstore/
|
||||
|
||||
# nginx route + static
|
||||
install -d $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox.d
|
||||
install -m 644 $(CURDIR)/nginx/appstore.conf $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox.d/
|
||||
|
||||
# Hub menu entry
|
||||
install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/menu.d
|
||||
install -m 644 $(CURDIR)/menu.d/580-appstore.json $(CURDIR)/debian/secubox-appstore/usr/share/secubox/menu.d/
|
||||
|
||||
# systemd service
|
||||
install -d $(CURDIR)/debian/secubox-appstore/usr/lib/systemd/system
|
||||
install -m 644 $(CURDIR)/debian/secubox-appstore.service $(CURDIR)/debian/secubox-appstore/usr/lib/systemd/system/
|
||||
|
||||
# Authoritative API route (secubox-routes.d)
|
||||
install -d $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox-routes.d
|
||||
install -m 644 $(CURDIR)/nginx/appstore-routes.conf $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox-routes.d/
|
||||
|
||||
# Privileged controller + narrow sudoers rule
|
||||
install -d $(CURDIR)/debian/secubox-appstore/usr/sbin
|
||||
install -m 755 $(CURDIR)/sbin/secubox-appstorectl $(CURDIR)/debian/secubox-appstore/usr/sbin/
|
||||
install -d $(CURDIR)/debian/secubox-appstore/etc/sudoers.d
|
||||
install -m 440 $(CURDIR)/debian/secubox-appstore.sudoers $(CURDIR)/debian/secubox-appstore/etc/sudoers.d/secubox-appstore
|
||||
|
||||
# Generate the catalog index from every sibling module's debian/secubox.yaml
|
||||
install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/appstore
|
||||
python3 $(CURDIR)/scripts/gen-appstore-catalog.py \
|
||||
$(CURDIR)/debian/secubox-appstore/usr/share/secubox/appstore/catalog.json
|
||||
|
||||
# Runtime dir
|
||||
install -d $(CURDIR)/debian/secubox-appstore/run/secubox
|
||||
27
packages/secubox-appstore/debian/secubox-appstore.service
Normal file
27
packages/secubox-appstore/debian/secubox-appstore.service
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[Unit]
|
||||
Description=SecuBox App Store — module catalog API
|
||||
After=network.target secubox-core.service
|
||||
Requires=secubox-core.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=secubox
|
||||
Group=secubox
|
||||
RuntimeDirectory=secubox
|
||||
RuntimeDirectoryPreserve=yes
|
||||
ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/appstore.sock --workers 1
|
||||
WorkingDirectory=/usr/lib/secubox/appstore
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# sudo->secubox-appstorectl bridge requires new privileges; the narrow
|
||||
# sudoers rule + helper validation are the security boundary.
|
||||
NoNewPrivileges=no
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# secubox-appstore: allow the unprivileged API user to drive module lifecycle
|
||||
# and config writes ONLY through the validated root helper.
|
||||
secubox ALL=(root) NOPASSWD: /usr/sbin/secubox-appstorectl
|
||||
15
packages/secubox-appstore/debian/secubox.yaml
Normal file
15
packages/secubox-appstore/debian/secubox.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# debian/secubox.yaml
|
||||
name: secubox-appstore
|
||||
category: system
|
||||
tier: all
|
||||
description: "SecuBox App Store — module catalog, install/enable, profiles"
|
||||
|
||||
depends:
|
||||
- secubox-core
|
||||
|
||||
api:
|
||||
socket: /run/secubox/appstore.sock
|
||||
health: /api/v1/appstore/health
|
||||
|
||||
ui:
|
||||
path: /appstore/
|
||||
10
packages/secubox-appstore/menu.d/580-appstore.json
Normal file
10
packages/secubox-appstore/menu.d/580-appstore.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "appstore",
|
||||
"name": "App Store",
|
||||
"icon": "🛍️",
|
||||
"path": "/appstore/",
|
||||
"category": "root",
|
||||
"order": 10,
|
||||
"description": "Install, enable & configure SecuBox modules",
|
||||
"requires": ["secubox-appstore"]
|
||||
}
|
||||
9
packages/secubox-appstore/nginx/appstore-routes.conf
Normal file
9
packages/secubox-appstore/nginx/appstore-routes.conf
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# /etc/nginx/secubox-routes.d/appstore.conf — Installed by secubox-appstore.
|
||||
# App Store catalog API, served by the standalone secubox-appstore.service
|
||||
# over /run/secubox/appstore.sock (no aggregator dependency).
|
||||
location /api/v1/appstore/ {
|
||||
rewrite ^/api/v1/appstore/(.*)$ /$1 break;
|
||||
proxy_pass http://unix:/run/secubox/appstore.sock;
|
||||
include /etc/nginx/snippets/secubox-proxy.conf;
|
||||
proxy_intercept_errors on;
|
||||
}
|
||||
9
packages/secubox-appstore/nginx/appstore.conf
Normal file
9
packages/secubox-appstore/nginx/appstore.conf
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# /etc/nginx/secubox.d/appstore.conf — Installed by secubox-appstore.
|
||||
# Static App Store UI. The /api/v1/appstore/ route lives in
|
||||
# secubox-routes.d/appstore.conf (the authoritative API include).
|
||||
|
||||
location /appstore/ {
|
||||
alias /usr/share/secubox/www/appstore/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /appstore/index.html;
|
||||
}
|
||||
41
packages/secubox-appstore/sbin/secubox-appstorectl
Executable file
41
packages/secubox-appstore/sbin/secubox-appstorectl
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# SecuBox-Deb :: secubox-appstore :: privileged module controller.
|
||||
# Invoked by the unprivileged secubox-appstore API via a narrow sudoers
|
||||
# rule. ONLY operates on secubox-*.service units and /etc/secubox/<name>.toml.
|
||||
set -euo pipefail
|
||||
|
||||
action="${1:-}"
|
||||
module="${2:-}"
|
||||
|
||||
# Strict allow-list: module name must be secubox-<slug>, nothing else.
|
||||
case "$module" in
|
||||
secubox-[a-z0-9]*) : ;;
|
||||
*) echo "appstorectl: invalid module name" >&2; exit 2 ;;
|
||||
esac
|
||||
unit="${module}.service"
|
||||
short="${module#secubox-}"
|
||||
|
||||
case "$action" in
|
||||
start|stop|restart|enable|disable)
|
||||
exec systemctl "$action" "$unit"
|
||||
;;
|
||||
write-config)
|
||||
dest="/etc/secubox/${short}.toml"
|
||||
tmp="$(mktemp /etc/secubox/.${short}.toml.XXXXXX)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
cat > "$tmp"
|
||||
# Reject anything that is not valid TOML.
|
||||
python3 -c 'import tomllib,sys; tomllib.load(open(sys.argv[1],"rb"))' "$tmp" \
|
||||
|| { echo "appstorectl: invalid TOML, refusing to write" >&2; exit 3; }
|
||||
# Preserve the conventional ownership/perms for SecuBox config.
|
||||
chown root:secubox "$tmp"
|
||||
chmod 0640 "$tmp"
|
||||
mv -f "$tmp" "$dest"
|
||||
trap - EXIT
|
||||
echo "wrote $dest"
|
||||
;;
|
||||
*)
|
||||
echo "appstorectl: unknown action '$action'" >&2; exit 2 ;;
|
||||
esac
|
||||
63
packages/secubox-appstore/scripts/gen-appstore-catalog.py
Executable file
63
packages/secubox-appstore/scripts/gen-appstore-catalog.py
Executable file
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
"""SecuBox-Deb :: secubox-appstore :: catalog generator.
|
||||
|
||||
Scan every module's debian/secubox.yaml in the monorepo and emit a flat
|
||||
catalog.json (the App Store's "available" set). Run at package build time
|
||||
from packages/secubox-appstore (siblings live at ../*/debian/secubox.yaml).
|
||||
Dependency-free: parses the small flat manifests without PyYAML.
|
||||
"""
|
||||
import json, sys, glob, os, re
|
||||
|
||||
def parse_manifest(path):
|
||||
m = {"depends": []}
|
||||
in_depends = False
|
||||
for raw in open(path, encoding="utf-8", errors="replace"):
|
||||
line = raw.rstrip("\n")
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
# top-level key: value (no leading space)
|
||||
mt = re.match(r"^([a-z_]+):\s*(.*)$", line)
|
||||
if mt:
|
||||
key, val = mt.group(1), mt.group(2).strip()
|
||||
in_depends = (key == "depends")
|
||||
if key in ("name", "category", "tier", "description") and val:
|
||||
m[key] = val.strip().strip('"').strip("'")
|
||||
continue
|
||||
# list item under depends:
|
||||
li = re.match(r"^\s+-\s+(.*)$", line)
|
||||
if li and in_depends:
|
||||
m["depends"].append(li.group(1).strip().strip('"').strip("'"))
|
||||
return m
|
||||
|
||||
def main(out):
|
||||
base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
catalog = []
|
||||
for path in sorted(glob.glob(os.path.join(base, "*", "debian", "secubox.yaml"))):
|
||||
# skip generated build-tree copies (debian/<pkg>/...)
|
||||
if "/debian/" in path and path.count("/debian/") > 1:
|
||||
continue
|
||||
m = parse_manifest(path)
|
||||
if not m.get("name"):
|
||||
continue
|
||||
catalog.append({
|
||||
"name": m["name"],
|
||||
"category": m.get("category", "misc"),
|
||||
"tier": m.get("tier", "lite"),
|
||||
"description": m.get("description", ""),
|
||||
"depends": m.get("depends", []),
|
||||
})
|
||||
# de-dup by name (keep first)
|
||||
seen, uniq = set(), []
|
||||
for c in catalog:
|
||||
if c["name"] in seen:
|
||||
continue
|
||||
seen.add(c["name"]); uniq.append(c)
|
||||
data = {"version": 1, "modules": uniq, "count": len(uniq)}
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
print(f"wrote {out}: {len(uniq)} modules")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1] if len(sys.argv) > 1 else "catalog.json")
|
||||
180
packages/secubox-appstore/www/appstore/index.html
Normal file
180
packages/secubox-appstore/www/appstore/index.html
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SecuBox · App Store</title>
|
||||
<link rel="stylesheet" href="/shared/design-tokens.css">
|
||||
<link rel="stylesheet" href="/shared/crt-light.css">
|
||||
<style>
|
||||
.as-bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:14px 0}
|
||||
.as-bar input,.as-bar select{background:var(--panel,#13131c);border:1px solid var(--line,#2a2a3a);
|
||||
color:var(--text,#e8e6d9);padding:8px 10px;border-radius:6px;font-family:inherit;font-size:13px}
|
||||
.as-bar input{min-width:220px}
|
||||
.as-stat{color:var(--text-muted,#6b6b7a);font-size:12px;margin-left:auto}
|
||||
.as-stat b{color:var(--cyber-cyan,#00d4ff)}
|
||||
.as-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:14px}
|
||||
.as-card{background:var(--panel,#13131c);border:1px solid var(--line,#2a2a3a);border-radius:10px;
|
||||
padding:14px;display:flex;flex-direction:column;gap:8px}
|
||||
.as-card:hover{border-color:var(--gold-hermetic,#c9a84c)}
|
||||
.as-top{display:flex;align-items:center;gap:10px}
|
||||
.as-ico{font-size:24px}
|
||||
.as-name{font-weight:700;font-size:14px;word-break:break-word}
|
||||
.as-desc{color:var(--text-muted,#6b6b7a);font-size:12px;min-height:30px;line-height:1.4}
|
||||
.as-badges{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.pill{font-size:10px;padding:2px 8px;border-radius:20px;border:1px solid var(--line,#2a2a3a);text-transform:uppercase;letter-spacing:.4px}
|
||||
.pill.cat{color:var(--cyber-cyan,#00d4ff)} .pill.tier{color:var(--gold-hermetic,#c9a84c)}
|
||||
.pill.s-running{color:var(--matrix-green,#00ff41);border-color:#1d5a2a}
|
||||
.pill.s-installed{color:var(--cyber-cyan,#00d4ff)}
|
||||
.pill.s-available{color:var(--text-muted,#6b6b7a)}
|
||||
.pill.s-tier-locked{color:var(--cinnabar,#e63946);border-color:#5a1d23}
|
||||
.as-actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto}
|
||||
.as-btn{background:transparent;border:1px solid var(--line,#2a2a3a);color:var(--text,#e8e6d9);
|
||||
padding:5px 10px;border-radius:6px;font-family:inherit;font-size:11px;cursor:pointer}
|
||||
.as-btn:hover{border-color:var(--gold-hermetic,#c9a84c)}
|
||||
.as-btn.danger{color:var(--cinnabar,#e63946)} .as-btn:disabled{opacity:.4;cursor:not-allowed}
|
||||
.as-over{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;z-index:1000;align-items:center;justify-content:center}
|
||||
.as-over.on{display:flex}
|
||||
.as-modal{background:var(--panel,#13131c);border:1px solid var(--line,#2a2a3a);border-radius:12px;
|
||||
width:min(720px,92vw);max-height:88vh;overflow:auto;padding:18px}
|
||||
.as-modal h2{font-family:'Cinzel',serif;color:var(--gold-hermetic,#c9a84c);margin:.2em 0}
|
||||
.as-modal textarea{width:100%;min-height:300px;background:#0a0a0f;color:var(--text,#e8e6d9);
|
||||
border:1px solid var(--line,#2a2a3a);border-radius:8px;font-family:'JetBrains Mono',monospace;font-size:12px;padding:10px}
|
||||
.as-row{display:flex;gap:8px;justify-content:flex-end;margin-top:10px;align-items:center}
|
||||
.as-msg{font-size:12px;margin-right:auto;color:var(--text-muted,#6b6b7a)}
|
||||
.as-deps{color:var(--text-muted,#6b6b7a);font-size:12px;margin:6px 0}
|
||||
</style>
|
||||
</head>
|
||||
<body class="crt-light">
|
||||
<nav class="sidebar" id="sidebar"></nav>
|
||||
<script src="/shared/sidebar.js"></script>
|
||||
|
||||
<main class="main">
|
||||
<header class="header">
|
||||
<div class="header-title">
|
||||
<h1>🛍️ App Store</h1>
|
||||
<span class="badge" id="board-badge">tier —</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="as-bar">
|
||||
<input id="q" type="search" placeholder="Search modules…" oninput="render()">
|
||||
<select id="category" onchange="render()"><option value="">All categories</option></select>
|
||||
<select id="tier" onchange="render()"><option value="">All tiers</option>
|
||||
<option>lite</option><option>standard</option><option>pro</option><option>all</option></select>
|
||||
<select id="state" onchange="render()"><option value="">All states</option>
|
||||
<option value="running">running</option><option value="installed">installed</option>
|
||||
<option value="available">available</option><option value="tier-locked">tier-locked</option></select>
|
||||
<span class="as-stat" id="stat">loading…</span>
|
||||
</div>
|
||||
<div class="as-grid" id="grid"></div>
|
||||
</main>
|
||||
|
||||
<!-- Config / detail drawer -->
|
||||
<div class="as-over" id="over">
|
||||
<div class="as-modal">
|
||||
<h2 id="m-title">module</h2>
|
||||
<div class="as-deps" id="m-deps"></div>
|
||||
<div class="as-actions" id="m-actions" style="margin-bottom:10px"></div>
|
||||
<label style="font-size:12px;color:var(--text-muted,#6b6b7a)">Config (<span id="m-path"></span>)</label>
|
||||
<textarea id="m-config" spellcheck="false"></textarea>
|
||||
<div class="as-row">
|
||||
<span class="as-msg" id="m-msg"></span>
|
||||
<button class="as-btn" onclick="closeModal()">Close</button>
|
||||
<button class="as-btn" id="m-save" onclick="saveConfig()">Save config</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API='/api/v1/appstore';
|
||||
const ICONS={media:'🎬',email:'✉️',ai:'🧠',iot:'🛰️',communication:'💬',publishing:'📰',
|
||||
network:'🌐',security:'🛡️',system:'⚙️',vpn:'🔒',dashboard:'📊',monitoring:'📈',misc:'🧩'};
|
||||
let ALL=[], CUR=null;
|
||||
|
||||
async function jget(p){try{const r=await fetch(API+p);if(!r.ok)throw 0;return await r.json();}catch(e){return null;}}
|
||||
async function jsend(p,m,b){try{const r=await fetch(API+p,{method:m,headers:{'Content-Type':'application/json'},
|
||||
body:b?JSON.stringify(b):undefined});const j=await r.json().catch(()=>({}));return{ok:r.ok,j};}catch(e){return{ok:false,j:{detail:String(e)}};}}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':s;return d.innerHTML;}
|
||||
|
||||
async function load(){
|
||||
const cats=await jget('/categories');
|
||||
if(cats&&cats.categories){const sel=document.getElementById('category');
|
||||
cats.categories.forEach(c=>{const o=document.createElement('option');o.value=c.name;o.textContent=`${c.name} (${c.count})`;sel.appendChild(o);});}
|
||||
const data=await jget('/catalog');
|
||||
ALL=(data&&Array.isArray(data.modules))?data.modules:[];
|
||||
document.getElementById('board-badge').textContent='tier '+((cats&&cats.board_tier)||'?');
|
||||
render();
|
||||
}
|
||||
|
||||
function actionsFor(m){
|
||||
if(!m.installed) return `<button class="as-btn" disabled title="Install arrives in a later phase">Install</button>`;
|
||||
const b=[];
|
||||
if(m.running){ b.push(`<button class="as-btn" onclick="act('${m.name}','restart')">Restart</button>`);
|
||||
b.push(`<button class="as-btn danger" onclick="act('${m.name}','stop')">Stop</button>`); }
|
||||
else { b.push(`<button class="as-btn" onclick="act('${m.name}','start')">Start</button>`); }
|
||||
b.push(`<button class="as-btn" onclick="act('${m.name}','enable')">Enable</button>`);
|
||||
b.push(`<button class="as-btn danger" onclick="act('${m.name}','disable')">Disable</button>`);
|
||||
b.push(`<button class="as-btn" onclick="openModal('${m.name}')">Config</button>`);
|
||||
return b.join('');
|
||||
}
|
||||
|
||||
function render(){
|
||||
const q=document.getElementById('q').value.toLowerCase();
|
||||
const cat=document.getElementById('category').value, tier=document.getElementById('tier').value, state=document.getElementById('state').value;
|
||||
let items=ALL.filter(m=>{
|
||||
if(cat&&m.category!==cat)return false; if(tier&&m.tier!==tier)return false; if(state&&m.state!==state)return false;
|
||||
if(q&&!(m.name.toLowerCase().includes(q)||(m.description||'').toLowerCase().includes(q)))return false; return true;});
|
||||
const running=ALL.filter(m=>m.state==='running').length, installed=ALL.filter(m=>m.installed).length;
|
||||
document.getElementById('stat').innerHTML=`showing <b>${items.length}</b>/${ALL.length} · installed <b>${installed}</b> · running <b>${running}</b>`;
|
||||
const grid=document.getElementById('grid');
|
||||
if(!items.length){grid.innerHTML='<div style="color:var(--text-muted,#6b6b7a);padding:30px">No modules match.</div>';return;}
|
||||
grid.innerHTML=items.map(m=>{
|
||||
const ico=ICONS[m.category]||ICONS.misc, label=m.name.replace(/^secubox-/,''), st=m.state||'available';
|
||||
return `<div class="as-card">
|
||||
<div class="as-top"><span class="as-ico">${ico}</span><span class="as-name">${esc(label)}</span></div>
|
||||
<div class="as-desc">${esc(m.description||'')}</div>
|
||||
<div class="as-badges"><span class="pill cat">${esc(m.category)}</span>
|
||||
<span class="pill tier">${esc(m.tier)}</span><span class="pill s-${st}">${st}</span>
|
||||
${m.version?`<span class="pill">v${esc(m.version)}</span>`:''}</div>
|
||||
<div class="as-actions">${actionsFor(m)}</div>
|
||||
</div>`;}).join('');
|
||||
}
|
||||
|
||||
async function act(name,verb){
|
||||
const r=await jsend(`/module/${name}/action/${verb}`,'POST');
|
||||
if(!r.ok){alert(`${verb} failed: ${(r.j&&r.j.detail)||'error'}`);return;}
|
||||
const data=await jget('/catalog'); ALL=(data&&Array.isArray(data.modules))?data.modules:ALL; render();
|
||||
if(CUR&&CUR===name) renderModalActions();
|
||||
}
|
||||
|
||||
async function openModal(name){
|
||||
CUR=name; document.getElementById('m-title').textContent=name.replace(/^secubox-/,'');
|
||||
const m=ALL.find(x=>x.name===name)||{};
|
||||
document.getElementById('m-deps').innerHTML='Depends: '+((m.depends&&m.depends.length)?m.depends.map(esc).join(', '):'—');
|
||||
renderModalActions();
|
||||
document.getElementById('m-msg').textContent='loading config…';
|
||||
document.getElementById('m-config').value='';
|
||||
document.getElementById('over').classList.add('on');
|
||||
const cfg=await jget(`/module/${name}/config`);
|
||||
const ta=document.getElementById('m-config'), save=document.getElementById('m-save');
|
||||
document.getElementById('m-path').textContent=(cfg&&cfg.path)||'';
|
||||
if(cfg&&cfg.readable===false){ta.value='';ta.disabled=true;save.disabled=true;document.getElementById('m-msg').textContent='config not readable by appstore';}
|
||||
else if(cfg&&!cfg.exists){ta.value='';ta.disabled=false;save.disabled=false;document.getElementById('m-msg').textContent='no config file yet — saving creates it';}
|
||||
else{ta.value=(cfg&&cfg.content)||'';ta.disabled=false;save.disabled=false;document.getElementById('m-msg').textContent='';}
|
||||
}
|
||||
function renderModalActions(){const m=ALL.find(x=>x.name===CUR)||{};document.getElementById('m-actions').innerHTML=actionsFor(m);}
|
||||
function closeModal(){document.getElementById('over').classList.remove('on');CUR=null;}
|
||||
|
||||
async function saveConfig(){
|
||||
if(!CUR)return; const content=document.getElementById('m-config').value;
|
||||
document.getElementById('m-msg').textContent='saving…';
|
||||
const r=await jsend(`/module/${CUR}/config`,'PUT',{content});
|
||||
document.getElementById('m-msg').textContent=r.ok?'saved ✓ (restart the service to apply)':('save failed: '+((r.j&&r.j.detail)||'error'));
|
||||
}
|
||||
document.getElementById('over').addEventListener('click',e=>{if(e.target.id==='over')closeModal();});
|
||||
document.addEventListener('DOMContentLoaded',load);
|
||||
</script>
|
||||
<script src="/shared/crt-engine.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -14,6 +14,9 @@ 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
|
||||
|
||||
app = FastAPI(title="secubox-hub", version="1.7.0", root_path="/api/v1/hub")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,15 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,21 @@
|
|||
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,
|
||||
|
|
|
|||
15
packages/secubox-lyrion/debian/secubox.yaml
Normal file
15
packages/secubox-lyrion/debian/secubox.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# debian/secubox.yaml
|
||||
name: secubox-lyrion
|
||||
category: media
|
||||
tier: lite
|
||||
description: "Lyrion Music Server (LMS / Squeezebox) in a Debian LXC"
|
||||
|
||||
depends:
|
||||
- secubox-core
|
||||
|
||||
api:
|
||||
socket: /run/secubox/aggregator.sock
|
||||
health: /api/v1/lyrion/health
|
||||
|
||||
ui:
|
||||
path: /lyrion/
|
||||
|
|
@ -67,21 +67,38 @@ ensure_masquerade() {
|
|||
# systemd nftables.service loads everything in that directory.
|
||||
ensure_slimproto_dnat() {
|
||||
local nft_file="/etc/nftables.d/secubox-lyrion-dnat.nft"
|
||||
local lan_iface="${SECUBOX_LAN_IFACE:-lan0}"
|
||||
log "Ensuring slimproto DNAT ${lan_iface}:3483 → ${LXC_IP}:3483 ..."
|
||||
# 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 ..."
|
||||
install -d -m 0755 /etc/nftables.d
|
||||
cat > "$nft_file" <<NFT
|
||||
# /etc/nftables.d/secubox-lyrion-dnat.nft
|
||||
# DNAT slimproto (TCP+UDP :3483) from LAN to the Lyrion LXC.
|
||||
# DNAT slimproto (TCP+UDP :3483) from the LAN to the Lyrion LXC.
|
||||
# Lets WiFi/LAN players (Squeezelite, iPeng, …) reach the LMS server
|
||||
# without bridging the LXC network into the LAN.
|
||||
# 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.
|
||||
# Generated by secubox-lyrion install-lxc.sh (#248).
|
||||
|
||||
table inet secubox-lyrion {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
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"
|
||||
${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"
|
||||
}
|
||||
}
|
||||
NFT
|
||||
|
|
|
|||
|
|
@ -5,9 +5,17 @@
|
|||
|
||||
"""
|
||||
SecuBox-Deb :: LiveHosts aggregator
|
||||
Polls the HAProxy admin socket once per minute, ring-buffers per-frontend
|
||||
request deltas over 60 minutes, and emits a sanitized top-N rollup of the
|
||||
hostnames being served.
|
||||
|
||||
Counts requests per vhost over the last `window_minutes` by reading the
|
||||
per-vhost nginx access logs (/var/log/nginx/<vhost>_access.log) and parsing
|
||||
their combined-format timestamps. Emits a top-N rollup plus the total request
|
||||
count in the window (used as the denominator for the WAF block rate).
|
||||
|
||||
Earlier versions polled the HAProxy admin socket per frontend; that only works
|
||||
when every vhost has its own HAProxy frontend. This deployment funnels all
|
||||
vhosts through a single `http-in` frontend (→ mitmproxy/WAF → nginx), so the
|
||||
authoritative per-vhost signal is the nginx logs. Requires the service user to
|
||||
be able to read /var/log/nginx (the `adm` group).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -15,22 +23,25 @@ import asyncio
|
|||
import collections
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
log = logging.getLogger("secubox.live_hosts")
|
||||
|
||||
CACHE_PATH = Path("/var/cache/secubox/metrics/live-hosts.json")
|
||||
DEFAULT_LOG_DIR = "/var/log/nginx"
|
||||
# nginx combined log timestamp: [29/Jun/2026:17:27:38 +0200]
|
||||
TS_RE = re.compile(r"\[(\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\]")
|
||||
TS_FMT = "%d/%b/%Y:%H:%M:%S %z"
|
||||
# Bound the per-file read so a huge log can't stall the collector. 60 min of a
|
||||
# busy vhost fits comfortably; older lines beyond this are out of window anyway.
|
||||
TAIL_BYTES = 3_000_000
|
||||
|
||||
|
||||
class LiveHostsAggregator:
|
||||
def __init__(self, cfg: dict):
|
||||
self.cfg = cfg
|
||||
self._buckets: collections.deque[dict[str, int]] = collections.deque(maxlen=60)
|
||||
self._prev_totals: dict[str, int] = {}
|
||||
self._payload: dict = {"enabled": False, "entries": []}
|
||||
self._refreshed = False
|
||||
|
||||
|
|
@ -44,13 +55,14 @@ class LiveHostsAggregator:
|
|||
return json.loads(CACHE_PATH.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {"enabled": False, "window_minutes": self.cfg["window_minutes"], "entries": []}
|
||||
return {"enabled": False, "window_minutes": self.cfg.get("window_minutes", 60),
|
||||
"entries": [], "total_requests": 0}
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
self._payload = await self.refresh_once()
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("refresh_once raised: %s", e)
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
|
@ -58,18 +70,15 @@ class LiveHostsAggregator:
|
|||
if not self.cfg.get("enabled"):
|
||||
self._refreshed = True
|
||||
return self._disabled_payload()
|
||||
totals = await asyncio.to_thread(self._read_haproxy_stats)
|
||||
if totals is None:
|
||||
self._refreshed = True
|
||||
return self._disabled_payload()
|
||||
kept = self._filter_frontends(totals)
|
||||
self._delta_and_buffer(kept)
|
||||
entries = self._aggregate()
|
||||
counts, total = await asyncio.to_thread(self._read_nginx_hosts)
|
||||
top_n = int(self.cfg.get("top_n", 5))
|
||||
entries = [{"host": h, "count": c} for h, c in counts.most_common(top_n) if c > 0]
|
||||
payload = {
|
||||
"enabled": True,
|
||||
"window_minutes": self.cfg["window_minutes"],
|
||||
"window_minutes": int(self.cfg.get("window_minutes", 60)),
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"entries": entries,
|
||||
"total_requests": total,
|
||||
}
|
||||
self._persist(payload)
|
||||
self._refreshed = True
|
||||
|
|
@ -77,91 +86,46 @@ class LiveHostsAggregator:
|
|||
|
||||
# -- helpers --------------------------------------------
|
||||
|
||||
def _filter_frontends(self, totals: dict[str, int]) -> dict[str, int]:
|
||||
flt = self.cfg.get("frontend_filter", "*")
|
||||
out: dict[str, int] = {}
|
||||
for name, n in totals.items():
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
if "." not in name:
|
||||
continue
|
||||
if flt != "*" and flt not in name:
|
||||
continue
|
||||
out[name] = n
|
||||
return out
|
||||
|
||||
def _delta_and_buffer(self, totals: dict[str, int]) -> None:
|
||||
if not self._prev_totals:
|
||||
self._buckets.append({k: 0 for k in totals})
|
||||
self._prev_totals = dict(totals)
|
||||
return
|
||||
bucket: dict[str, int] = {}
|
||||
for host, cur in totals.items():
|
||||
prev = self._prev_totals.get(host)
|
||||
if prev is None or cur < prev:
|
||||
bucket[host] = 0
|
||||
else:
|
||||
bucket[host] = cur - prev
|
||||
self._buckets.append(bucket)
|
||||
self._prev_totals = dict(totals)
|
||||
|
||||
def _aggregate(self) -> list[dict]:
|
||||
totals: dict[str, int] = collections.Counter()
|
||||
for bucket in self._buckets:
|
||||
for host, n in bucket.items():
|
||||
totals[host] += n
|
||||
entries = [{"host": h, "count": c} for h, c in totals.items() if c > 0]
|
||||
entries.sort(key=lambda e: (-e["count"], e["host"]))
|
||||
return entries[: self.cfg["top_n"]]
|
||||
|
||||
def _read_haproxy_stats(self) -> Optional[dict[str, int]]:
|
||||
sock_path = self.cfg["haproxy_socket"]
|
||||
if not Path(sock_path).exists():
|
||||
return None
|
||||
def _read_nginx_hosts(self) -> tuple[collections.Counter, int]:
|
||||
log_dir = Path(self.cfg.get("log_dir", DEFAULT_LOG_DIR))
|
||||
window = int(self.cfg.get("window_minutes", 60))
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=window)
|
||||
counts: collections.Counter = collections.Counter()
|
||||
total = 0
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(2.0)
|
||||
s.connect(sock_path)
|
||||
s.sendall(b"show stat\n")
|
||||
chunks = []
|
||||
while True:
|
||||
data = s.recv(8192)
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data)
|
||||
blob = b"".join(chunks).decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
log.warning("haproxy socket read failed: %s", e)
|
||||
return None
|
||||
return self._parse_show_stat(blob)
|
||||
|
||||
@staticmethod
|
||||
def _parse_show_stat(blob: str) -> dict[str, int]:
|
||||
"""Extract {frontend_name: req_tot} from `show stat` CSV output."""
|
||||
out: dict[str, int] = {}
|
||||
lines = blob.splitlines()
|
||||
if not lines:
|
||||
return out
|
||||
header = lines[0].lstrip("# ").split(",")
|
||||
files = sorted(log_dir.glob("*_access.log"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("cannot list %s: %s", log_dir, e)
|
||||
return counts, 0
|
||||
for f in files:
|
||||
try:
|
||||
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("#"):
|
||||
size = f.stat().st_size
|
||||
if size == 0:
|
||||
continue
|
||||
cols = line.split(",")
|
||||
if len(cols) <= max(pxname_i, svname_i, req_tot_i):
|
||||
host = f.name[: -len("_access.log")]
|
||||
with open(f, "rb") as fh:
|
||||
if size > TAIL_BYTES:
|
||||
fh.seek(size - TAIL_BYTES)
|
||||
fh.readline() # drop the partial first line
|
||||
blob = fh.read().decode("utf-8", errors="replace")
|
||||
except PermissionError:
|
||||
log.warning("no read permission on %s (add service user to 'adm')", f)
|
||||
continue
|
||||
if cols[svname_i] != "FRONTEND":
|
||||
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:
|
||||
out[cols[pxname_i]] = int(cols[req_tot_i] or "0")
|
||||
ts = datetime.strptime(m.group(1), TS_FMT)
|
||||
except ValueError:
|
||||
continue
|
||||
return out
|
||||
if ts >= cutoff:
|
||||
counts[host] += 1
|
||||
total += 1
|
||||
return counts, total
|
||||
|
||||
def _persist(self, payload: dict) -> None:
|
||||
try:
|
||||
|
|
@ -169,13 +133,14 @@ class LiveHostsAggregator:
|
|||
tmp = CACHE_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload))
|
||||
tmp.replace(CACHE_PATH)
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("persist failed: %s", e)
|
||||
|
||||
def _disabled_payload(self) -> dict:
|
||||
return {
|
||||
"enabled": False,
|
||||
"window_minutes": self.cfg["window_minutes"],
|
||||
"window_minutes": int(self.cfg.get("window_minutes", 60)),
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"entries": [],
|
||||
"total_requests": 0,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -536,6 +536,38 @@ async def get_firewall_stats(auth: None = Depends(require_jwt)):
|
|||
# For the global health banner with smart doctor advisor
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _count_waf_blocks(window_minutes: int = 60) -> int:
|
||||
"""Count Go-sbxwaf block events in the last window from the live threat
|
||||
log (/var/log/secubox/waf/waf-threats.log, JSONL with ISO timestamps).
|
||||
Bounded tail read so a large log can't stall the summary."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
p = Path("/var/log/secubox/waf/waf-threats.log")
|
||||
try:
|
||||
size = p.stat().st_size
|
||||
except Exception:
|
||||
return 0
|
||||
if size == 0:
|
||||
return 0
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=window_minutes)
|
||||
n = 0
|
||||
try:
|
||||
with open(p, "rb") as fh:
|
||||
if size > 3_000_000:
|
||||
fh.seek(size - 3_000_000)
|
||||
fh.readline()
|
||||
for line in fh.read().decode("utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
e = json.loads(line)
|
||||
ts = datetime.fromisoformat(e["timestamp"])
|
||||
if ts >= cutoff:
|
||||
n += 1
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return n
|
||||
|
||||
|
||||
def build_health_summary() -> dict:
|
||||
"""Build aggregated health summary for the health banner."""
|
||||
|
||||
|
|
@ -626,16 +658,16 @@ def build_health_summary() -> dict:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Get WAF blocked percentage (estimate from recent logs)
|
||||
# WAF block rate from the live sbxwaf threat log over the last hour,
|
||||
# as a share of total traffic (legit nginx requests + blocks).
|
||||
blocked_pct = 0
|
||||
waf_blocks_1h = _count_waf_blocks(60)
|
||||
try:
|
||||
waf_log = Path('/var/log/mitmproxy/threats.jsonl')
|
||||
if waf_log.exists():
|
||||
# Count threats in last hour
|
||||
result = run_cmd(['wc', '-l', str(waf_log)])
|
||||
threat_count = int(result.split()[0]) if result else 0
|
||||
# Rough estimate: 1000 requests/hour baseline
|
||||
blocked_pct = min(100, threat_count // 10)
|
||||
lh = live_hosts_agg.current()
|
||||
total_req = int(lh.get("total_requests", 0)) if isinstance(lh, dict) else 0
|
||||
denom = total_req + waf_blocks_1h
|
||||
if denom > 0:
|
||||
blocked_pct = round(waf_blocks_1h / denom * 100, 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -683,6 +715,7 @@ def build_health_summary() -> dict:
|
|||
"modules": modules,
|
||||
"waf": {
|
||||
"blocked_pct": blocked_pct,
|
||||
"blocks_1h": waf_blocks_1h,
|
||||
"active": waf_stats.get("mitmproxy_running", False)
|
||||
},
|
||||
"crowdsec": {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,18 @@
|
|||
secubox-metrics (1.0.5-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(metrics): real data for the portal/dashboard.
|
||||
- live_hosts: source per-vhost request counts from the nginx per-vhost
|
||||
access logs (windowed) instead of HAProxy frontends — this topology
|
||||
funnels all vhosts through one frontend, so HAProxy had no per-vhost
|
||||
signal. Adds total_requests to the payload.
|
||||
- health/summary WAF block rate now reads the live sbxwaf threat log
|
||||
(/var/log/secubox/waf/waf-threats.log) over the last hour as a share
|
||||
of total traffic, replacing the stale /var/log/mitmproxy path + the
|
||||
count//10 heuristic; adds waf.blocks_1h.
|
||||
- postinst: add the service user to 'adm' to read /var/log/nginx.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 21:30:00 +0200
|
||||
|
||||
secubox-metrics (1.0.4-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* #494: postinst no longer chowns the shared /run/secubox parent
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ case "$1" in
|
|||
# Add secubox to haproxy group for admin-socket access (issue #92)
|
||||
if getent group haproxy >/dev/null; then
|
||||
usermod -aG haproxy secubox || true
|
||||
# adm group: read /var/log/nginx for per-vhost live-hosts metric
|
||||
usermod -aG adm secubox || true
|
||||
fi
|
||||
|
||||
# Persistent cache dir for live-panel rollups
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ except ImportError:
|
|||
async def require_jwt():
|
||||
return {"sub": "admin"}
|
||||
|
||||
from . import mesh
|
||||
|
||||
app = FastAPI(
|
||||
title="SecuBox P2P API",
|
||||
description="P2P network hub with peer discovery and master-link enrollment",
|
||||
|
|
@ -634,6 +636,10 @@ async def get_status():
|
|||
peers = [p for p in peers if not p.get("is_local") and p.get("id") != local_id]
|
||||
online_peers = [p for p in peers if p.get("status") == "online"]
|
||||
|
||||
# Mesh view is driven by the wg-mesh transport (wg_mesh.json), which p2p
|
||||
# now owns (Gondwana Phase 1). The web UI reads total_peers/active_peers.
|
||||
wg_peer_count = len(mesh.peer_nodes(get_wg_mesh_config()))
|
||||
|
||||
# Get master-link status
|
||||
ml_config = get_ml_config()
|
||||
|
||||
|
|
@ -643,8 +649,10 @@ async def get_status():
|
|||
"hostname": get_hostname(),
|
||||
"lan_ip": get_lan_ip(),
|
||||
"wan_ip": get_wan_ip(),
|
||||
"peer_count": len(peers),
|
||||
"online_peers": len(online_peers),
|
||||
"peer_count": wg_peer_count or len(peers),
|
||||
"online_peers": wg_peer_count or len(online_peers),
|
||||
"total_peers": wg_peer_count or len(peers),
|
||||
"active_peers": wg_peer_count or len(online_peers),
|
||||
"service_count": len(services) if isinstance(services, list) else 0,
|
||||
"threat_count": len(threats) if isinstance(threats, dict) else 0,
|
||||
"master_link": {
|
||||
|
|
@ -698,18 +706,22 @@ async def get_self():
|
|||
|
||||
@app.get("/peers")
|
||||
async def list_peers():
|
||||
"""List all known peers (public read)."""
|
||||
"""List all known peers (public read).
|
||||
|
||||
The wg-mesh transport (wg_mesh.json) is the source of truth for the mesh
|
||||
view (Gondwana Phase 1); fall back to the legacy peers.json registry only
|
||||
when there are no wg-mesh peers configured.
|
||||
"""
|
||||
init_dirs()
|
||||
nodes = mesh.peer_nodes(get_wg_mesh_config())
|
||||
if nodes:
|
||||
return {"peers": nodes, "count": len(nodes)}
|
||||
|
||||
peers_data = load_json(PEERS_FILE, {"peers": []})
|
||||
peers = peers_data.get("peers", []) if isinstance(peers_data, dict) else peers_data
|
||||
|
||||
# A node is not its own peer: never insert/persist the local node here.
|
||||
# (Older versions did, which inflated peer_count and listed "<host> (local)"
|
||||
# as a phantom peer.) Drop any self entry a prior version may have saved.
|
||||
# Use /discover/self for the local node's announcement payload instead.
|
||||
local_id = get_node_id()
|
||||
peers = [p for p in peers if not p.get("is_local") and p.get("id") != local_id]
|
||||
|
||||
return {"peers": peers, "count": len(peers)}
|
||||
|
||||
|
||||
|
|
@ -859,15 +871,39 @@ async def unregister_service(name: str, user: dict = Depends(require_jwt)):
|
|||
|
||||
@app.get("/mesh")
|
||||
async def get_mesh_status():
|
||||
"""Get mesh network topology (public read)."""
|
||||
init_dirs()
|
||||
peers_data = load_json(PEERS_FILE, {"peers": []})
|
||||
peers = peers_data.get("peers", []) if isinstance(peers_data, dict) else peers_data
|
||||
"""Get mesh network topology (public read).
|
||||
|
||||
nodes = []
|
||||
links = []
|
||||
Derived from the wg-mesh transport (wg_mesh.json) that p2p owns
|
||||
(Gondwana Phase 1): the local node is the center, each wg peer is a node,
|
||||
links go from local to each peer (hub-and-spoke). Falls back to the
|
||||
legacy peers.json registry when no wg-mesh peers are configured.
|
||||
"""
|
||||
init_dirs()
|
||||
wg = get_wg_mesh_config()
|
||||
peer_views = mesh.peer_nodes(wg)
|
||||
local_id = get_node_id()
|
||||
|
||||
if peer_views:
|
||||
local_addr = (wg.get("address") or "").split("/")[0]
|
||||
local_node = {
|
||||
"id": local_id,
|
||||
"name": get_hostname(),
|
||||
"address": local_addr,
|
||||
"status": "online",
|
||||
"is_local": True,
|
||||
}
|
||||
nodes = [local_node]
|
||||
links = []
|
||||
for pv in peer_views:
|
||||
nodes.append({**pv, "is_local": False})
|
||||
links.append({"source": local_id, "target": pv["id"], "status": pv.get("status", "online")})
|
||||
return {"nodes": nodes, "links": links, "total_nodes": len(nodes), "local_node": local_id}
|
||||
|
||||
# Legacy fallback (peers.json registry)
|
||||
peers_data = load_json(PEERS_FILE, {"peers": []})
|
||||
peers = peers_data.get("peers", []) if isinstance(peers_data, dict) else peers_data
|
||||
nodes = []
|
||||
links = []
|
||||
for peer in peers:
|
||||
node = {
|
||||
"id": peer.get('id', ''),
|
||||
|
|
@ -877,21 +913,9 @@ async def get_mesh_status():
|
|||
"is_local": peer.get('is_local', False) or peer.get('id') == local_id
|
||||
}
|
||||
nodes.append(node)
|
||||
|
||||
# Create links from local node to all peers
|
||||
if not node["is_local"]:
|
||||
links.append({
|
||||
"source": local_id,
|
||||
"target": peer.get('id'),
|
||||
"status": peer.get('status', 'unknown')
|
||||
})
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"links": links,
|
||||
"total_nodes": len(nodes),
|
||||
"local_node": local_id
|
||||
}
|
||||
links.append({"source": local_id, "target": peer.get('id'), "status": peer.get('status', 'unknown')})
|
||||
return {"nodes": nodes, "links": links, "total_nodes": len(nodes), "local_node": local_id}
|
||||
|
||||
|
||||
# ============== Profiles ==============
|
||||
|
|
@ -975,9 +999,9 @@ async def remove_threat(ip: str, user: dict = Depends(require_jwt)):
|
|||
# ============== WireGuard Mesh ==============
|
||||
|
||||
WG_MESH_CONFIG = P2P_DIR / "wg_mesh.json"
|
||||
WG_INTERFACE = "wg-mesh"
|
||||
WG_PORT = 51820
|
||||
WG_NETWORK = "10.100.0.0/24"
|
||||
WG_INTERFACE = mesh.MESH_INTERFACE
|
||||
WG_PORT = mesh.MESH_PORT
|
||||
WG_NETWORK = mesh.MESH_NETWORK
|
||||
|
||||
|
||||
def get_wg_mesh_config() -> Dict:
|
||||
|
|
@ -1055,11 +1079,16 @@ async def init_wireguard(user: dict = Depends(require_jwt)):
|
|||
config["private_key"] = private_key
|
||||
config["public_key"] = public_key
|
||||
|
||||
# Assign IP from network (based on node ID)
|
||||
node_id = get_node_id()
|
||||
ip_suffix = int(hashlib.md5(node_id.encode()).hexdigest()[:2], 16) % 253 + 1
|
||||
network_prefix = WG_NETWORK.rsplit('.', 2)[0]
|
||||
config["address"] = f"{network_prefix}.{ip_suffix}/24"
|
||||
# Assign mesh IP: .1 for the master role, else allocate from the pool.
|
||||
p2p_cfg = mesh.load_p2p_config(CONFIG_FILE)
|
||||
if p2p_cfg["role"] == "master":
|
||||
addr = "10.10.0.1"
|
||||
else:
|
||||
taken = [p.get("allowed_ips", "") for p in config.get("peers", [])]
|
||||
addr = mesh.allocate_mesh_ip(WG_NETWORK, taken)
|
||||
config["address"] = f"{addr}/24"
|
||||
config["role"] = p2p_cfg["role"]
|
||||
config["ddns"] = mesh.ddns_name(get_hostname())
|
||||
|
||||
save_json(WG_MESH_CONFIG, config)
|
||||
|
||||
|
|
@ -1075,7 +1104,7 @@ async def init_wireguard(user: dict = Depends(require_jwt)):
|
|||
async def add_wireguard_peer(
|
||||
public_key: str,
|
||||
endpoint: str,
|
||||
allowed_ips: str = "10.100.0.0/24",
|
||||
allowed_ips: str = "10.10.0.0/24",
|
||||
user: dict = Depends(require_jwt)
|
||||
):
|
||||
"""Add a WireGuard mesh peer."""
|
||||
|
|
@ -1107,40 +1136,21 @@ async def enable_wireguard(user: dict = Depends(require_jwt)):
|
|||
if not config.get("private_key"):
|
||||
raise HTTPException(status_code=400, detail="WireGuard not initialized")
|
||||
|
||||
# Create interface config
|
||||
wg_conf = f"""[Interface]
|
||||
PrivateKey = {config['private_key']}
|
||||
Address = {config.get('address', '10.100.0.1/24')}
|
||||
ListenPort = {config.get('listen_port', WG_PORT)}
|
||||
"""
|
||||
for peer in config.get("peers", []):
|
||||
wg_conf += f"""
|
||||
[Peer]
|
||||
PublicKey = {peer['public_key']}
|
||||
Endpoint = {peer['endpoint']}
|
||||
AllowedIPs = {peer.get('allowed_ips', '10.100.0.0/24')}
|
||||
PersistentKeepalive = 25
|
||||
"""
|
||||
bad = mesh.subnet_overlap(config.get("network", WG_NETWORK))
|
||||
if bad:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"mesh network overlaps reserved subnet {bad!r}; refusing")
|
||||
|
||||
# Write config and bring up interface
|
||||
conf_path = Path(f"/etc/wireguard/{WG_INTERFACE}.conf")
|
||||
conf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conf_path.write_text(wg_conf)
|
||||
conf_path.chmod(0o600)
|
||||
|
||||
try:
|
||||
subprocess.run(["wg-quick", "down", WG_INTERFACE], capture_output=True, timeout=10)
|
||||
except:
|
||||
pass
|
||||
|
||||
result = subprocess.run(["wg-quick", "up", WG_INTERFACE], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start WireGuard: {result.stderr}")
|
||||
if not config.get("address"):
|
||||
raise HTTPException(status_code=400,
|
||||
detail="WireGuard not initialized (no address); run /wireguard/init first")
|
||||
|
||||
# Provisioning (wg-quick, /etc/wireguard write) is delegated to the root
|
||||
# CLI sbx-mesh-up; this unprivileged endpoint only marks the intent.
|
||||
config["enabled"] = True
|
||||
save_json(WG_MESH_CONFIG, config)
|
||||
|
||||
return {"status": "ok", "message": "WireGuard mesh enabled"}
|
||||
return {"status": "ok", "message": "mesh marked enabled; run 'sbx-mesh-up' as root to provision the interface"}
|
||||
|
||||
|
||||
# ============== Remote Announcers ==============
|
||||
|
|
@ -1744,6 +1754,7 @@ async def ml_join(req: JoinRequest, request: Request):
|
|||
join_request["approved_at"] = now.isoformat()
|
||||
join_request["approved_by"] = config.get("fingerprint", get_node_id())
|
||||
join_request["depth"] = peer_depth
|
||||
_assign_mesh_ip(join_request)
|
||||
|
||||
# Mark token as used
|
||||
ml_token_mark_used(token_hash, req.fingerprint)
|
||||
|
|
@ -1774,6 +1785,12 @@ async def ml_join(req: JoinRequest, request: Request):
|
|||
}
|
||||
|
||||
|
||||
def _assign_mesh_ip(join_request: Dict) -> None:
|
||||
"""Allocate the next free mesh IP, deduping against persisted peers."""
|
||||
taken = [p.get("mesh_ip", "") for p in load_json(PEERS_FILE, {"peers": []}).get("peers", [])]
|
||||
join_request["mesh_ip"] = mesh.allocate_mesh_ip(mesh.MESH_NETWORK, taken)
|
||||
|
||||
|
||||
def _add_approved_peer(join_request: Dict):
|
||||
"""Add approved peer to peer list."""
|
||||
peers_data = load_json(PEERS_FILE, {"peers": []})
|
||||
|
|
@ -1790,6 +1807,7 @@ def _add_approved_peer(join_request: Dict):
|
|||
"fingerprint": join_request["fingerprint"],
|
||||
"name": join_request.get("hostname", "Peer"),
|
||||
"address": join_request.get("address"),
|
||||
"mesh_ip": join_request.get("mesh_ip"),
|
||||
"depth": join_request.get("depth", 1),
|
||||
"role": join_request.get("role", "peer"),
|
||||
"added": datetime.utcnow().isoformat(),
|
||||
|
|
@ -1830,6 +1848,10 @@ async def ml_approve(req: ApproveRequest, user: dict = Depends(require_jwt)):
|
|||
if token_hash:
|
||||
ml_token_mark_used(token_hash, req.fingerprint)
|
||||
|
||||
# Allocate mesh IP if not already set (handles manual-approve path)
|
||||
if not join_request.get("mesh_ip"):
|
||||
_assign_mesh_ip(join_request)
|
||||
|
||||
# Add to peers
|
||||
_add_approved_peer(join_request)
|
||||
|
||||
|
|
|
|||
173
packages/secubox-p2p/api/mesh.py
Normal file
173
packages/secubox-p2p/api/mesh.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""
|
||||
SecuBox-Deb :: secubox-p2p :: mesh
|
||||
Pure mesh logic — no FastAPI, no privilege. Imported by api/main.py (state
|
||||
endpoints, runs as user secubox) and by sbx-mesh-up (root provisioner).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import ipaddress
|
||||
import pathlib
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
MESH_INTERFACE = "wg-mesh"
|
||||
MESH_PORT = 51822
|
||||
MESH_NETWORK = "10.10.0.0/24"
|
||||
|
||||
# Reserved subnets the mesh must never overlap (name -> CIDR).
|
||||
RESERVED_SUBNETS = {
|
||||
"br-lxc": "10.100.0.0/24",
|
||||
"eye-br0": "10.55.0.0/24",
|
||||
"lxcbr0": "10.0.3.0/24",
|
||||
"wg-toolbox": "10.99.0.0/24",
|
||||
}
|
||||
|
||||
|
||||
def subnet_overlap(network: str) -> str | None:
|
||||
"""Return the name of the first RESERVED_SUBNETS entry that overlaps
|
||||
`network`, or None if `network` is clear."""
|
||||
net = ipaddress.ip_network(network, strict=False)
|
||||
for name, cidr in RESERVED_SUBNETS.items():
|
||||
if net.overlaps(ipaddress.ip_network(cidr, strict=False)):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def load_p2p_config(path: pathlib.Path) -> dict:
|
||||
"""Read the [wireguard] section of /etc/secubox/p2p.toml, with defaults."""
|
||||
defaults = {
|
||||
"interface": MESH_INTERFACE,
|
||||
"listen_port": MESH_PORT,
|
||||
"network": MESH_NETWORK,
|
||||
"role": "satellite",
|
||||
"master_endpoint": None,
|
||||
}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
wg = (tomllib.load(f) or {}).get("wireguard", {}) or {}
|
||||
except (FileNotFoundError, tomllib.TOMLDecodeError):
|
||||
wg = {}
|
||||
out = dict(defaults)
|
||||
for k in defaults:
|
||||
if wg.get(k) is not None:
|
||||
out[k] = wg[k]
|
||||
return out
|
||||
|
||||
|
||||
def allocate_mesh_ip(network: str, taken: list[str]) -> str:
|
||||
"""Lowest free host >= .2 in `network` (.1 reserved for master)."""
|
||||
taken_set = {t.split("/")[0] for t in taken}
|
||||
net = ipaddress.ip_network(network, strict=False)
|
||||
base = int(net.network_address)
|
||||
for off in range(2, net.num_addresses - 1):
|
||||
cand = str(ipaddress.ip_address(base + off))
|
||||
if cand not in taken_set:
|
||||
return cand
|
||||
raise RuntimeError(f"mesh address pool {network} exhausted")
|
||||
|
||||
|
||||
def parse_wg_conf(text: str) -> dict:
|
||||
"""Extract Interface fields from a wg-quick config (first [Interface])."""
|
||||
out = {"private_key": None, "address": None, "listen_port": None}
|
||||
in_iface = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith("["):
|
||||
in_iface = line.lower() == "[interface]"
|
||||
continue
|
||||
if not in_iface or "=" not in line:
|
||||
continue
|
||||
key, val = (p.strip() for p in line.split("=", 1))
|
||||
kl = key.lower()
|
||||
if kl == "privatekey":
|
||||
out["private_key"] = val
|
||||
elif kl == "address":
|
||||
out["address"] = val
|
||||
elif kl == "listenport":
|
||||
out["listen_port"] = int(val)
|
||||
return out
|
||||
|
||||
|
||||
def render_wg_conf(state: dict) -> str:
|
||||
"""Render a wg-quick config from mesh state."""
|
||||
lines = [
|
||||
"# Managed by secubox-p2p (sbx-mesh-up) — do not edit by hand.",
|
||||
"[Interface]",
|
||||
f"PrivateKey = {state['private_key']}",
|
||||
f"Address = {state['address']}",
|
||||
f"ListenPort = {state.get('listen_port', MESH_PORT)}",
|
||||
]
|
||||
for peer in state.get("peers", []):
|
||||
lines += ["", "[Peer]", f"PublicKey = {peer['public_key']}"]
|
||||
if peer.get("endpoint"):
|
||||
lines.append(f"Endpoint = {peer['endpoint']}")
|
||||
lines.append(f"AllowedIPs = {peer.get('allowed_ips', MESH_NETWORK)}")
|
||||
lines.append("PersistentKeepalive = 25")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def adopt_state(state: dict, existing_conf_text: str | None) -> dict:
|
||||
"""Import the live wg-mesh private key so the public key is preserved.
|
||||
Never overwrites a key already present in state."""
|
||||
if state.get("private_key"):
|
||||
return state
|
||||
if not existing_conf_text:
|
||||
return state
|
||||
parsed = parse_wg_conf(existing_conf_text)
|
||||
if parsed["private_key"]:
|
||||
state["private_key"] = parsed["private_key"]
|
||||
if not state.get("address") and parsed["address"]:
|
||||
state["address"] = parsed["address"]
|
||||
if parsed["listen_port"]:
|
||||
state["listen_port"] = parsed["listen_port"]
|
||||
return state
|
||||
|
||||
|
||||
def ddns_name(hostname: str, domain: str = "secubox.in") -> str:
|
||||
"""Return DDNS-safe hostname: lowercased, non-[a-z0-9-] replaced by -, .domain appended."""
|
||||
slug = re.sub(r"[^a-z0-9-]", "-", hostname.lower())
|
||||
slug = slug[:63] if slug else "node"
|
||||
return f"{slug}.{domain}"
|
||||
|
||||
|
||||
def _host_ip(allowed_ips: str) -> str:
|
||||
"""Return the single host IP from an allowed-ips value, else "".
|
||||
|
||||
A peer's mesh address is recoverable only when its allowed-ips is a /32
|
||||
host route (the master's view of a spoke). A /24 (a spoke's route to the
|
||||
hub) is not a host address, so we return "" and rely on an explicit
|
||||
mesh_ip field instead.
|
||||
"""
|
||||
first = (allowed_ips or "").split(",")[0].strip()
|
||||
if first.endswith("/32"):
|
||||
return first.split("/")[0]
|
||||
return ""
|
||||
|
||||
|
||||
def peer_nodes(state: dict) -> list:
|
||||
"""Map wg_mesh.json peers to app-layer node dicts for the /peers + /status
|
||||
API and the P2P web UI. The mesh transport (wg_mesh.json) is the source of
|
||||
truth; the legacy peers.json registry is unused by the mesh view.
|
||||
|
||||
Each node carries the fields the web UI renders: id, name, address,
|
||||
public_key, status, latency, last_seen. `status` is reported "online"
|
||||
(the unprivileged service cannot read wg handshakes to probe liveness;
|
||||
a privileged liveness probe is future work).
|
||||
"""
|
||||
out = []
|
||||
for p in state.get("peers", []):
|
||||
ip = p.get("mesh_ip") or _host_ip(p.get("allowed_ips", ""))
|
||||
name = p.get("name") or ip or (p.get("public_key", "")[:12] or "peer")
|
||||
out.append({
|
||||
"id": name,
|
||||
"name": name,
|
||||
"address": ip,
|
||||
"public_key": p.get("public_key", ""),
|
||||
"status": "online",
|
||||
"latency": None,
|
||||
"last_seen": None,
|
||||
})
|
||||
return out
|
||||
19
packages/secubox-p2p/conf/p2p.toml.example
Normal file
19
packages/secubox-p2p/conf/p2p.toml.example
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Installed to /etc/secubox/p2p.toml.example by secubox-p2p.
|
||||
# Copy to /etc/secubox/p2p.toml and edit per node.
|
||||
|
||||
[wireguard]
|
||||
# Mesh transport. Do NOT change `network` to anything overlapping the LXC
|
||||
# bridge (10.100.0.0/24) or other reserved subnets — sbx-mesh-up refuses.
|
||||
interface = "wg-mesh"
|
||||
listen_port = 51822
|
||||
network = "10.10.0.0/24"
|
||||
|
||||
# "master" = this node holds the rendezvous role (publicly reachable).
|
||||
# "satellite" = this node dials the rendezvous. Rendezvous is a ROLE — any
|
||||
# node may hold it; today only gk2 is publicly reachable.
|
||||
role = "satellite"
|
||||
|
||||
# Satellite only: where to reach the active rendezvous. Free-form host:port —
|
||||
# a literal IP (pinned now) or a DDNS name (WireGuard re-resolves per
|
||||
# handshake, so the rendezvous can change IP without reconfiguring peers).
|
||||
master_endpoint = "82.67.100.75:51822"
|
||||
|
|
@ -1,3 +1,40 @@
|
|||
secubox-p2p (1.7.8-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(webui): P2P dashboard rendered empty due to API-contract drift.
|
||||
- JS: /peers returns {peers,count} (object) but loadPeers read .length
|
||||
as an array -> peers never listed; same for /threats (a dict). Handle
|
||||
both shapes. loadOverview reads status.service_count.
|
||||
- JS: mesh graph was a hardcoded 'Peer 1-4' placeholder; now wired to
|
||||
GET /mesh (local node center + real wg-mesh peers on a ring).
|
||||
- api: GET /mesh now derives nodes/links from wg_mesh.json (local +
|
||||
wg peers), matching /peers + /status; legacy peers.json fallback kept.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 16:30:00 +0200
|
||||
|
||||
secubox-p2p (1.7.7-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(webui): P2P dashboard was empty — /peers and /status read the unused
|
||||
legacy peers.json, while the live mesh is in wg_mesh.json (Phase 1). Both
|
||||
endpoints now derive the mesh view from wg_mesh.json via mesh.peer_nodes();
|
||||
/status gains total_peers/active_peers (the fields the web UI reads).
|
||||
Per-peer name + mesh_ip surface friendly node labels.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 15:30:00 +0200
|
||||
|
||||
secubox-p2p (1.7.6-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* feat(gondwana P1): adopt secubox-p2p as the single mesh owner.
|
||||
- api/mesh.py: pure mesh logic (subnet collision guard, p2p.toml
|
||||
[wireguard] loader, master-assigned IP allocation, wg.conf
|
||||
parse/render, key adoption, per-node DDNS name).
|
||||
- WireGuard defaults fixed 10.100.0.0/24->10.10.0.0/24 (br-lxc
|
||||
collision), 51820->51822. Role-aware addressing (.1 master).
|
||||
- sbx-mesh-up: root provisioner (adopt live key -> guard -> render ->
|
||||
wg-quick up); the service user cannot run wg-quick.
|
||||
- Depends: wireguard-tools. Ships /etc/secubox/p2p.toml.example.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 14:00:00 +0200
|
||||
|
||||
secubox-p2p (1.7.5-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* sbx-mesh-invite: re-own the master-link token store to secubox when run as
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Standards-Version: 4.6.2
|
|||
|
||||
Package: secubox-p2p
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, avahi-daemon, avahi-utils
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, avahi-daemon, avahi-utils, wireguard-tools
|
||||
Breaks: secubox-master-link (<< 1.1)
|
||||
Replaces: secubox-master-link (<< 1.1)
|
||||
Description: SecuBox P2P - Peer-to-Peer Network Hub
|
||||
|
|
|
|||
|
|
@ -33,5 +33,12 @@ override_dh_auto_install:
|
|||
install -m 755 $(CURDIR)/scripts/sbx-mesh-join $(CURDIR)/debian/secubox-p2p/usr/bin/
|
||||
install -m 755 $(CURDIR)/scripts/sbx-mesh-invite $(CURDIR)/debian/secubox-p2p/usr/bin/
|
||||
|
||||
# Install p2p.toml example
|
||||
install -d $(CURDIR)/debian/secubox-p2p/etc/secubox
|
||||
install -m 644 $(CURDIR)/conf/p2p.toml.example $(CURDIR)/debian/secubox-p2p/etc/secubox/
|
||||
|
||||
# Install root mesh provisioner CLI
|
||||
install -m 755 $(CURDIR)/scripts/sbx-mesh-up $(CURDIR)/debian/secubox-p2p/usr/bin/
|
||||
|
||||
# Create runtime directory
|
||||
install -d $(CURDIR)/debian/secubox-p2p/run/secubox
|
||||
|
|
|
|||
41
packages/secubox-p2p/scripts/sbx-mesh-up
Executable file
41
packages/secubox-p2p/scripts/sbx-mesh-up
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# SecuBox-Deb :: secubox-p2p :: sbx-mesh-up
|
||||
# Root provisioner: adopt existing key -> collision guard -> render -> up.
|
||||
# The secubox-p2p service runs as user `secubox` and cannot do this.
|
||||
set -euo pipefail
|
||||
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||
|
||||
STATE=/var/lib/secubox/p2p/wg_mesh.json
|
||||
CONF=/etc/wireguard/wg-mesh.conf
|
||||
|
||||
python3 - "$STATE" "$CONF" <<'PY'
|
||||
import json, sys, pathlib
|
||||
sys.path.insert(0, "/usr/lib/secubox/p2p")
|
||||
from api import mesh
|
||||
|
||||
state_path, conf_path = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
|
||||
state = json.loads(state_path.read_text()) if state_path.exists() else {"peers": []}
|
||||
|
||||
# Adopt the live key if state has none (preserves the gk2<->c3box handshake).
|
||||
existing = conf_path.read_text() if conf_path.exists() else None
|
||||
state = mesh.adopt_state(state, existing)
|
||||
|
||||
net = state.get("network", mesh.MESH_NETWORK)
|
||||
bad = mesh.subnet_overlap(net)
|
||||
if bad:
|
||||
sys.exit(f"REFUSING: mesh network {net} overlaps reserved subnet {bad!r}")
|
||||
|
||||
if not state.get("private_key"):
|
||||
sys.exit("no private key in state and none to adopt; run /wireguard/init first")
|
||||
|
||||
conf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conf_path.write_text(mesh.render_wg_conf(state))
|
||||
conf_path.chmod(0o600)
|
||||
state_path.write_text(json.dumps(state, indent=2))
|
||||
print(f"rendered {conf_path} (addr {state.get('address')}, peers {len(state.get('peers', []))})")
|
||||
PY
|
||||
|
||||
wg-quick down wg-mesh 2>/dev/null || true
|
||||
wg-quick up wg-mesh
|
||||
wg show wg-mesh
|
||||
0
packages/secubox-p2p/tests/__init__.py
Normal file
0
packages/secubox-p2p/tests/__init__.py
Normal file
5
packages/secubox-p2p/tests/conftest.py
Normal file
5
packages/secubox-p2p/tests/conftest.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Ensures `from api import mesh` resolves from the package root during tests.
|
||||
import sys, pathlib
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
147
packages/secubox-p2p/tests/test_mesh.py
Normal file
147
packages/secubox-p2p/tests/test_mesh.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
import pytest
|
||||
from api import mesh
|
||||
|
||||
|
||||
def test_mesh_defaults():
|
||||
assert mesh.MESH_NETWORK == "10.10.0.0/24"
|
||||
assert mesh.MESH_PORT == 51822
|
||||
assert mesh.MESH_INTERFACE == "wg-mesh"
|
||||
|
||||
|
||||
def test_subnet_overlap_detects_br_lxc():
|
||||
assert mesh.subnet_overlap("10.100.0.0/24") == "br-lxc"
|
||||
|
||||
|
||||
def test_subnet_overlap_detects_partial_supernet():
|
||||
# a /16 that contains br-lxc must also be rejected
|
||||
assert mesh.subnet_overlap("10.100.0.0/16") == "br-lxc"
|
||||
|
||||
|
||||
def test_subnet_overlap_clean_mesh_subnet():
|
||||
assert mesh.subnet_overlap("10.10.0.0/24") is None
|
||||
|
||||
|
||||
def test_load_p2p_config_defaults_when_missing(tmp_path):
|
||||
cfg = mesh.load_p2p_config(tmp_path / "nope.toml")
|
||||
assert cfg["network"] == "10.10.0.0/24"
|
||||
assert cfg["listen_port"] == 51822
|
||||
assert cfg["interface"] == "wg-mesh"
|
||||
assert cfg["role"] == "satellite"
|
||||
assert cfg["master_endpoint"] is None
|
||||
|
||||
|
||||
def test_load_p2p_config_reads_wireguard_section(tmp_path):
|
||||
p = tmp_path / "p2p.toml"
|
||||
p.write_text(
|
||||
"[wireguard]\n"
|
||||
'role = "master"\n'
|
||||
'listen_port = 51822\n'
|
||||
'network = "10.10.0.0/24"\n'
|
||||
'master_endpoint = "82.67.100.75:51822"\n'
|
||||
)
|
||||
cfg = mesh.load_p2p_config(p)
|
||||
assert cfg["role"] == "master"
|
||||
assert cfg["master_endpoint"] == "82.67.100.75:51822"
|
||||
|
||||
|
||||
def test_allocate_mesh_ip_first_free_is_2():
|
||||
assert mesh.allocate_mesh_ip("10.10.0.0/24", []) == "10.10.0.2"
|
||||
|
||||
|
||||
def test_allocate_mesh_ip_skips_taken_with_or_without_mask():
|
||||
got = mesh.allocate_mesh_ip("10.10.0.0/24", ["10.10.0.2/24", "10.10.0.3"])
|
||||
assert got == "10.10.0.4"
|
||||
|
||||
|
||||
def test_allocate_mesh_ip_exhausted_raises():
|
||||
taken = [f"10.10.0.{n}" for n in range(2, 255)]
|
||||
with pytest.raises(RuntimeError):
|
||||
mesh.allocate_mesh_ip("10.10.0.0/24", taken)
|
||||
|
||||
|
||||
def test_parse_wg_conf_extracts_interface_fields():
|
||||
text = (
|
||||
"[Interface]\n"
|
||||
"PrivateKey = ABC123=\n"
|
||||
"Address = 10.10.0.1/24\n"
|
||||
"ListenPort = 51822\n"
|
||||
"[Peer]\nPublicKey = X=\n"
|
||||
)
|
||||
got = mesh.parse_wg_conf(text)
|
||||
assert got == {"private_key": "ABC123=", "address": "10.10.0.1/24", "listen_port": 51822}
|
||||
|
||||
|
||||
def test_render_wg_conf_master_with_roaming_peer():
|
||||
state = {
|
||||
"private_key": "PRIV=",
|
||||
"address": "10.10.0.1/24",
|
||||
"listen_port": 51822,
|
||||
"peers": [{"public_key": "PUB2=", "allowed_ips": "10.10.0.2/32"}],
|
||||
}
|
||||
out = mesh.render_wg_conf(state)
|
||||
assert "PrivateKey = PRIV=" in out
|
||||
assert "ListenPort = 51822" in out
|
||||
assert "AllowedIPs = 10.10.0.2/32" in out
|
||||
assert "Endpoint" not in out # roaming peer => no Endpoint line
|
||||
|
||||
|
||||
def test_render_wg_conf_satellite_with_endpoint_and_keepalive():
|
||||
state = {
|
||||
"private_key": "PRIV=", "address": "10.10.0.3/24", "listen_port": 51822,
|
||||
"peers": [{"public_key": "GK2=", "endpoint": "82.67.100.75:51822", "allowed_ips": "10.10.0.0/24"}],
|
||||
}
|
||||
out = mesh.render_wg_conf(state)
|
||||
assert "Endpoint = 82.67.100.75:51822" in out
|
||||
assert "PersistentKeepalive = 25" in out
|
||||
|
||||
|
||||
def test_ddns_name_basic():
|
||||
assert mesh.ddns_name("gk2") == "gk2.secubox.in"
|
||||
|
||||
|
||||
def test_ddns_name_sanitizes():
|
||||
assert mesh.ddns_name("Secubox_Live!") == "secubox-live-.secubox.in"
|
||||
|
||||
|
||||
def test_adopt_state_imports_existing_key_when_absent():
|
||||
state = {"private_key": None, "peers": []}
|
||||
conf = "[Interface]\nPrivateKey = LIVEKEY=\nAddress = 10.10.0.1/24\nListenPort = 51822\n"
|
||||
out = mesh.adopt_state(state, conf)
|
||||
assert out["private_key"] == "LIVEKEY="
|
||||
assert out["address"] == "10.10.0.1/24"
|
||||
assert out["listen_port"] == 51822
|
||||
|
||||
|
||||
def test_adopt_state_never_overwrites_existing_key():
|
||||
state = {"private_key": "KEEP=", "peers": []}
|
||||
conf = "[Interface]\nPrivateKey = OTHER=\n"
|
||||
out = mesh.adopt_state(state, conf)
|
||||
assert out["private_key"] == "KEEP="
|
||||
|
||||
|
||||
def test_ddns_name_empty_falls_back():
|
||||
assert mesh.ddns_name("") == "node.secubox.in"
|
||||
|
||||
|
||||
def test_host_ip_only_from_slash32():
|
||||
assert mesh._host_ip("10.10.0.2/32") == "10.10.0.2"
|
||||
assert mesh._host_ip("10.10.0.0/24") == ""
|
||||
assert mesh._host_ip("") == ""
|
||||
|
||||
|
||||
def test_peer_nodes_uses_name_and_mesh_ip():
|
||||
state = {"peers": [
|
||||
{"public_key": "AAA=", "name": "c3box", "mesh_ip": "10.10.0.2", "allowed_ips": "10.10.0.2/32"},
|
||||
{"public_key": "BBB=", "allowed_ips": "10.10.0.3/32"}, # no name/mesh_ip -> derive ip
|
||||
{"public_key": "CCC=", "name": "gk2", "mesh_ip": "10.10.0.1", "allowed_ips": "10.10.0.0/24"},
|
||||
]}
|
||||
nodes = mesh.peer_nodes(state)
|
||||
assert [n["name"] for n in nodes] == ["c3box", "10.10.0.3", "gk2"]
|
||||
assert [n["address"] for n in nodes] == ["10.10.0.2", "10.10.0.3", "10.10.0.1"]
|
||||
assert all(n["status"] == "online" for n in nodes)
|
||||
|
||||
|
||||
def test_peer_nodes_empty():
|
||||
assert mesh.peer_nodes({"peers": []}) == []
|
||||
|
|
@ -737,7 +737,7 @@
|
|||
document.getElementById('status-value').className = 'value ' + (status.online ? 'online' : 'offline');
|
||||
document.getElementById('peer-count').textContent = status.total_peers || 0;
|
||||
document.getElementById('active-peer-count').textContent = status.active_peers || 0;
|
||||
document.getElementById('service-count').textContent = status.services || 0;
|
||||
document.getElementById('service-count').textContent = status.service_count || status.services || 0;
|
||||
}
|
||||
|
||||
const activity = await apiGet('/activity');
|
||||
|
|
@ -758,9 +758,10 @@
|
|||
|
||||
// Load Peers
|
||||
async function loadPeers() {
|
||||
const peers = await apiGet('/peers');
|
||||
const data = await apiGet('/peers');
|
||||
const peers = Array.isArray(data) ? data : (data && Array.isArray(data.peers) ? data.peers : []);
|
||||
const tbody = document.getElementById('peers-table');
|
||||
if (peers && peers.length > 0) {
|
||||
if (peers.length > 0) {
|
||||
tbody.innerHTML = peers.map(peer => `
|
||||
<tr>
|
||||
<td>${escapeHtml(peer.id)}</td>
|
||||
|
|
@ -781,9 +782,10 @@
|
|||
|
||||
// Load Services
|
||||
async function loadServices() {
|
||||
const services = await apiGet('/services');
|
||||
const data = await apiGet('/services');
|
||||
const services = Array.isArray(data) ? data : (data && Array.isArray(data.services) ? data.services : []);
|
||||
const tbody = document.getElementById('services-table');
|
||||
if (services && services.length > 0) {
|
||||
if (services.length > 0) {
|
||||
tbody.innerHTML = services.map(svc => `
|
||||
<tr>
|
||||
<td>${escapeHtml(svc.name)}</td>
|
||||
|
|
@ -803,9 +805,18 @@
|
|||
|
||||
// Load Threats
|
||||
async function loadThreats() {
|
||||
const threats = await apiGet('/threats');
|
||||
const data = await apiGet('/threats');
|
||||
let threats = [];
|
||||
if (Array.isArray(data)) threats = data;
|
||||
else if (data && typeof data === 'object') threats = Object.entries(data).map(([ip, t]) => ({
|
||||
type: (t && t.type) || 'threat',
|
||||
severity: (t && t.severity) || 'info',
|
||||
source: (t && t.source) || ip,
|
||||
target: (t && t.target) || ip,
|
||||
description: (t && (t.description || t.reason)) || 'shared via mesh'
|
||||
}));
|
||||
const container = document.getElementById('threats-list');
|
||||
if (threats && threats.length > 0) {
|
||||
if (threats.length > 0) {
|
||||
container.innerHTML = threats.map(threat => `
|
||||
<div class="threat-item ${threat.severity}">
|
||||
<div class="threat-header">
|
||||
|
|
@ -824,48 +835,50 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Mesh Visualization
|
||||
function initMesh() {
|
||||
// Mesh Visualization (wired to /mesh — real nodes from the wg-mesh)
|
||||
async function initMesh() {
|
||||
const canvas = document.getElementById('mesh-canvas');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Set canvas size
|
||||
canvas.width = canvas.parentElement.clientWidth;
|
||||
canvas.height = canvas.parentElement.clientHeight;
|
||||
|
||||
// Light theme colors
|
||||
const bgColor = '#f1f8f2';
|
||||
const nodeColor = '#006622';
|
||||
const lineColor = '#a7c4a0';
|
||||
const textColor = '#1b4332';
|
||||
|
||||
// Draw placeholder mesh
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.fillStyle = nodeColor;
|
||||
ctx.font = '14px Courier New';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
// Center node
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
|
||||
drawNode(ctx, centerX, centerY, 'LOCAL', true, nodeColor, textColor);
|
||||
const data = await apiGet('/mesh');
|
||||
const allNodes = (data && Array.isArray(data.nodes)) ? data.nodes : [];
|
||||
const local = allNodes.find(n => n.is_local) || { name: 'LOCAL' };
|
||||
const peers = allNodes.filter(n => !n.is_local);
|
||||
|
||||
// Placeholder peer nodes
|
||||
const peers = [
|
||||
{ x: centerX - 150, y: centerY - 100, name: 'Peer 1' },
|
||||
{ x: centerX + 150, y: centerY - 100, name: 'Peer 2' },
|
||||
{ x: centerX - 150, y: centerY + 100, name: 'Peer 3' },
|
||||
{ x: centerX + 150, y: centerY + 100, name: 'Peer 4' }
|
||||
];
|
||||
|
||||
peers.forEach(peer => {
|
||||
drawConnection(ctx, centerX, centerY, peer.x, peer.y, lineColor);
|
||||
drawNode(ctx, peer.x, peer.y, peer.name, false, nodeColor, textColor);
|
||||
const radius = Math.max(60, Math.min(centerX, centerY) - 60);
|
||||
peers.forEach((peer, i) => {
|
||||
const angle = (Math.PI * 2 * i) / Math.max(peers.length, 1) - Math.PI / 2;
|
||||
const px = centerX + radius * Math.cos(angle);
|
||||
const py = centerY + radius * Math.sin(angle);
|
||||
drawConnection(ctx, centerX, centerY, px, py, lineColor);
|
||||
const label = (peer.name || peer.address || peer.id || 'peer') +
|
||||
(peer.address && peer.name !== peer.address ? ' (' + peer.address + ')' : '');
|
||||
drawNode(ctx, px, py, label, false, nodeColor, textColor);
|
||||
});
|
||||
|
||||
const localLabel = (local.name || 'LOCAL') + (local.address ? ' (' + local.address + ')' : '');
|
||||
drawNode(ctx, centerX, centerY, localLabel, true, nodeColor, textColor);
|
||||
|
||||
if (peers.length === 0) {
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.fillText('No mesh peers', centerX, centerY + 70);
|
||||
}
|
||||
}
|
||||
|
||||
function drawNode(ctx, x, y, label, isCenter, nodeColor, textColor) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,21 @@
|
|||
secubox-portal (2.2.2-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(ui): 'Active bans' now reflects the live /crowdsec/decisions list
|
||||
(includes CAPI community blocklist) instead of the local-only
|
||||
health/summary.active_decisions; hero 'Bans active' kept consistent.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 21:00:00 +0200
|
||||
|
||||
secubox-portal (2.2.1-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(ui): reskin the portal to the standard SecuBox template — shared
|
||||
sidebar (nav#sidebar + sidebar.js) replacing the bespoke top navbar,
|
||||
design-tokens + crt-light C3BOX palette replacing the P31 green theme,
|
||||
main.main layout + crt-engine. Dashboard content, element IDs and the
|
||||
data-fetch JS are unchanged.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 20:30:00 +0200
|
||||
|
||||
secubox-portal (2.2.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* portal: regenerate /portal/index.html as a public-facing operational
|
||||
|
|
|
|||
|
|
@ -5,74 +5,12 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SecuBox · Portal</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/shared/design-tokens.css">
|
||||
<link rel="stylesheet" href="/shared/crt-light.css">
|
||||
<style>
|
||||
:root {
|
||||
--p31-peak: #00dd44;
|
||||
--p31-hot: #00ff55;
|
||||
--p31-mid: #009933;
|
||||
--p31-dim: #006622;
|
||||
--p31-ghost: #003311;
|
||||
--p31-decay: #ffb347;
|
||||
--p31-decay-dim: #cc7722;
|
||||
--tube-light: #e8f5e9;
|
||||
--tube-pale: #c8e6c9;
|
||||
--tube-soft: #a5d6a7;
|
||||
--tube-dark: #1b1b1f;
|
||||
--primary: var(--p31-peak);
|
||||
--cyan: #00d4ff;
|
||||
--red: #ff4466;
|
||||
--yellow: var(--p31-decay);
|
||||
--bloom-text: 0 0 2px var(--p31-peak), 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
|
||||
--bloom-amber: 0 0 3px var(--p31-decay), 0 0 10px rgba(255,179,71,0.4);
|
||||
--bloom-red: 0 0 3px var(--red), 0 0 10px rgba(255,68,102,0.4);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Courier Prime', 'Courier New', monospace;
|
||||
background: var(--tube-light);
|
||||
background-image: radial-gradient(ellipse at 50% 40%, rgba(51,255,102,0.025) 0%, transparent 70%);
|
||||
color: var(--tube-dark);
|
||||
min-height: 100vh;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,221,68,0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,221,68,0.02) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
pointer-events: none; z-index: -1;
|
||||
}
|
||||
a { color: var(--p31-mid); text-decoration: none; }
|
||||
a:hover { color: var(--p31-peak); text-shadow: var(--bloom-text); }
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
background: var(--tube-dark);
|
||||
color: var(--p31-peak);
|
||||
padding: 0.75rem 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
border-bottom: 1px solid var(--p31-mid);
|
||||
}
|
||||
.navbar .brand {
|
||||
font-weight: bold; font-size: 1.1rem; letter-spacing: 1px;
|
||||
text-shadow: var(--bloom-text);
|
||||
}
|
||||
.navbar .brand a { color: var(--p31-peak); }
|
||||
.navbar .nav-links { display: flex; gap: 1.5rem; flex-wrap: wrap; }
|
||||
.navbar .nav-links a {
|
||||
color: var(--p31-mid); font-size: 0.9rem;
|
||||
padding: 0.25rem 0.5rem; border-radius: 3px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.navbar .nav-links a:hover { color: var(--p31-peak); background: rgba(0,221,68,0.08); text-shadow: var(--bloom-text); }
|
||||
.navbar .nav-meta { font-size: 0.75rem; color: var(--p31-dim); }
|
||||
a { color: var(--cyber-cyan, #00d4ff); text-decoration: none; }
|
||||
a:hover { color: var(--gold-hermetic, #c9a84c); }
|
||||
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
|
||||
|
||||
|
|
@ -80,10 +18,9 @@
|
|||
.hero {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem 2rem;
|
||||
background: var(--tube-pale);
|
||||
border: 1px solid var(--p31-mid);
|
||||
background: var(--panel, #13131c);
|
||||
border: 1px solid var(--line, #2a2a3a);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 12px rgba(0,221,68,0.15);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
|
|
@ -95,16 +32,16 @@
|
|||
}
|
||||
.hero-score .num {
|
||||
font-size: 3.5rem; font-weight: bold; line-height: 1;
|
||||
color: var(--p31-peak); text-shadow: var(--bloom-text);
|
||||
color: var(--matrix-green, #00ff41);
|
||||
}
|
||||
.hero-score .label { font-size: 0.8rem; color: var(--p31-dim); margin-top: 0.25rem; letter-spacing: 1px; }
|
||||
.hero-score.warn .num { color: var(--yellow); text-shadow: var(--bloom-amber); }
|
||||
.hero-score.crit .num { color: var(--red); text-shadow: var(--bloom-red); }
|
||||
.hero-score .label { font-size: 0.8rem; color: var(--text-muted, #6b6b7a); margin-top: 0.25rem; letter-spacing: 1px; }
|
||||
.hero-score.warn .num { color: var(--gold-hermetic, #c9a84c); }
|
||||
.hero-score.crit .num { color: var(--cinnabar, #e63946); }
|
||||
.hero-info { font-size: 0.95rem; line-height: 1.6; }
|
||||
.hero-info .kv { display: flex; gap: 1.5rem; flex-wrap: wrap; }
|
||||
.hero-info .k { color: var(--p31-dim); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.hero-info .v { color: var(--tube-dark); font-weight: bold; }
|
||||
.hero-refresh { font-size: 0.75rem; color: var(--p31-dim); text-align: right; }
|
||||
.hero-info .k { color: var(--text-muted, #6b6b7a); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.hero-info .v { color: var(--text, #e8e6d9); font-weight: bold; }
|
||||
.hero-refresh { font-size: 0.75rem; color: var(--text-muted, #6b6b7a); text-align: right; }
|
||||
|
||||
/* Section grid */
|
||||
.grid {
|
||||
|
|
@ -114,101 +51,87 @@
|
|||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--tube-light);
|
||||
border: 1px solid var(--p31-mid);
|
||||
background: var(--panel, #13131c);
|
||||
border: 1px solid var(--line, #2a2a3a);
|
||||
border-radius: 4px;
|
||||
padding: 1.25rem;
|
||||
position: relative;
|
||||
box-shadow: 0 0 8px rgba(0,221,68,0.08);
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 0.85rem; text-transform: uppercase; letter-spacing: 2px;
|
||||
color: var(--p31-mid); margin-bottom: 1rem;
|
||||
color: var(--gold-hermetic, #c9a84c); margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px dashed var(--p31-ghost);
|
||||
text-shadow: var(--bloom-text);
|
||||
border-bottom: 1px dashed var(--line, #2a2a3a);
|
||||
}
|
||||
.card .row { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 0.9rem; }
|
||||
.card .row .k { color: var(--p31-dim); }
|
||||
.card .row .v { color: var(--tube-dark); font-weight: bold; }
|
||||
.card .row .v.ok { color: var(--p31-peak); text-shadow: var(--bloom-text); }
|
||||
.card .row .v.warn { color: var(--yellow); text-shadow: var(--bloom-amber); }
|
||||
.card .row .v.crit { color: var(--red); text-shadow: var(--bloom-red); }
|
||||
.empty { color: var(--p31-dim); font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; }
|
||||
.card .row .k { color: var(--text-muted, #6b6b7a); }
|
||||
.card .row .v { color: var(--text, #e8e6d9); font-weight: bold; }
|
||||
.card .row .v.ok { color: var(--matrix-green, #00ff41); }
|
||||
.card .row .v.warn { color: var(--gold-hermetic, #c9a84c); }
|
||||
.card .row .v.crit { color: var(--cinnabar, #e63946); }
|
||||
.empty { color: var(--text-muted, #6b6b7a); font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; }
|
||||
|
||||
/* Bar */
|
||||
.bar {
|
||||
height: 4px; background: var(--p31-ghost); border-radius: 2px;
|
||||
height: 4px; background: var(--cosmos-black, #0a0a0f); border-radius: 2px;
|
||||
overflow: hidden; margin: 0.25rem 0;
|
||||
}
|
||||
.bar > span { display: block; height: 100%; background: var(--p31-peak); }
|
||||
.bar.warn > span { background: var(--yellow); }
|
||||
.bar.crit > span { background: var(--red); }
|
||||
.bar > span { display: block; height: 100%; background: var(--matrix-green, #00ff41); }
|
||||
.bar.warn > span { background: var(--gold-hermetic, #c9a84c); }
|
||||
.bar.crit > span { background: var(--cinnabar, #e63946); }
|
||||
|
||||
/* Modules LED grid */
|
||||
.modules { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; }
|
||||
.module {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0.25rem; padding: 0.5rem; border-radius: 4px;
|
||||
background: var(--tube-pale); border: 1px solid var(--p31-ghost);
|
||||
background: var(--cosmos-black, #0a0a0f); border: 1px solid var(--line, #2a2a3a);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.module .led {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--p31-dim);
|
||||
background: var(--text-muted, #6b6b7a);
|
||||
}
|
||||
.module.ok .led { background: var(--p31-peak); box-shadow: 0 0 8px var(--p31-peak); }
|
||||
.module.warn .led { background: var(--yellow); box-shadow: 0 0 8px var(--yellow); }
|
||||
.module.err .led { background: var(--red); box-shadow: 0 0 8px var(--red); }
|
||||
.module .name { font-size: 0.65rem; text-transform: uppercase; color: var(--p31-dim); }
|
||||
.module.ok .led { background: var(--matrix-green, #00ff41); box-shadow: 0 0 6px var(--matrix-green, #00ff41); }
|
||||
.module.warn .led { background: var(--gold-hermetic, #c9a84c); box-shadow: 0 0 6px var(--gold-hermetic, #c9a84c); }
|
||||
.module.err .led { background: var(--cinnabar, #e63946); box-shadow: 0 0 6px var(--cinnabar, #e63946); }
|
||||
.module .name { font-size: 0.65rem; text-transform: uppercase; color: var(--text-muted, #6b6b7a); }
|
||||
|
||||
/* Tables — vhosts / ASNs */
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { text-align: left; padding: 0.25rem 0.5rem; }
|
||||
th { font-size: 0.7rem; color: var(--p31-dim); text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px dashed var(--p31-ghost); }
|
||||
td.num { text-align: right; color: var(--cyan); font-variant-numeric: tabular-nums; }
|
||||
tr:hover td { background: rgba(0,221,68,0.04); }
|
||||
th { font-size: 0.7rem; color: var(--text-muted, #6b6b7a); text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px dashed var(--line, #2a2a3a); }
|
||||
td.num { text-align: right; color: var(--cyber-cyan, #00d4ff); font-variant-numeric: tabular-nums; }
|
||||
tr:hover td { background: rgba(201,168,76,0.05); }
|
||||
|
||||
/* Big number tiles */
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; }
|
||||
.tile {
|
||||
background: var(--tube-pale); border: 1px solid var(--p31-ghost);
|
||||
background: var(--cosmos-black, #0a0a0f); border: 1px solid var(--line, #2a2a3a);
|
||||
border-radius: 4px; padding: 0.75rem; text-align: center;
|
||||
}
|
||||
.tile .v { font-size: 1.75rem; font-weight: bold; color: var(--p31-peak); text-shadow: var(--bloom-text); }
|
||||
.tile .v.warn { color: var(--yellow); text-shadow: var(--bloom-amber); }
|
||||
.tile .v.crit { color: var(--red); text-shadow: var(--bloom-red); }
|
||||
.tile .l { font-size: 0.7rem; text-transform: uppercase; color: var(--p31-dim); letter-spacing: 1px; }
|
||||
.tile .v { font-size: 1.75rem; font-weight: bold; color: var(--matrix-green, #00ff41); }
|
||||
.tile .v.warn { color: var(--gold-hermetic, #c9a84c); }
|
||||
.tile .v.crit { color: var(--cinnabar, #e63946); }
|
||||
.tile .l { font-size: 0.7rem; text-transform: uppercase; color: var(--text-muted, #6b6b7a); letter-spacing: 1px; }
|
||||
|
||||
.footer {
|
||||
margin-top: 3rem; padding-top: 1.5rem;
|
||||
border-top: 1px dashed var(--p31-ghost);
|
||||
text-align: center; font-size: 0.8rem; color: var(--p31-dim);
|
||||
border-top: 1px dashed var(--line, #2a2a3a);
|
||||
text-align: center; font-size: 0.8rem; color: var(--text-muted, #6b6b7a);
|
||||
}
|
||||
.footer a { color: var(--p31-mid); }
|
||||
.footer a { color: var(--cyber-cyan, #00d4ff); }
|
||||
@media (max-width: 768px) {
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.modules { grid-template-columns: repeat(3, 1fr); }
|
||||
.navbar { padding: 0.75rem 1rem; flex-direction: column; gap: 0.5rem; }
|
||||
.navbar .nav-links { font-size: 0.8rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar">
|
||||
<div class="brand"><a href="/portal/">🛡️ SecuBox</a></div>
|
||||
<div class="nav-links">
|
||||
<a href="/portal/">PORTAL</a>
|
||||
<a href="/soc/">SOC</a>
|
||||
<a href="/metablogizer/">METABLOGS</a>
|
||||
<a href="/publish/">PUBLISH</a>
|
||||
<a href="/cookies/">COOKIES</a>
|
||||
<a href="https://github.com/CyberMind-FR/secubox-deb" rel="noopener">REPO</a>
|
||||
</div>
|
||||
<div class="nav-meta" id="nav-hostname">—</div>
|
||||
</nav>
|
||||
<body class="crt-light">
|
||||
<nav class="sidebar" id="sidebar"></nav>
|
||||
<script src="/shared/sidebar.js"></script>
|
||||
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
|
||||
<!-- Hero -->
|
||||
|
|
@ -295,7 +218,9 @@
|
|||
</footer>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/shared/crt-engine.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
|
@ -430,7 +355,12 @@
|
|||
}
|
||||
|
||||
function renderBans(h, decisions) {
|
||||
const ban = h && h.crowdsec && h.crowdsec.active_decisions;
|
||||
// Prefer the live /crowdsec/decisions list (includes CAPI community
|
||||
// blocklist), falling back to health/summary's local-only count.
|
||||
const decList = decisions && Array.isArray(decisions.decisions) ? decisions.decisions
|
||||
: (Array.isArray(decisions) ? decisions : null);
|
||||
const ban = decList ? decList.filter(d => (d.type || 'ban') === 'ban').length
|
||||
: (h && h.crowdsec ? h.crowdsec.active_decisions : null);
|
||||
const alerts = h && h.crowdsec && h.crowdsec.alerts_today;
|
||||
const wafPct = h && h.waf && h.waf.blocked_pct;
|
||||
const wafCls = wafPct == null ? '' : (wafPct >= 25 ? 'crit' : wafPct >= 10 ? 'warn' : 'ok');
|
||||
|
|
@ -440,6 +370,8 @@
|
|||
$('t-waf').textContent = wafPct != null ? wafPct + '%' : '—';
|
||||
$('t-waf').className = 'v ' + wafCls;
|
||||
$('t-alerts').textContent = alerts != null ? alerts : '—';
|
||||
// Keep the hero "Bans active" consistent with the real blocked-IP count.
|
||||
if (ban != null && $('hero-bans')) $('hero-bans').textContent = ban;
|
||||
}
|
||||
|
||||
function renderVhosts(d) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user