Compare commits

...

14 Commits

Author SHA1 Message Date
a1ec2601c8 fix(p2p): render P2P web UI — API-shape handling + live mesh graph
Some checks are pending
License Headers / check (push) Waiting to run
JS read /peers and /threats as arrays but the API returns {peers,count} /
a dict -> nothing rendered. Handle both shapes; loadOverview uses
service_count. Mesh canvas was a hardcoded placeholder; now wired to GET
/mesh. /mesh derives nodes/links from wg_mesh.json (local + wg peers).
Bumps secubox-p2p 1.7.7 -> 1.7.8.
2026-06-29 13:03:03 +02:00
a0f9c7811f fix(p2p): P2P web UI empty — surface the wg-mesh in /peers + /status
The dashboard read the unused legacy peers.json; the live mesh lives in
wg_mesh.json (Phase 1). Add mesh.peer_nodes() and drive /peers + /status
from it; /status now returns total_peers/active_peers (the fields the web
UI actually reads). Per-peer name + mesh_ip give friendly node labels.
Bumps secubox-p2p 1.7.6 -> 1.7.7.
2026-06-29 12:45:09 +02:00
b8fce891de Merge branch 'feature/gondwana-phase1-mesh' — Gondwana Phase 1 mesh substrate
secubox-p2p becomes the single WireGuard mesh owner: subnet collision fixed
(10.100.0.0/24 -> 10.10.0.0/24), port 51822, pure api/mesh.py (collision
guard, p2p.toml loader, master-assigned IP, wg.conf parse/render, key
adoption, DDNS name), root sbx-mesh-up provisioner, secubox-p2p 1.7.6.
Live: gk2 (.1 master, key-preserving state sync, no bounce) + c3box (.2) +
amd64 (.3) — full 3-node mesh verified alive.
2026-06-29 12:19:05 +02:00
7effe5fb1a fix(p2p): delegate provisioning to sbx-mesh-up, mesh_ip on manual approve, top import, SPDX/test polish
- enable_wireguard: remove inline wg-quick + wg.conf render (KeyError on
  roaming peers without endpoint, fails as secubox user); now only sets
  enabled=true and returns a message directing root to run sbx-mesh-up
- _assign_mesh_ip helper DRYs allocation; ml_approve now also allocates
  mesh_ip for manually-approved peers (was silently None before)
- `from . import mesh` moved from mid-file WireGuard section to top import block
- SPDX headers added to tests/conftest.py and tests/test_mesh.py
- `import pytest` moved to top-level in test_mesh.py; redundant sys.path.insert removed
- ddns_name: slug[:63] RFC label cap + empty-string falls back to "node";
  test_ddns_name_empty_falls_back added (17 tests, all green)
2026-06-29 11:59:52 +02:00
b080612396 build(p2p): ship p2p.toml.example + sbx-mesh-up, dep wireguard-tools, 1.7.6 2026-06-29 11:46:04 +02:00
96c048860d fix(p2p): drop unused subprocess import + PKG var in sbx-mesh-up, assert listen_port
Cleanups in secubox-p2p (Task 7 follow-up):
- Remove unused 'subprocess' import from sbx-mesh-up heredoc
- Remove unused 'PKG' shell variable (path hardcoded in sys.path.insert)
- Add listen_port assertion in test_adopt_state_imports_existing_key_when_absent
2026-06-29 11:44:25 +02:00
7e75efffd2 feat(p2p): root sbx-mesh-up provisioner (adopt key, guard, render, up) 2026-06-29 11:42:10 +02:00
910b87fd3a fix(p2p): drop colliding 10.100.0.x fallbacks from enable_wireguard
Add address guard (400 if /wireguard/init never called), use
config['address'] directly (no fallback), replace hardcoded peer
AllowedIPs fallback '10.100.0.0/24' with mesh.MESH_NETWORK constant.
Collision guard now covers all code paths.
2026-06-29 11:40:04 +02:00
a3ec30ed96 feat(p2p): adopt mesh.py — 10.10.0.0/24:51822, role-aware addressing, collision guard 2026-06-29 11:35:37 +02:00
8ef46e086b feat(p2p): per-node DDNS identity name helper 2026-06-29 11:31:36 +02:00
a949b2e495 feat(p2p): parse/render wg-mesh.conf (key adoption + provisioning) 2026-06-29 11:29:33 +02:00
6a662a165c feat(p2p): master-assigned mesh IP allocation (.2+, .1=master) 2026-06-29 11:26:34 +02:00
29897b40bc feat(p2p): /etc/secubox/p2p.toml [wireguard] loader + example 2026-06-29 11:23:54 +02:00
d70db5ea7e feat(p2p): mesh module with subnet collision guard 2026-06-29 11:20:55 +02:00
11 changed files with 560 additions and 96 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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