mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-30 05:27:07 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e4d431bd9 | |||
| 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 |
|
|
@ -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).
|
||||||
File diff suppressed because it is too large
Load Diff
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>
|
||||||
|
|
@ -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
|
secubox-lyrion (1.1.0-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
* /lyrion/ on the canonical hub vhost is now a SecuBox admin webui,
|
* /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.
|
# systemd nftables.service loads everything in that directory.
|
||||||
ensure_slimproto_dnat() {
|
ensure_slimproto_dnat() {
|
||||||
local nft_file="/etc/nftables.d/secubox-lyrion-dnat.nft"
|
local nft_file="/etc/nftables.d/secubox-lyrion-dnat.nft"
|
||||||
local lan_iface="${SECUBOX_LAN_IFACE:-lan0}"
|
# The DNAT must catch players arriving on whatever interface faces the
|
||||||
log "Ensuring slimproto DNAT ${lan_iface}:3483 → ${LXC_IP}:3483 ..."
|
# 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
|
install -d -m 0755 /etc/nftables.d
|
||||||
cat > "$nft_file" <<NFT
|
cat > "$nft_file" <<NFT
|
||||||
# /etc/nftables.d/secubox-lyrion-dnat.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
|
# 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).
|
# Generated by secubox-lyrion install-lxc.sh (#248).
|
||||||
|
|
||||||
table inet secubox-lyrion {
|
table inet secubox-lyrion {
|
||||||
chain prerouting {
|
chain prerouting {
|
||||||
type nat hook prerouting priority dstnat; policy accept;
|
type nat hook prerouting priority dstnat; policy accept;
|
||||||
iifname "${lan_iface}" tcp dport 3483 dnat ip to ${LXC_IP}:3483 comment "lyrion-slimproto-tcp"
|
${match} 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} udp dport 3483 dnat ip to ${LXC_IP}:3483 comment "lyrion-slimproto-udp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NFT
|
NFT
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,17 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
SecuBox-Deb :: LiveHosts aggregator
|
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
|
Counts requests per vhost over the last `window_minutes` by reading the
|
||||||
hostnames being served.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -15,22 +23,25 @@ import asyncio
|
||||||
import collections
|
import collections
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger("secubox.live_hosts")
|
log = logging.getLogger("secubox.live_hosts")
|
||||||
|
|
||||||
CACHE_PATH = Path("/var/cache/secubox/metrics/live-hosts.json")
|
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:
|
class LiveHostsAggregator:
|
||||||
def __init__(self, cfg: dict):
|
def __init__(self, cfg: dict):
|
||||||
self.cfg = cfg
|
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._payload: dict = {"enabled": False, "entries": []}
|
||||||
self._refreshed = False
|
self._refreshed = False
|
||||||
|
|
||||||
|
|
@ -44,13 +55,14 @@ class LiveHostsAggregator:
|
||||||
return json.loads(CACHE_PATH.read_text())
|
return json.loads(CACHE_PATH.read_text())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
async def run_forever(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
self._payload = await self.refresh_once()
|
self._payload = await self.refresh_once()
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
log.warning("refresh_once raised: %s", e)
|
log.warning("refresh_once raised: %s", e)
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
@ -58,18 +70,15 @@ class LiveHostsAggregator:
|
||||||
if not self.cfg.get("enabled"):
|
if not self.cfg.get("enabled"):
|
||||||
self._refreshed = True
|
self._refreshed = True
|
||||||
return self._disabled_payload()
|
return self._disabled_payload()
|
||||||
totals = await asyncio.to_thread(self._read_haproxy_stats)
|
counts, total = await asyncio.to_thread(self._read_nginx_hosts)
|
||||||
if totals is None:
|
top_n = int(self.cfg.get("top_n", 5))
|
||||||
self._refreshed = True
|
entries = [{"host": h, "count": c} for h, c in counts.most_common(top_n) if c > 0]
|
||||||
return self._disabled_payload()
|
|
||||||
kept = self._filter_frontends(totals)
|
|
||||||
self._delta_and_buffer(kept)
|
|
||||||
entries = self._aggregate()
|
|
||||||
payload = {
|
payload = {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"window_minutes": self.cfg["window_minutes"],
|
"window_minutes": int(self.cfg.get("window_minutes", 60)),
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
|
"total_requests": total,
|
||||||
}
|
}
|
||||||
self._persist(payload)
|
self._persist(payload)
|
||||||
self._refreshed = True
|
self._refreshed = True
|
||||||
|
|
@ -77,91 +86,46 @@ class LiveHostsAggregator:
|
||||||
|
|
||||||
# -- helpers --------------------------------------------
|
# -- helpers --------------------------------------------
|
||||||
|
|
||||||
def _filter_frontends(self, totals: dict[str, int]) -> dict[str, int]:
|
def _read_nginx_hosts(self) -> tuple[collections.Counter, int]:
|
||||||
flt = self.cfg.get("frontend_filter", "*")
|
log_dir = Path(self.cfg.get("log_dir", DEFAULT_LOG_DIR))
|
||||||
out: dict[str, int] = {}
|
window = int(self.cfg.get("window_minutes", 60))
|
||||||
for name, n in totals.items():
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=window)
|
||||||
if name.startswith("_"):
|
counts: collections.Counter = collections.Counter()
|
||||||
continue
|
total = 0
|
||||||
if "." not in name:
|
|
||||||
continue
|
|
||||||
if flt != "*" and flt not in name:
|
|
||||||
continue
|
|
||||||
out[name] = n
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _delta_and_buffer(self, totals: dict[str, int]) -> None:
|
|
||||||
if not self._prev_totals:
|
|
||||||
self._buckets.append({k: 0 for k in totals})
|
|
||||||
self._prev_totals = dict(totals)
|
|
||||||
return
|
|
||||||
bucket: dict[str, int] = {}
|
|
||||||
for host, cur in totals.items():
|
|
||||||
prev = self._prev_totals.get(host)
|
|
||||||
if prev is None or cur < prev:
|
|
||||||
bucket[host] = 0
|
|
||||||
else:
|
|
||||||
bucket[host] = cur - prev
|
|
||||||
self._buckets.append(bucket)
|
|
||||||
self._prev_totals = dict(totals)
|
|
||||||
|
|
||||||
def _aggregate(self) -> list[dict]:
|
|
||||||
totals: dict[str, int] = collections.Counter()
|
|
||||||
for bucket in self._buckets:
|
|
||||||
for host, n in bucket.items():
|
|
||||||
totals[host] += n
|
|
||||||
entries = [{"host": h, "count": c} for h, c in totals.items() if c > 0]
|
|
||||||
entries.sort(key=lambda e: (-e["count"], e["host"]))
|
|
||||||
return entries[: self.cfg["top_n"]]
|
|
||||||
|
|
||||||
def _read_haproxy_stats(self) -> Optional[dict[str, int]]:
|
|
||||||
sock_path = self.cfg["haproxy_socket"]
|
|
||||||
if not Path(sock_path).exists():
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
files = sorted(log_dir.glob("*_access.log"))
|
||||||
s.settimeout(2.0)
|
except Exception as e: # noqa: BLE001
|
||||||
s.connect(sock_path)
|
log.warning("cannot list %s: %s", log_dir, e)
|
||||||
s.sendall(b"show stat\n")
|
return counts, 0
|
||||||
chunks = []
|
for f in files:
|
||||||
while True:
|
|
||||||
data = s.recv(8192)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
chunks.append(data)
|
|
||||||
blob = b"".join(chunks).decode("utf-8", errors="replace")
|
|
||||||
except Exception as e:
|
|
||||||
log.warning("haproxy socket read failed: %s", e)
|
|
||||||
return None
|
|
||||||
return self._parse_show_stat(blob)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_show_stat(blob: str) -> dict[str, int]:
|
|
||||||
"""Extract {frontend_name: req_tot} from `show stat` CSV output."""
|
|
||||||
out: dict[str, int] = {}
|
|
||||||
lines = blob.splitlines()
|
|
||||||
if not lines:
|
|
||||||
return out
|
|
||||||
header = lines[0].lstrip("# ").split(",")
|
|
||||||
try:
|
|
||||||
pxname_i = header.index("pxname")
|
|
||||||
svname_i = header.index("svname")
|
|
||||||
req_tot_i = header.index("req_tot")
|
|
||||||
except ValueError:
|
|
||||||
return out
|
|
||||||
for line in lines[1:]:
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
cols = line.split(",")
|
|
||||||
if len(cols) <= max(pxname_i, svname_i, req_tot_i):
|
|
||||||
continue
|
|
||||||
if cols[svname_i] != "FRONTEND":
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
out[cols[pxname_i]] = int(cols[req_tot_i] or "0")
|
size = f.stat().st_size
|
||||||
except ValueError:
|
if size == 0:
|
||||||
|
continue
|
||||||
|
host = f.name[: -len("_access.log")]
|
||||||
|
with open(f, "rb") as fh:
|
||||||
|
if size > TAIL_BYTES:
|
||||||
|
fh.seek(size - TAIL_BYTES)
|
||||||
|
fh.readline() # drop the partial first line
|
||||||
|
blob = fh.read().decode("utf-8", errors="replace")
|
||||||
|
except PermissionError:
|
||||||
|
log.warning("no read permission on %s (add service user to 'adm')", f)
|
||||||
continue
|
continue
|
||||||
return out
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.warning("read %s failed: %s", f, e)
|
||||||
|
continue
|
||||||
|
for line in blob.splitlines():
|
||||||
|
m = TS_RE.search(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ts = datetime.strptime(m.group(1), TS_FMT)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if ts >= cutoff:
|
||||||
|
counts[host] += 1
|
||||||
|
total += 1
|
||||||
|
return counts, total
|
||||||
|
|
||||||
def _persist(self, payload: dict) -> None:
|
def _persist(self, payload: dict) -> None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -169,13 +133,14 @@ class LiveHostsAggregator:
|
||||||
tmp = CACHE_PATH.with_suffix(".json.tmp")
|
tmp = CACHE_PATH.with_suffix(".json.tmp")
|
||||||
tmp.write_text(json.dumps(payload))
|
tmp.write_text(json.dumps(payload))
|
||||||
tmp.replace(CACHE_PATH)
|
tmp.replace(CACHE_PATH)
|
||||||
except Exception as e:
|
except Exception as e: # noqa: BLE001
|
||||||
log.warning("persist failed: %s", e)
|
log.warning("persist failed: %s", e)
|
||||||
|
|
||||||
def _disabled_payload(self) -> dict:
|
def _disabled_payload(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"window_minutes": self.cfg["window_minutes"],
|
"window_minutes": int(self.cfg.get("window_minutes", 60)),
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"entries": [],
|
"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
|
# 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:
|
def build_health_summary() -> dict:
|
||||||
"""Build aggregated health summary for the health banner."""
|
"""Build aggregated health summary for the health banner."""
|
||||||
|
|
||||||
|
|
@ -626,16 +658,16 @@ def build_health_summary() -> dict:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
blocked_pct = 0
|
||||||
|
waf_blocks_1h = _count_waf_blocks(60)
|
||||||
try:
|
try:
|
||||||
waf_log = Path('/var/log/mitmproxy/threats.jsonl')
|
lh = live_hosts_agg.current()
|
||||||
if waf_log.exists():
|
total_req = int(lh.get("total_requests", 0)) if isinstance(lh, dict) else 0
|
||||||
# Count threats in last hour
|
denom = total_req + waf_blocks_1h
|
||||||
result = run_cmd(['wc', '-l', str(waf_log)])
|
if denom > 0:
|
||||||
threat_count = int(result.split()[0]) if result else 0
|
blocked_pct = round(waf_blocks_1h / denom * 100, 1)
|
||||||
# Rough estimate: 1000 requests/hour baseline
|
|
||||||
blocked_pct = min(100, threat_count // 10)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -683,6 +715,7 @@ def build_health_summary() -> dict:
|
||||||
"modules": modules,
|
"modules": modules,
|
||||||
"waf": {
|
"waf": {
|
||||||
"blocked_pct": blocked_pct,
|
"blocked_pct": blocked_pct,
|
||||||
|
"blocks_1h": waf_blocks_1h,
|
||||||
"active": waf_stats.get("mitmproxy_running", False)
|
"active": waf_stats.get("mitmproxy_running", False)
|
||||||
},
|
},
|
||||||
"crowdsec": {
|
"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
|
secubox-metrics (1.0.4-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
* #494: postinst no longer chowns the shared /run/secubox parent
|
* #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)
|
# Add secubox to haproxy group for admin-socket access (issue #92)
|
||||||
if getent group haproxy >/dev/null; then
|
if getent group haproxy >/dev/null; then
|
||||||
usermod -aG haproxy secubox || true
|
usermod -aG haproxy secubox || true
|
||||||
|
# adm group: read /var/log/nginx for per-vhost live-hosts metric
|
||||||
|
usermod -aG adm secubox || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Persistent cache dir for live-panel rollups
|
# Persistent cache dir for live-panel rollups
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ except ImportError:
|
||||||
async def require_jwt():
|
async def require_jwt():
|
||||||
return {"sub": "admin"}
|
return {"sub": "admin"}
|
||||||
|
|
||||||
|
from . import mesh
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="SecuBox P2P API",
|
title="SecuBox P2P API",
|
||||||
description="P2P network hub with peer discovery and master-link enrollment",
|
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]
|
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"]
|
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
|
# Get master-link status
|
||||||
ml_config = get_ml_config()
|
ml_config = get_ml_config()
|
||||||
|
|
||||||
|
|
@ -643,8 +649,10 @@ async def get_status():
|
||||||
"hostname": get_hostname(),
|
"hostname": get_hostname(),
|
||||||
"lan_ip": get_lan_ip(),
|
"lan_ip": get_lan_ip(),
|
||||||
"wan_ip": get_wan_ip(),
|
"wan_ip": get_wan_ip(),
|
||||||
"peer_count": len(peers),
|
"peer_count": wg_peer_count or len(peers),
|
||||||
"online_peers": len(online_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,
|
"service_count": len(services) if isinstance(services, list) else 0,
|
||||||
"threat_count": len(threats) if isinstance(threats, dict) else 0,
|
"threat_count": len(threats) if isinstance(threats, dict) else 0,
|
||||||
"master_link": {
|
"master_link": {
|
||||||
|
|
@ -698,18 +706,22 @@ async def get_self():
|
||||||
|
|
||||||
@app.get("/peers")
|
@app.get("/peers")
|
||||||
async def list_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()
|
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_data = load_json(PEERS_FILE, {"peers": []})
|
||||||
peers = peers_data.get("peers", []) if isinstance(peers_data, dict) else peers_data
|
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.
|
# 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()
|
local_id = get_node_id()
|
||||||
peers = [p for p in peers if not p.get("is_local") and p.get("id") != local_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)}
|
return {"peers": peers, "count": len(peers)}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -859,15 +871,39 @@ async def unregister_service(name: str, user: dict = Depends(require_jwt)):
|
||||||
|
|
||||||
@app.get("/mesh")
|
@app.get("/mesh")
|
||||||
async def get_mesh_status():
|
async def get_mesh_status():
|
||||||
"""Get mesh network topology (public read)."""
|
"""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
|
|
||||||
|
|
||||||
nodes = []
|
Derived from the wg-mesh transport (wg_mesh.json) that p2p owns
|
||||||
links = []
|
(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()
|
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:
|
for peer in peers:
|
||||||
node = {
|
node = {
|
||||||
"id": peer.get('id', ''),
|
"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
|
"is_local": peer.get('is_local', False) or peer.get('id') == local_id
|
||||||
}
|
}
|
||||||
nodes.append(node)
|
nodes.append(node)
|
||||||
|
|
||||||
# Create links from local node to all peers
|
|
||||||
if not node["is_local"]:
|
if not node["is_local"]:
|
||||||
links.append({
|
links.append({"source": local_id, "target": peer.get('id'), "status": peer.get('status', 'unknown')})
|
||||||
"source": local_id,
|
return {"nodes": nodes, "links": links, "total_nodes": len(nodes), "local_node": 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 ==============
|
# ============== Profiles ==============
|
||||||
|
|
@ -975,9 +999,9 @@ async def remove_threat(ip: str, user: dict = Depends(require_jwt)):
|
||||||
# ============== WireGuard Mesh ==============
|
# ============== WireGuard Mesh ==============
|
||||||
|
|
||||||
WG_MESH_CONFIG = P2P_DIR / "wg_mesh.json"
|
WG_MESH_CONFIG = P2P_DIR / "wg_mesh.json"
|
||||||
WG_INTERFACE = "wg-mesh"
|
WG_INTERFACE = mesh.MESH_INTERFACE
|
||||||
WG_PORT = 51820
|
WG_PORT = mesh.MESH_PORT
|
||||||
WG_NETWORK = "10.100.0.0/24"
|
WG_NETWORK = mesh.MESH_NETWORK
|
||||||
|
|
||||||
|
|
||||||
def get_wg_mesh_config() -> Dict:
|
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["private_key"] = private_key
|
||||||
config["public_key"] = public_key
|
config["public_key"] = public_key
|
||||||
|
|
||||||
# Assign IP from network (based on node ID)
|
# Assign mesh IP: .1 for the master role, else allocate from the pool.
|
||||||
node_id = get_node_id()
|
p2p_cfg = mesh.load_p2p_config(CONFIG_FILE)
|
||||||
ip_suffix = int(hashlib.md5(node_id.encode()).hexdigest()[:2], 16) % 253 + 1
|
if p2p_cfg["role"] == "master":
|
||||||
network_prefix = WG_NETWORK.rsplit('.', 2)[0]
|
addr = "10.10.0.1"
|
||||||
config["address"] = f"{network_prefix}.{ip_suffix}/24"
|
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)
|
save_json(WG_MESH_CONFIG, config)
|
||||||
|
|
||||||
|
|
@ -1075,7 +1104,7 @@ async def init_wireguard(user: dict = Depends(require_jwt)):
|
||||||
async def add_wireguard_peer(
|
async def add_wireguard_peer(
|
||||||
public_key: str,
|
public_key: str,
|
||||||
endpoint: str,
|
endpoint: str,
|
||||||
allowed_ips: str = "10.100.0.0/24",
|
allowed_ips: str = "10.10.0.0/24",
|
||||||
user: dict = Depends(require_jwt)
|
user: dict = Depends(require_jwt)
|
||||||
):
|
):
|
||||||
"""Add a WireGuard mesh peer."""
|
"""Add a WireGuard mesh peer."""
|
||||||
|
|
@ -1107,40 +1136,21 @@ async def enable_wireguard(user: dict = Depends(require_jwt)):
|
||||||
if not config.get("private_key"):
|
if not config.get("private_key"):
|
||||||
raise HTTPException(status_code=400, detail="WireGuard not initialized")
|
raise HTTPException(status_code=400, detail="WireGuard not initialized")
|
||||||
|
|
||||||
# Create interface config
|
bad = mesh.subnet_overlap(config.get("network", WG_NETWORK))
|
||||||
wg_conf = f"""[Interface]
|
if bad:
|
||||||
PrivateKey = {config['private_key']}
|
raise HTTPException(status_code=409,
|
||||||
Address = {config.get('address', '10.100.0.1/24')}
|
detail=f"mesh network overlaps reserved subnet {bad!r}; refusing")
|
||||||
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
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Write config and bring up interface
|
if not config.get("address"):
|
||||||
conf_path = Path(f"/etc/wireguard/{WG_INTERFACE}.conf")
|
raise HTTPException(status_code=400,
|
||||||
conf_path.parent.mkdir(parents=True, exist_ok=True)
|
detail="WireGuard not initialized (no address); run /wireguard/init first")
|
||||||
conf_path.write_text(wg_conf)
|
|
||||||
conf_path.chmod(0o600)
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.run(["wg-quick", "down", WG_INTERFACE], capture_output=True, timeout=10)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
result = subprocess.run(["wg-quick", "up", WG_INTERFACE], capture_output=True, text=True, timeout=10)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to start WireGuard: {result.stderr}")
|
|
||||||
|
|
||||||
|
# Provisioning (wg-quick, /etc/wireguard write) is delegated to the root
|
||||||
|
# CLI sbx-mesh-up; this unprivileged endpoint only marks the intent.
|
||||||
config["enabled"] = True
|
config["enabled"] = True
|
||||||
save_json(WG_MESH_CONFIG, config)
|
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 ==============
|
# ============== Remote Announcers ==============
|
||||||
|
|
@ -1744,6 +1754,7 @@ async def ml_join(req: JoinRequest, request: Request):
|
||||||
join_request["approved_at"] = now.isoformat()
|
join_request["approved_at"] = now.isoformat()
|
||||||
join_request["approved_by"] = config.get("fingerprint", get_node_id())
|
join_request["approved_by"] = config.get("fingerprint", get_node_id())
|
||||||
join_request["depth"] = peer_depth
|
join_request["depth"] = peer_depth
|
||||||
|
_assign_mesh_ip(join_request)
|
||||||
|
|
||||||
# Mark token as used
|
# Mark token as used
|
||||||
ml_token_mark_used(token_hash, req.fingerprint)
|
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):
|
def _add_approved_peer(join_request: Dict):
|
||||||
"""Add approved peer to peer list."""
|
"""Add approved peer to peer list."""
|
||||||
peers_data = load_json(PEERS_FILE, {"peers": []})
|
peers_data = load_json(PEERS_FILE, {"peers": []})
|
||||||
|
|
@ -1790,6 +1807,7 @@ def _add_approved_peer(join_request: Dict):
|
||||||
"fingerprint": join_request["fingerprint"],
|
"fingerprint": join_request["fingerprint"],
|
||||||
"name": join_request.get("hostname", "Peer"),
|
"name": join_request.get("hostname", "Peer"),
|
||||||
"address": join_request.get("address"),
|
"address": join_request.get("address"),
|
||||||
|
"mesh_ip": join_request.get("mesh_ip"),
|
||||||
"depth": join_request.get("depth", 1),
|
"depth": join_request.get("depth", 1),
|
||||||
"role": join_request.get("role", "peer"),
|
"role": join_request.get("role", "peer"),
|
||||||
"added": datetime.utcnow().isoformat(),
|
"added": datetime.utcnow().isoformat(),
|
||||||
|
|
@ -1830,6 +1848,10 @@ async def ml_approve(req: ApproveRequest, user: dict = Depends(require_jwt)):
|
||||||
if token_hash:
|
if token_hash:
|
||||||
ml_token_mark_used(token_hash, req.fingerprint)
|
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 to peers
|
||||||
_add_approved_peer(join_request)
|
_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
|
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
|
* 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
|
Package: secubox-p2p
|
||||||
Architecture: all
|
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)
|
Breaks: secubox-master-link (<< 1.1)
|
||||||
Replaces: secubox-master-link (<< 1.1)
|
Replaces: secubox-master-link (<< 1.1)
|
||||||
Description: SecuBox P2P - Peer-to-Peer Network Hub
|
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-join $(CURDIR)/debian/secubox-p2p/usr/bin/
|
||||||
install -m 755 $(CURDIR)/scripts/sbx-mesh-invite $(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
|
# Create runtime directory
|
||||||
install -d $(CURDIR)/debian/secubox-p2p/run/secubox
|
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('status-value').className = 'value ' + (status.online ? 'online' : 'offline');
|
||||||
document.getElementById('peer-count').textContent = status.total_peers || 0;
|
document.getElementById('peer-count').textContent = status.total_peers || 0;
|
||||||
document.getElementById('active-peer-count').textContent = status.active_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');
|
const activity = await apiGet('/activity');
|
||||||
|
|
@ -758,9 +758,10 @@
|
||||||
|
|
||||||
// Load Peers
|
// Load Peers
|
||||||
async function loadPeers() {
|
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');
|
const tbody = document.getElementById('peers-table');
|
||||||
if (peers && peers.length > 0) {
|
if (peers.length > 0) {
|
||||||
tbody.innerHTML = peers.map(peer => `
|
tbody.innerHTML = peers.map(peer => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(peer.id)}</td>
|
<td>${escapeHtml(peer.id)}</td>
|
||||||
|
|
@ -781,9 +782,10 @@
|
||||||
|
|
||||||
// Load Services
|
// Load Services
|
||||||
async function loadServices() {
|
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');
|
const tbody = document.getElementById('services-table');
|
||||||
if (services && services.length > 0) {
|
if (services.length > 0) {
|
||||||
tbody.innerHTML = services.map(svc => `
|
tbody.innerHTML = services.map(svc => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(svc.name)}</td>
|
<td>${escapeHtml(svc.name)}</td>
|
||||||
|
|
@ -803,9 +805,18 @@
|
||||||
|
|
||||||
// Load Threats
|
// Load Threats
|
||||||
async function loadThreats() {
|
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');
|
const container = document.getElementById('threats-list');
|
||||||
if (threats && threats.length > 0) {
|
if (threats.length > 0) {
|
||||||
container.innerHTML = threats.map(threat => `
|
container.innerHTML = threats.map(threat => `
|
||||||
<div class="threat-item ${threat.severity}">
|
<div class="threat-item ${threat.severity}">
|
||||||
<div class="threat-header">
|
<div class="threat-header">
|
||||||
|
|
@ -824,48 +835,50 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mesh Visualization
|
// Mesh Visualization (wired to /mesh — real nodes from the wg-mesh)
|
||||||
function initMesh() {
|
async function initMesh() {
|
||||||
const canvas = document.getElementById('mesh-canvas');
|
const canvas = document.getElementById('mesh-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
// Set canvas size
|
|
||||||
canvas.width = canvas.parentElement.clientWidth;
|
canvas.width = canvas.parentElement.clientWidth;
|
||||||
canvas.height = canvas.parentElement.clientHeight;
|
canvas.height = canvas.parentElement.clientHeight;
|
||||||
|
|
||||||
// Light theme colors
|
|
||||||
const bgColor = '#f1f8f2';
|
const bgColor = '#f1f8f2';
|
||||||
const nodeColor = '#006622';
|
const nodeColor = '#006622';
|
||||||
const lineColor = '#a7c4a0';
|
const lineColor = '#a7c4a0';
|
||||||
const textColor = '#1b4332';
|
const textColor = '#1b4332';
|
||||||
|
|
||||||
// Draw placeholder mesh
|
|
||||||
ctx.fillStyle = bgColor;
|
ctx.fillStyle = bgColor;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
ctx.strokeStyle = lineColor;
|
|
||||||
ctx.fillStyle = nodeColor;
|
|
||||||
ctx.font = '14px Courier New';
|
ctx.font = '14px Courier New';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
|
|
||||||
// Center node
|
|
||||||
const centerX = canvas.width / 2;
|
const centerX = canvas.width / 2;
|
||||||
const centerY = canvas.height / 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 radius = Math.max(60, Math.min(centerX, centerY) - 60);
|
||||||
const peers = [
|
peers.forEach((peer, i) => {
|
||||||
{ x: centerX - 150, y: centerY - 100, name: 'Peer 1' },
|
const angle = (Math.PI * 2 * i) / Math.max(peers.length, 1) - Math.PI / 2;
|
||||||
{ x: centerX + 150, y: centerY - 100, name: 'Peer 2' },
|
const px = centerX + radius * Math.cos(angle);
|
||||||
{ x: centerX - 150, y: centerY + 100, name: 'Peer 3' },
|
const py = centerY + radius * Math.sin(angle);
|
||||||
{ x: centerX + 150, y: centerY + 100, name: 'Peer 4' }
|
drawConnection(ctx, centerX, centerY, px, py, lineColor);
|
||||||
];
|
const label = (peer.name || peer.address || peer.id || 'peer') +
|
||||||
|
(peer.address && peer.name !== peer.address ? ' (' + peer.address + ')' : '');
|
||||||
peers.forEach(peer => {
|
drawNode(ctx, px, py, label, false, nodeColor, textColor);
|
||||||
drawConnection(ctx, centerX, centerY, peer.x, peer.y, lineColor);
|
|
||||||
drawNode(ctx, peer.x, peer.y, peer.name, false, nodeColor, textColor);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const localLabel = (local.name || 'LOCAL') + (local.address ? ' (' + local.address + ')' : '');
|
||||||
|
drawNode(ctx, centerX, centerY, localLabel, true, nodeColor, textColor);
|
||||||
|
|
||||||
|
if (peers.length === 0) {
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.fillText('No mesh peers', centerX, centerY + 70);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawNode(ctx, x, y, label, isCenter, nodeColor, textColor) {
|
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
|
secubox-portal (2.2.0-1~bookworm1) bookworm; urgency=medium
|
||||||
|
|
||||||
* portal: regenerate /portal/index.html as a public-facing operational
|
* 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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SecuBox · Portal</title>
|
<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="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 rel="stylesheet" href="/shared/design-tokens.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/shared/crt-light.css">
|
<link rel="stylesheet" href="/shared/crt-light.css">
|
||||||
<style>
|
<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; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
a { color: var(--cyber-cyan, #00d4ff); text-decoration: none; }
|
||||||
font-family: 'Courier Prime', 'Courier New', monospace;
|
a:hover { color: var(--gold-hermetic, #c9a84c); }
|
||||||
background: var(--tube-light);
|
|
||||||
background-image: radial-gradient(ellipse at 50% 40%, rgba(51,255,102,0.025) 0%, transparent 70%);
|
|
||||||
color: var(--tube-dark);
|
|
||||||
min-height: 100vh;
|
|
||||||
padding-bottom: 3rem;
|
|
||||||
}
|
|
||||||
body::before {
|
|
||||||
content: "";
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(rgba(0,221,68,0.02) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(0,221,68,0.02) 1px, transparent 1px);
|
|
||||||
background-size: 50px 50px;
|
|
||||||
pointer-events: none; z-index: -1;
|
|
||||||
}
|
|
||||||
a { color: var(--p31-mid); text-decoration: none; }
|
|
||||||
a:hover { color: var(--p31-peak); text-shadow: var(--bloom-text); }
|
|
||||||
|
|
||||||
/* Navbar */
|
|
||||||
.navbar {
|
|
||||||
background: var(--tube-dark);
|
|
||||||
color: var(--p31-peak);
|
|
||||||
padding: 0.75rem 2rem;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
position: sticky; top: 0; z-index: 50;
|
|
||||||
border-bottom: 1px solid var(--p31-mid);
|
|
||||||
}
|
|
||||||
.navbar .brand {
|
|
||||||
font-weight: bold; font-size: 1.1rem; letter-spacing: 1px;
|
|
||||||
text-shadow: var(--bloom-text);
|
|
||||||
}
|
|
||||||
.navbar .brand a { color: var(--p31-peak); }
|
|
||||||
.navbar .nav-links { display: flex; gap: 1.5rem; flex-wrap: wrap; }
|
|
||||||
.navbar .nav-links a {
|
|
||||||
color: var(--p31-mid); font-size: 0.9rem;
|
|
||||||
padding: 0.25rem 0.5rem; border-radius: 3px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.navbar .nav-links a:hover { color: var(--p31-peak); background: rgba(0,221,68,0.08); text-shadow: var(--bloom-text); }
|
|
||||||
.navbar .nav-meta { font-size: 0.75rem; color: var(--p31-dim); }
|
|
||||||
|
|
||||||
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
|
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
|
||||||
|
|
||||||
|
|
@ -80,10 +18,9 @@
|
||||||
.hero {
|
.hero {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
background: var(--tube-pale);
|
background: var(--panel, #13131c);
|
||||||
border: 1px solid var(--p31-mid);
|
border: 1px solid var(--line, #2a2a3a);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 0 12px rgba(0,221,68,0.15);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -95,16 +32,16 @@
|
||||||
}
|
}
|
||||||
.hero-score .num {
|
.hero-score .num {
|
||||||
font-size: 3.5rem; font-weight: bold; line-height: 1;
|
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 .label { font-size: 0.8rem; color: var(--text-muted, #6b6b7a); margin-top: 0.25rem; letter-spacing: 1px; }
|
||||||
.hero-score.warn .num { color: var(--yellow); text-shadow: var(--bloom-amber); }
|
.hero-score.warn .num { color: var(--gold-hermetic, #c9a84c); }
|
||||||
.hero-score.crit .num { color: var(--red); text-shadow: var(--bloom-red); }
|
.hero-score.crit .num { color: var(--cinnabar, #e63946); }
|
||||||
.hero-info { font-size: 0.95rem; line-height: 1.6; }
|
.hero-info { font-size: 0.95rem; line-height: 1.6; }
|
||||||
.hero-info .kv { display: flex; gap: 1.5rem; flex-wrap: wrap; }
|
.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 .k { color: var(--text-muted, #6b6b7a); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; }
|
||||||
.hero-info .v { color: var(--tube-dark); font-weight: bold; }
|
.hero-info .v { color: var(--text, #e8e6d9); font-weight: bold; }
|
||||||
.hero-refresh { font-size: 0.75rem; color: var(--p31-dim); text-align: right; }
|
.hero-refresh { font-size: 0.75rem; color: var(--text-muted, #6b6b7a); text-align: right; }
|
||||||
|
|
||||||
/* Section grid */
|
/* Section grid */
|
||||||
.grid {
|
.grid {
|
||||||
|
|
@ -114,101 +51,87 @@
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
background: var(--tube-light);
|
background: var(--panel, #13131c);
|
||||||
border: 1px solid var(--p31-mid);
|
border: 1px solid var(--line, #2a2a3a);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 0 8px rgba(0,221,68,0.08);
|
|
||||||
}
|
}
|
||||||
.card h3 {
|
.card h3 {
|
||||||
font-size: 0.85rem; text-transform: uppercase; letter-spacing: 2px;
|
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;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 1px dashed var(--p31-ghost);
|
border-bottom: 1px dashed var(--line, #2a2a3a);
|
||||||
text-shadow: var(--bloom-text);
|
|
||||||
}
|
}
|
||||||
.card .row { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 0.9rem; }
|
.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 .k { color: var(--text-muted, #6b6b7a); }
|
||||||
.card .row .v { color: var(--tube-dark); font-weight: bold; }
|
.card .row .v { color: var(--text, #e8e6d9); font-weight: bold; }
|
||||||
.card .row .v.ok { color: var(--p31-peak); text-shadow: var(--bloom-text); }
|
.card .row .v.ok { color: var(--matrix-green, #00ff41); }
|
||||||
.card .row .v.warn { color: var(--yellow); text-shadow: var(--bloom-amber); }
|
.card .row .v.warn { color: var(--gold-hermetic, #c9a84c); }
|
||||||
.card .row .v.crit { color: var(--red); text-shadow: var(--bloom-red); }
|
.card .row .v.crit { color: var(--cinnabar, #e63946); }
|
||||||
.empty { color: var(--p31-dim); font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; }
|
.empty { color: var(--text-muted, #6b6b7a); font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; }
|
||||||
|
|
||||||
/* Bar */
|
/* Bar */
|
||||||
.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;
|
overflow: hidden; margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
.bar > span { display: block; height: 100%; background: var(--p31-peak); }
|
.bar > span { display: block; height: 100%; background: var(--matrix-green, #00ff41); }
|
||||||
.bar.warn > span { background: var(--yellow); }
|
.bar.warn > span { background: var(--gold-hermetic, #c9a84c); }
|
||||||
.bar.crit > span { background: var(--red); }
|
.bar.crit > span { background: var(--cinnabar, #e63946); }
|
||||||
|
|
||||||
/* Modules LED grid */
|
/* Modules LED grid */
|
||||||
.modules { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; }
|
.modules { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; }
|
||||||
.module {
|
.module {
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
gap: 0.25rem; padding: 0.5rem; border-radius: 4px;
|
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;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
.module .led {
|
.module .led {
|
||||||
width: 10px; height: 10px; border-radius: 50%;
|
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.ok .led { background: var(--matrix-green, #00ff41); box-shadow: 0 0 6px var(--matrix-green, #00ff41); }
|
||||||
.module.warn .led { background: var(--yellow); box-shadow: 0 0 8px var(--yellow); }
|
.module.warn .led { background: var(--gold-hermetic, #c9a84c); box-shadow: 0 0 6px var(--gold-hermetic, #c9a84c); }
|
||||||
.module.err .led { background: var(--red); box-shadow: 0 0 8px var(--red); }
|
.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(--p31-dim); }
|
.module .name { font-size: 0.65rem; text-transform: uppercase; color: var(--text-muted, #6b6b7a); }
|
||||||
|
|
||||||
/* Tables — vhosts / ASNs */
|
/* Tables — vhosts / ASNs */
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
th, td { text-align: left; padding: 0.25rem 0.5rem; }
|
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); }
|
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(--cyan); font-variant-numeric: tabular-nums; }
|
td.num { text-align: right; color: var(--cyber-cyan, #00d4ff); font-variant-numeric: tabular-nums; }
|
||||||
tr:hover td { background: rgba(0,221,68,0.04); }
|
tr:hover td { background: rgba(201,168,76,0.05); }
|
||||||
|
|
||||||
/* Big number tiles */
|
/* Big number tiles */
|
||||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; }
|
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; }
|
||||||
.tile {
|
.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;
|
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 { font-size: 1.75rem; font-weight: bold; color: var(--matrix-green, #00ff41); }
|
||||||
.tile .v.warn { color: var(--yellow); text-shadow: var(--bloom-amber); }
|
.tile .v.warn { color: var(--gold-hermetic, #c9a84c); }
|
||||||
.tile .v.crit { color: var(--red); text-shadow: var(--bloom-red); }
|
.tile .v.crit { color: var(--cinnabar, #e63946); }
|
||||||
.tile .l { font-size: 0.7rem; text-transform: uppercase; color: var(--p31-dim); letter-spacing: 1px; }
|
.tile .l { font-size: 0.7rem; text-transform: uppercase; color: var(--text-muted, #6b6b7a); letter-spacing: 1px; }
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 3rem; padding-top: 1.5rem;
|
margin-top: 3rem; padding-top: 1.5rem;
|
||||||
border-top: 1px dashed var(--p31-ghost);
|
border-top: 1px dashed var(--line, #2a2a3a);
|
||||||
text-align: center; font-size: 0.8rem; color: var(--p31-dim);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.hero { grid-template-columns: 1fr; }
|
.hero { grid-template-columns: 1fr; }
|
||||||
.modules { grid-template-columns: repeat(3, 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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="crt-light">
|
||||||
|
<nav class="sidebar" id="sidebar"></nav>
|
||||||
<nav class="navbar">
|
<script src="/shared/sidebar.js"></script>
|
||||||
<div class="brand"><a href="/portal/">🛡️ SecuBox</a></div>
|
|
||||||
<div class="nav-links">
|
|
||||||
<a href="/portal/">PORTAL</a>
|
|
||||||
<a href="/soc/">SOC</a>
|
|
||||||
<a href="/metablogizer/">METABLOGS</a>
|
|
||||||
<a href="/publish/">PUBLISH</a>
|
|
||||||
<a href="/cookies/">COOKIES</a>
|
|
||||||
<a href="https://github.com/CyberMind-FR/secubox-deb" rel="noopener">REPO</a>
|
|
||||||
</div>
|
|
||||||
<div class="nav-meta" id="nav-hostname">—</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
|
|
@ -295,7 +218,9 @@
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/shared/crt-engine.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
@ -430,7 +355,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBans(h, decisions) {
|
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 alerts = h && h.crowdsec && h.crowdsec.alerts_today;
|
||||||
const wafPct = h && h.waf && h.waf.blocked_pct;
|
const wafPct = h && h.waf && h.waf.blocked_pct;
|
||||||
const wafCls = wafPct == null ? '' : (wafPct >= 25 ? 'crit' : wafPct >= 10 ? 'warn' : 'ok');
|
const wafCls = wafPct == null ? '' : (wafPct >= 25 ? 'crit' : wafPct >= 10 ? 'warn' : 'ok');
|
||||||
|
|
@ -440,6 +370,8 @@
|
||||||
$('t-waf').textContent = wafPct != null ? wafPct + '%' : '—';
|
$('t-waf').textContent = wafPct != null ? wafPct + '%' : '—';
|
||||||
$('t-waf').className = 'v ' + wafCls;
|
$('t-waf').className = 'v ' + wafCls;
|
||||||
$('t-alerts').textContent = alerts != null ? alerts : '—';
|
$('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) {
|
function renderVhosts(d) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user