mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 18:06:21 +00:00
Compare commits
14 Commits
7206350c34
...
a1ec2601c8
| Author | SHA1 | Date | |
|---|---|---|---|
| a1ec2601c8 | |||
| a0f9c7811f | |||
| b8fce891de | |||
| 7effe5fb1a | |||
| b080612396 | |||
| 96c048860d | |||
| 7e75efffd2 | |||
| 910b87fd3a | |||
| a3ec30ed96 | |||
| 8ef46e086b | |||
| a949b2e495 | |||
| 6a662a165c | |||
| 29897b40bc | |||
| d70db5ea7e |
|
|
@ -26,6 +26,8 @@ except ImportError:
|
|||
async def require_jwt():
|
||||
return {"sub": "admin"}
|
||||
|
||||
from . import mesh
|
||||
|
||||
app = FastAPI(
|
||||
title="SecuBox P2P API",
|
||||
description="P2P network hub with peer discovery and master-link enrollment",
|
||||
|
|
@ -634,6 +636,10 @@ async def get_status():
|
|||
peers = [p for p in peers if not p.get("is_local") and p.get("id") != local_id]
|
||||
online_peers = [p for p in peers if p.get("status") == "online"]
|
||||
|
||||
# Mesh view is driven by the wg-mesh transport (wg_mesh.json), which p2p
|
||||
# now owns (Gondwana Phase 1). The web UI reads total_peers/active_peers.
|
||||
wg_peer_count = len(mesh.peer_nodes(get_wg_mesh_config()))
|
||||
|
||||
# Get master-link status
|
||||
ml_config = get_ml_config()
|
||||
|
||||
|
|
@ -643,8 +649,10 @@ async def get_status():
|
|||
"hostname": get_hostname(),
|
||||
"lan_ip": get_lan_ip(),
|
||||
"wan_ip": get_wan_ip(),
|
||||
"peer_count": len(peers),
|
||||
"online_peers": len(online_peers),
|
||||
"peer_count": wg_peer_count or len(peers),
|
||||
"online_peers": wg_peer_count or len(online_peers),
|
||||
"total_peers": wg_peer_count or len(peers),
|
||||
"active_peers": wg_peer_count or len(online_peers),
|
||||
"service_count": len(services) if isinstance(services, list) else 0,
|
||||
"threat_count": len(threats) if isinstance(threats, dict) else 0,
|
||||
"master_link": {
|
||||
|
|
@ -698,18 +706,22 @@ async def get_self():
|
|||
|
||||
@app.get("/peers")
|
||||
async def list_peers():
|
||||
"""List all known peers (public read)."""
|
||||
"""List all known peers (public read).
|
||||
|
||||
The wg-mesh transport (wg_mesh.json) is the source of truth for the mesh
|
||||
view (Gondwana Phase 1); fall back to the legacy peers.json registry only
|
||||
when there are no wg-mesh peers configured.
|
||||
"""
|
||||
init_dirs()
|
||||
nodes = mesh.peer_nodes(get_wg_mesh_config())
|
||||
if nodes:
|
||||
return {"peers": nodes, "count": len(nodes)}
|
||||
|
||||
peers_data = load_json(PEERS_FILE, {"peers": []})
|
||||
peers = peers_data.get("peers", []) if isinstance(peers_data, dict) else peers_data
|
||||
|
||||
# A node is not its own peer: never insert/persist the local node here.
|
||||
# (Older versions did, which inflated peer_count and listed "<host> (local)"
|
||||
# as a phantom peer.) Drop any self entry a prior version may have saved.
|
||||
# Use /discover/self for the local node's announcement payload instead.
|
||||
local_id = get_node_id()
|
||||
peers = [p for p in peers if not p.get("is_local") and p.get("id") != local_id]
|
||||
|
||||
return {"peers": peers, "count": len(peers)}
|
||||
|
||||
|
||||
|
|
@ -859,15 +871,39 @@ async def unregister_service(name: str, user: dict = Depends(require_jwt)):
|
|||
|
||||
@app.get("/mesh")
|
||||
async def get_mesh_status():
|
||||
"""Get mesh network topology (public read)."""
|
||||
init_dirs()
|
||||
peers_data = load_json(PEERS_FILE, {"peers": []})
|
||||
peers = peers_data.get("peers", []) if isinstance(peers_data, dict) else peers_data
|
||||
"""Get mesh network topology (public read).
|
||||
|
||||
nodes = []
|
||||
links = []
|
||||
Derived from the wg-mesh transport (wg_mesh.json) that p2p owns
|
||||
(Gondwana Phase 1): the local node is the center, each wg peer is a node,
|
||||
links go from local to each peer (hub-and-spoke). Falls back to the
|
||||
legacy peers.json registry when no wg-mesh peers are configured.
|
||||
"""
|
||||
init_dirs()
|
||||
wg = get_wg_mesh_config()
|
||||
peer_views = mesh.peer_nodes(wg)
|
||||
local_id = get_node_id()
|
||||
|
||||
if peer_views:
|
||||
local_addr = (wg.get("address") or "").split("/")[0]
|
||||
local_node = {
|
||||
"id": local_id,
|
||||
"name": get_hostname(),
|
||||
"address": local_addr,
|
||||
"status": "online",
|
||||
"is_local": True,
|
||||
}
|
||||
nodes = [local_node]
|
||||
links = []
|
||||
for pv in peer_views:
|
||||
nodes.append({**pv, "is_local": False})
|
||||
links.append({"source": local_id, "target": pv["id"], "status": pv.get("status", "online")})
|
||||
return {"nodes": nodes, "links": links, "total_nodes": len(nodes), "local_node": local_id}
|
||||
|
||||
# Legacy fallback (peers.json registry)
|
||||
peers_data = load_json(PEERS_FILE, {"peers": []})
|
||||
peers = peers_data.get("peers", []) if isinstance(peers_data, dict) else peers_data
|
||||
nodes = []
|
||||
links = []
|
||||
for peer in peers:
|
||||
node = {
|
||||
"id": peer.get('id', ''),
|
||||
|
|
@ -877,21 +913,9 @@ async def get_mesh_status():
|
|||
"is_local": peer.get('is_local', False) or peer.get('id') == local_id
|
||||
}
|
||||
nodes.append(node)
|
||||
|
||||
# Create links from local node to all peers
|
||||
if not node["is_local"]:
|
||||
links.append({
|
||||
"source": local_id,
|
||||
"target": peer.get('id'),
|
||||
"status": peer.get('status', 'unknown')
|
||||
})
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"links": links,
|
||||
"total_nodes": len(nodes),
|
||||
"local_node": local_id
|
||||
}
|
||||
links.append({"source": local_id, "target": peer.get('id'), "status": peer.get('status', 'unknown')})
|
||||
return {"nodes": nodes, "links": links, "total_nodes": len(nodes), "local_node": local_id}
|
||||
|
||||
|
||||
# ============== Profiles ==============
|
||||
|
|
@ -975,9 +999,9 @@ async def remove_threat(ip: str, user: dict = Depends(require_jwt)):
|
|||
# ============== WireGuard Mesh ==============
|
||||
|
||||
WG_MESH_CONFIG = P2P_DIR / "wg_mesh.json"
|
||||
WG_INTERFACE = "wg-mesh"
|
||||
WG_PORT = 51820
|
||||
WG_NETWORK = "10.100.0.0/24"
|
||||
WG_INTERFACE = mesh.MESH_INTERFACE
|
||||
WG_PORT = mesh.MESH_PORT
|
||||
WG_NETWORK = mesh.MESH_NETWORK
|
||||
|
||||
|
||||
def get_wg_mesh_config() -> Dict:
|
||||
|
|
@ -1055,11 +1079,16 @@ async def init_wireguard(user: dict = Depends(require_jwt)):
|
|||
config["private_key"] = private_key
|
||||
config["public_key"] = public_key
|
||||
|
||||
# Assign IP from network (based on node ID)
|
||||
node_id = get_node_id()
|
||||
ip_suffix = int(hashlib.md5(node_id.encode()).hexdigest()[:2], 16) % 253 + 1
|
||||
network_prefix = WG_NETWORK.rsplit('.', 2)[0]
|
||||
config["address"] = f"{network_prefix}.{ip_suffix}/24"
|
||||
# Assign mesh IP: .1 for the master role, else allocate from the pool.
|
||||
p2p_cfg = mesh.load_p2p_config(CONFIG_FILE)
|
||||
if p2p_cfg["role"] == "master":
|
||||
addr = "10.10.0.1"
|
||||
else:
|
||||
taken = [p.get("allowed_ips", "") for p in config.get("peers", [])]
|
||||
addr = mesh.allocate_mesh_ip(WG_NETWORK, taken)
|
||||
config["address"] = f"{addr}/24"
|
||||
config["role"] = p2p_cfg["role"]
|
||||
config["ddns"] = mesh.ddns_name(get_hostname())
|
||||
|
||||
save_json(WG_MESH_CONFIG, config)
|
||||
|
||||
|
|
@ -1075,7 +1104,7 @@ async def init_wireguard(user: dict = Depends(require_jwt)):
|
|||
async def add_wireguard_peer(
|
||||
public_key: str,
|
||||
endpoint: str,
|
||||
allowed_ips: str = "10.100.0.0/24",
|
||||
allowed_ips: str = "10.10.0.0/24",
|
||||
user: dict = Depends(require_jwt)
|
||||
):
|
||||
"""Add a WireGuard mesh peer."""
|
||||
|
|
@ -1107,40 +1136,21 @@ async def enable_wireguard(user: dict = Depends(require_jwt)):
|
|||
if not config.get("private_key"):
|
||||
raise HTTPException(status_code=400, detail="WireGuard not initialized")
|
||||
|
||||
# Create interface config
|
||||
wg_conf = f"""[Interface]
|
||||
PrivateKey = {config['private_key']}
|
||||
Address = {config.get('address', '10.100.0.1/24')}
|
||||
ListenPort = {config.get('listen_port', WG_PORT)}
|
||||
"""
|
||||
for peer in config.get("peers", []):
|
||||
wg_conf += f"""
|
||||
[Peer]
|
||||
PublicKey = {peer['public_key']}
|
||||
Endpoint = {peer['endpoint']}
|
||||
AllowedIPs = {peer.get('allowed_ips', '10.100.0.0/24')}
|
||||
PersistentKeepalive = 25
|
||||
"""
|
||||
bad = mesh.subnet_overlap(config.get("network", WG_NETWORK))
|
||||
if bad:
|
||||
raise HTTPException(status_code=409,
|
||||
detail=f"mesh network overlaps reserved subnet {bad!r}; refusing")
|
||||
|
||||
# Write config and bring up interface
|
||||
conf_path = Path(f"/etc/wireguard/{WG_INTERFACE}.conf")
|
||||
conf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conf_path.write_text(wg_conf)
|
||||
conf_path.chmod(0o600)
|
||||
|
||||
try:
|
||||
subprocess.run(["wg-quick", "down", WG_INTERFACE], capture_output=True, timeout=10)
|
||||
except:
|
||||
pass
|
||||
|
||||
result = subprocess.run(["wg-quick", "up", WG_INTERFACE], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to start WireGuard: {result.stderr}")
|
||||
if not config.get("address"):
|
||||
raise HTTPException(status_code=400,
|
||||
detail="WireGuard not initialized (no address); run /wireguard/init first")
|
||||
|
||||
# Provisioning (wg-quick, /etc/wireguard write) is delegated to the root
|
||||
# CLI sbx-mesh-up; this unprivileged endpoint only marks the intent.
|
||||
config["enabled"] = True
|
||||
save_json(WG_MESH_CONFIG, config)
|
||||
|
||||
return {"status": "ok", "message": "WireGuard mesh enabled"}
|
||||
return {"status": "ok", "message": "mesh marked enabled; run 'sbx-mesh-up' as root to provision the interface"}
|
||||
|
||||
|
||||
# ============== Remote Announcers ==============
|
||||
|
|
@ -1744,6 +1754,7 @@ async def ml_join(req: JoinRequest, request: Request):
|
|||
join_request["approved_at"] = now.isoformat()
|
||||
join_request["approved_by"] = config.get("fingerprint", get_node_id())
|
||||
join_request["depth"] = peer_depth
|
||||
_assign_mesh_ip(join_request)
|
||||
|
||||
# Mark token as used
|
||||
ml_token_mark_used(token_hash, req.fingerprint)
|
||||
|
|
@ -1774,6 +1785,12 @@ async def ml_join(req: JoinRequest, request: Request):
|
|||
}
|
||||
|
||||
|
||||
def _assign_mesh_ip(join_request: Dict) -> None:
|
||||
"""Allocate the next free mesh IP, deduping against persisted peers."""
|
||||
taken = [p.get("mesh_ip", "") for p in load_json(PEERS_FILE, {"peers": []}).get("peers", [])]
|
||||
join_request["mesh_ip"] = mesh.allocate_mesh_ip(mesh.MESH_NETWORK, taken)
|
||||
|
||||
|
||||
def _add_approved_peer(join_request: Dict):
|
||||
"""Add approved peer to peer list."""
|
||||
peers_data = load_json(PEERS_FILE, {"peers": []})
|
||||
|
|
@ -1790,6 +1807,7 @@ def _add_approved_peer(join_request: Dict):
|
|||
"fingerprint": join_request["fingerprint"],
|
||||
"name": join_request.get("hostname", "Peer"),
|
||||
"address": join_request.get("address"),
|
||||
"mesh_ip": join_request.get("mesh_ip"),
|
||||
"depth": join_request.get("depth", 1),
|
||||
"role": join_request.get("role", "peer"),
|
||||
"added": datetime.utcnow().isoformat(),
|
||||
|
|
@ -1830,6 +1848,10 @@ async def ml_approve(req: ApproveRequest, user: dict = Depends(require_jwt)):
|
|||
if token_hash:
|
||||
ml_token_mark_used(token_hash, req.fingerprint)
|
||||
|
||||
# Allocate mesh IP if not already set (handles manual-approve path)
|
||||
if not join_request.get("mesh_ip"):
|
||||
_assign_mesh_ip(join_request)
|
||||
|
||||
# Add to peers
|
||||
_add_approved_peer(join_request)
|
||||
|
||||
|
|
|
|||
173
packages/secubox-p2p/api/mesh.py
Normal file
173
packages/secubox-p2p/api/mesh.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""
|
||||
SecuBox-Deb :: secubox-p2p :: mesh
|
||||
Pure mesh logic — no FastAPI, no privilege. Imported by api/main.py (state
|
||||
endpoints, runs as user secubox) and by sbx-mesh-up (root provisioner).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import ipaddress
|
||||
import pathlib
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
MESH_INTERFACE = "wg-mesh"
|
||||
MESH_PORT = 51822
|
||||
MESH_NETWORK = "10.10.0.0/24"
|
||||
|
||||
# Reserved subnets the mesh must never overlap (name -> CIDR).
|
||||
RESERVED_SUBNETS = {
|
||||
"br-lxc": "10.100.0.0/24",
|
||||
"eye-br0": "10.55.0.0/24",
|
||||
"lxcbr0": "10.0.3.0/24",
|
||||
"wg-toolbox": "10.99.0.0/24",
|
||||
}
|
||||
|
||||
|
||||
def subnet_overlap(network: str) -> str | None:
|
||||
"""Return the name of the first RESERVED_SUBNETS entry that overlaps
|
||||
`network`, or None if `network` is clear."""
|
||||
net = ipaddress.ip_network(network, strict=False)
|
||||
for name, cidr in RESERVED_SUBNETS.items():
|
||||
if net.overlaps(ipaddress.ip_network(cidr, strict=False)):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def load_p2p_config(path: pathlib.Path) -> dict:
|
||||
"""Read the [wireguard] section of /etc/secubox/p2p.toml, with defaults."""
|
||||
defaults = {
|
||||
"interface": MESH_INTERFACE,
|
||||
"listen_port": MESH_PORT,
|
||||
"network": MESH_NETWORK,
|
||||
"role": "satellite",
|
||||
"master_endpoint": None,
|
||||
}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
wg = (tomllib.load(f) or {}).get("wireguard", {}) or {}
|
||||
except (FileNotFoundError, tomllib.TOMLDecodeError):
|
||||
wg = {}
|
||||
out = dict(defaults)
|
||||
for k in defaults:
|
||||
if wg.get(k) is not None:
|
||||
out[k] = wg[k]
|
||||
return out
|
||||
|
||||
|
||||
def allocate_mesh_ip(network: str, taken: list[str]) -> str:
|
||||
"""Lowest free host >= .2 in `network` (.1 reserved for master)."""
|
||||
taken_set = {t.split("/")[0] for t in taken}
|
||||
net = ipaddress.ip_network(network, strict=False)
|
||||
base = int(net.network_address)
|
||||
for off in range(2, net.num_addresses - 1):
|
||||
cand = str(ipaddress.ip_address(base + off))
|
||||
if cand not in taken_set:
|
||||
return cand
|
||||
raise RuntimeError(f"mesh address pool {network} exhausted")
|
||||
|
||||
|
||||
def parse_wg_conf(text: str) -> dict:
|
||||
"""Extract Interface fields from a wg-quick config (first [Interface])."""
|
||||
out = {"private_key": None, "address": None, "listen_port": None}
|
||||
in_iface = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith("["):
|
||||
in_iface = line.lower() == "[interface]"
|
||||
continue
|
||||
if not in_iface or "=" not in line:
|
||||
continue
|
||||
key, val = (p.strip() for p in line.split("=", 1))
|
||||
kl = key.lower()
|
||||
if kl == "privatekey":
|
||||
out["private_key"] = val
|
||||
elif kl == "address":
|
||||
out["address"] = val
|
||||
elif kl == "listenport":
|
||||
out["listen_port"] = int(val)
|
||||
return out
|
||||
|
||||
|
||||
def render_wg_conf(state: dict) -> str:
|
||||
"""Render a wg-quick config from mesh state."""
|
||||
lines = [
|
||||
"# Managed by secubox-p2p (sbx-mesh-up) — do not edit by hand.",
|
||||
"[Interface]",
|
||||
f"PrivateKey = {state['private_key']}",
|
||||
f"Address = {state['address']}",
|
||||
f"ListenPort = {state.get('listen_port', MESH_PORT)}",
|
||||
]
|
||||
for peer in state.get("peers", []):
|
||||
lines += ["", "[Peer]", f"PublicKey = {peer['public_key']}"]
|
||||
if peer.get("endpoint"):
|
||||
lines.append(f"Endpoint = {peer['endpoint']}")
|
||||
lines.append(f"AllowedIPs = {peer.get('allowed_ips', MESH_NETWORK)}")
|
||||
lines.append("PersistentKeepalive = 25")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def adopt_state(state: dict, existing_conf_text: str | None) -> dict:
|
||||
"""Import the live wg-mesh private key so the public key is preserved.
|
||||
Never overwrites a key already present in state."""
|
||||
if state.get("private_key"):
|
||||
return state
|
||||
if not existing_conf_text:
|
||||
return state
|
||||
parsed = parse_wg_conf(existing_conf_text)
|
||||
if parsed["private_key"]:
|
||||
state["private_key"] = parsed["private_key"]
|
||||
if not state.get("address") and parsed["address"]:
|
||||
state["address"] = parsed["address"]
|
||||
if parsed["listen_port"]:
|
||||
state["listen_port"] = parsed["listen_port"]
|
||||
return state
|
||||
|
||||
|
||||
def ddns_name(hostname: str, domain: str = "secubox.in") -> str:
|
||||
"""Return DDNS-safe hostname: lowercased, non-[a-z0-9-] replaced by -, .domain appended."""
|
||||
slug = re.sub(r"[^a-z0-9-]", "-", hostname.lower())
|
||||
slug = slug[:63] if slug else "node"
|
||||
return f"{slug}.{domain}"
|
||||
|
||||
|
||||
def _host_ip(allowed_ips: str) -> str:
|
||||
"""Return the single host IP from an allowed-ips value, else "".
|
||||
|
||||
A peer's mesh address is recoverable only when its allowed-ips is a /32
|
||||
host route (the master's view of a spoke). A /24 (a spoke's route to the
|
||||
hub) is not a host address, so we return "" and rely on an explicit
|
||||
mesh_ip field instead.
|
||||
"""
|
||||
first = (allowed_ips or "").split(",")[0].strip()
|
||||
if first.endswith("/32"):
|
||||
return first.split("/")[0]
|
||||
return ""
|
||||
|
||||
|
||||
def peer_nodes(state: dict) -> list:
|
||||
"""Map wg_mesh.json peers to app-layer node dicts for the /peers + /status
|
||||
API and the P2P web UI. The mesh transport (wg_mesh.json) is the source of
|
||||
truth; the legacy peers.json registry is unused by the mesh view.
|
||||
|
||||
Each node carries the fields the web UI renders: id, name, address,
|
||||
public_key, status, latency, last_seen. `status` is reported "online"
|
||||
(the unprivileged service cannot read wg handshakes to probe liveness;
|
||||
a privileged liveness probe is future work).
|
||||
"""
|
||||
out = []
|
||||
for p in state.get("peers", []):
|
||||
ip = p.get("mesh_ip") or _host_ip(p.get("allowed_ips", ""))
|
||||
name = p.get("name") or ip or (p.get("public_key", "")[:12] or "peer")
|
||||
out.append({
|
||||
"id": name,
|
||||
"name": name,
|
||||
"address": ip,
|
||||
"public_key": p.get("public_key", ""),
|
||||
"status": "online",
|
||||
"latency": None,
|
||||
"last_seen": None,
|
||||
})
|
||||
return out
|
||||
19
packages/secubox-p2p/conf/p2p.toml.example
Normal file
19
packages/secubox-p2p/conf/p2p.toml.example
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Installed to /etc/secubox/p2p.toml.example by secubox-p2p.
|
||||
# Copy to /etc/secubox/p2p.toml and edit per node.
|
||||
|
||||
[wireguard]
|
||||
# Mesh transport. Do NOT change `network` to anything overlapping the LXC
|
||||
# bridge (10.100.0.0/24) or other reserved subnets — sbx-mesh-up refuses.
|
||||
interface = "wg-mesh"
|
||||
listen_port = 51822
|
||||
network = "10.10.0.0/24"
|
||||
|
||||
# "master" = this node holds the rendezvous role (publicly reachable).
|
||||
# "satellite" = this node dials the rendezvous. Rendezvous is a ROLE — any
|
||||
# node may hold it; today only gk2 is publicly reachable.
|
||||
role = "satellite"
|
||||
|
||||
# Satellite only: where to reach the active rendezvous. Free-form host:port —
|
||||
# a literal IP (pinned now) or a DDNS name (WireGuard re-resolves per
|
||||
# handshake, so the rendezvous can change IP without reconfiguring peers).
|
||||
master_endpoint = "82.67.100.75:51822"
|
||||
|
|
@ -1,3 +1,40 @@
|
|||
secubox-p2p (1.7.8-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(webui): P2P dashboard rendered empty due to API-contract drift.
|
||||
- JS: /peers returns {peers,count} (object) but loadPeers read .length
|
||||
as an array -> peers never listed; same for /threats (a dict). Handle
|
||||
both shapes. loadOverview reads status.service_count.
|
||||
- JS: mesh graph was a hardcoded 'Peer 1-4' placeholder; now wired to
|
||||
GET /mesh (local node center + real wg-mesh peers on a ring).
|
||||
- api: GET /mesh now derives nodes/links from wg_mesh.json (local +
|
||||
wg peers), matching /peers + /status; legacy peers.json fallback kept.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 16:30:00 +0200
|
||||
|
||||
secubox-p2p (1.7.7-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* fix(webui): P2P dashboard was empty — /peers and /status read the unused
|
||||
legacy peers.json, while the live mesh is in wg_mesh.json (Phase 1). Both
|
||||
endpoints now derive the mesh view from wg_mesh.json via mesh.peer_nodes();
|
||||
/status gains total_peers/active_peers (the fields the web UI reads).
|
||||
Per-peer name + mesh_ip surface friendly node labels.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 15:30:00 +0200
|
||||
|
||||
secubox-p2p (1.7.6-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* feat(gondwana P1): adopt secubox-p2p as the single mesh owner.
|
||||
- api/mesh.py: pure mesh logic (subnet collision guard, p2p.toml
|
||||
[wireguard] loader, master-assigned IP allocation, wg.conf
|
||||
parse/render, key adoption, per-node DDNS name).
|
||||
- WireGuard defaults fixed 10.100.0.0/24->10.10.0.0/24 (br-lxc
|
||||
collision), 51820->51822. Role-aware addressing (.1 master).
|
||||
- sbx-mesh-up: root provisioner (adopt live key -> guard -> render ->
|
||||
wg-quick up); the service user cannot run wg-quick.
|
||||
- Depends: wireguard-tools. Ships /etc/secubox/p2p.toml.example.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Mon, 29 Jun 2026 14:00:00 +0200
|
||||
|
||||
secubox-p2p (1.7.5-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* sbx-mesh-invite: re-own the master-link token store to secubox when run as
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Standards-Version: 4.6.2
|
|||
|
||||
Package: secubox-p2p
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, avahi-daemon, avahi-utils
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip, avahi-daemon, avahi-utils, wireguard-tools
|
||||
Breaks: secubox-master-link (<< 1.1)
|
||||
Replaces: secubox-master-link (<< 1.1)
|
||||
Description: SecuBox P2P - Peer-to-Peer Network Hub
|
||||
|
|
|
|||
|
|
@ -33,5 +33,12 @@ override_dh_auto_install:
|
|||
install -m 755 $(CURDIR)/scripts/sbx-mesh-join $(CURDIR)/debian/secubox-p2p/usr/bin/
|
||||
install -m 755 $(CURDIR)/scripts/sbx-mesh-invite $(CURDIR)/debian/secubox-p2p/usr/bin/
|
||||
|
||||
# Install p2p.toml example
|
||||
install -d $(CURDIR)/debian/secubox-p2p/etc/secubox
|
||||
install -m 644 $(CURDIR)/conf/p2p.toml.example $(CURDIR)/debian/secubox-p2p/etc/secubox/
|
||||
|
||||
# Install root mesh provisioner CLI
|
||||
install -m 755 $(CURDIR)/scripts/sbx-mesh-up $(CURDIR)/debian/secubox-p2p/usr/bin/
|
||||
|
||||
# Create runtime directory
|
||||
install -d $(CURDIR)/debian/secubox-p2p/run/secubox
|
||||
|
|
|
|||
41
packages/secubox-p2p/scripts/sbx-mesh-up
Executable file
41
packages/secubox-p2p/scripts/sbx-mesh-up
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# SecuBox-Deb :: secubox-p2p :: sbx-mesh-up
|
||||
# Root provisioner: adopt existing key -> collision guard -> render -> up.
|
||||
# The secubox-p2p service runs as user `secubox` and cannot do this.
|
||||
set -euo pipefail
|
||||
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||
|
||||
STATE=/var/lib/secubox/p2p/wg_mesh.json
|
||||
CONF=/etc/wireguard/wg-mesh.conf
|
||||
|
||||
python3 - "$STATE" "$CONF" <<'PY'
|
||||
import json, sys, pathlib
|
||||
sys.path.insert(0, "/usr/lib/secubox/p2p")
|
||||
from api import mesh
|
||||
|
||||
state_path, conf_path = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
|
||||
state = json.loads(state_path.read_text()) if state_path.exists() else {"peers": []}
|
||||
|
||||
# Adopt the live key if state has none (preserves the gk2<->c3box handshake).
|
||||
existing = conf_path.read_text() if conf_path.exists() else None
|
||||
state = mesh.adopt_state(state, existing)
|
||||
|
||||
net = state.get("network", mesh.MESH_NETWORK)
|
||||
bad = mesh.subnet_overlap(net)
|
||||
if bad:
|
||||
sys.exit(f"REFUSING: mesh network {net} overlaps reserved subnet {bad!r}")
|
||||
|
||||
if not state.get("private_key"):
|
||||
sys.exit("no private key in state and none to adopt; run /wireguard/init first")
|
||||
|
||||
conf_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conf_path.write_text(mesh.render_wg_conf(state))
|
||||
conf_path.chmod(0o600)
|
||||
state_path.write_text(json.dumps(state, indent=2))
|
||||
print(f"rendered {conf_path} (addr {state.get('address')}, peers {len(state.get('peers', []))})")
|
||||
PY
|
||||
|
||||
wg-quick down wg-mesh 2>/dev/null || true
|
||||
wg-quick up wg-mesh
|
||||
wg show wg-mesh
|
||||
0
packages/secubox-p2p/tests/__init__.py
Normal file
0
packages/secubox-p2p/tests/__init__.py
Normal file
5
packages/secubox-p2p/tests/conftest.py
Normal file
5
packages/secubox-p2p/tests/conftest.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Ensures `from api import mesh` resolves from the package root during tests.
|
||||
import sys, pathlib
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
147
packages/secubox-p2p/tests/test_mesh.py
Normal file
147
packages/secubox-p2p/tests/test_mesh.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
import pytest
|
||||
from api import mesh
|
||||
|
||||
|
||||
def test_mesh_defaults():
|
||||
assert mesh.MESH_NETWORK == "10.10.0.0/24"
|
||||
assert mesh.MESH_PORT == 51822
|
||||
assert mesh.MESH_INTERFACE == "wg-mesh"
|
||||
|
||||
|
||||
def test_subnet_overlap_detects_br_lxc():
|
||||
assert mesh.subnet_overlap("10.100.0.0/24") == "br-lxc"
|
||||
|
||||
|
||||
def test_subnet_overlap_detects_partial_supernet():
|
||||
# a /16 that contains br-lxc must also be rejected
|
||||
assert mesh.subnet_overlap("10.100.0.0/16") == "br-lxc"
|
||||
|
||||
|
||||
def test_subnet_overlap_clean_mesh_subnet():
|
||||
assert mesh.subnet_overlap("10.10.0.0/24") is None
|
||||
|
||||
|
||||
def test_load_p2p_config_defaults_when_missing(tmp_path):
|
||||
cfg = mesh.load_p2p_config(tmp_path / "nope.toml")
|
||||
assert cfg["network"] == "10.10.0.0/24"
|
||||
assert cfg["listen_port"] == 51822
|
||||
assert cfg["interface"] == "wg-mesh"
|
||||
assert cfg["role"] == "satellite"
|
||||
assert cfg["master_endpoint"] is None
|
||||
|
||||
|
||||
def test_load_p2p_config_reads_wireguard_section(tmp_path):
|
||||
p = tmp_path / "p2p.toml"
|
||||
p.write_text(
|
||||
"[wireguard]\n"
|
||||
'role = "master"\n'
|
||||
'listen_port = 51822\n'
|
||||
'network = "10.10.0.0/24"\n'
|
||||
'master_endpoint = "82.67.100.75:51822"\n'
|
||||
)
|
||||
cfg = mesh.load_p2p_config(p)
|
||||
assert cfg["role"] == "master"
|
||||
assert cfg["master_endpoint"] == "82.67.100.75:51822"
|
||||
|
||||
|
||||
def test_allocate_mesh_ip_first_free_is_2():
|
||||
assert mesh.allocate_mesh_ip("10.10.0.0/24", []) == "10.10.0.2"
|
||||
|
||||
|
||||
def test_allocate_mesh_ip_skips_taken_with_or_without_mask():
|
||||
got = mesh.allocate_mesh_ip("10.10.0.0/24", ["10.10.0.2/24", "10.10.0.3"])
|
||||
assert got == "10.10.0.4"
|
||||
|
||||
|
||||
def test_allocate_mesh_ip_exhausted_raises():
|
||||
taken = [f"10.10.0.{n}" for n in range(2, 255)]
|
||||
with pytest.raises(RuntimeError):
|
||||
mesh.allocate_mesh_ip("10.10.0.0/24", taken)
|
||||
|
||||
|
||||
def test_parse_wg_conf_extracts_interface_fields():
|
||||
text = (
|
||||
"[Interface]\n"
|
||||
"PrivateKey = ABC123=\n"
|
||||
"Address = 10.10.0.1/24\n"
|
||||
"ListenPort = 51822\n"
|
||||
"[Peer]\nPublicKey = X=\n"
|
||||
)
|
||||
got = mesh.parse_wg_conf(text)
|
||||
assert got == {"private_key": "ABC123=", "address": "10.10.0.1/24", "listen_port": 51822}
|
||||
|
||||
|
||||
def test_render_wg_conf_master_with_roaming_peer():
|
||||
state = {
|
||||
"private_key": "PRIV=",
|
||||
"address": "10.10.0.1/24",
|
||||
"listen_port": 51822,
|
||||
"peers": [{"public_key": "PUB2=", "allowed_ips": "10.10.0.2/32"}],
|
||||
}
|
||||
out = mesh.render_wg_conf(state)
|
||||
assert "PrivateKey = PRIV=" in out
|
||||
assert "ListenPort = 51822" in out
|
||||
assert "AllowedIPs = 10.10.0.2/32" in out
|
||||
assert "Endpoint" not in out # roaming peer => no Endpoint line
|
||||
|
||||
|
||||
def test_render_wg_conf_satellite_with_endpoint_and_keepalive():
|
||||
state = {
|
||||
"private_key": "PRIV=", "address": "10.10.0.3/24", "listen_port": 51822,
|
||||
"peers": [{"public_key": "GK2=", "endpoint": "82.67.100.75:51822", "allowed_ips": "10.10.0.0/24"}],
|
||||
}
|
||||
out = mesh.render_wg_conf(state)
|
||||
assert "Endpoint = 82.67.100.75:51822" in out
|
||||
assert "PersistentKeepalive = 25" in out
|
||||
|
||||
|
||||
def test_ddns_name_basic():
|
||||
assert mesh.ddns_name("gk2") == "gk2.secubox.in"
|
||||
|
||||
|
||||
def test_ddns_name_sanitizes():
|
||||
assert mesh.ddns_name("Secubox_Live!") == "secubox-live-.secubox.in"
|
||||
|
||||
|
||||
def test_adopt_state_imports_existing_key_when_absent():
|
||||
state = {"private_key": None, "peers": []}
|
||||
conf = "[Interface]\nPrivateKey = LIVEKEY=\nAddress = 10.10.0.1/24\nListenPort = 51822\n"
|
||||
out = mesh.adopt_state(state, conf)
|
||||
assert out["private_key"] == "LIVEKEY="
|
||||
assert out["address"] == "10.10.0.1/24"
|
||||
assert out["listen_port"] == 51822
|
||||
|
||||
|
||||
def test_adopt_state_never_overwrites_existing_key():
|
||||
state = {"private_key": "KEEP=", "peers": []}
|
||||
conf = "[Interface]\nPrivateKey = OTHER=\n"
|
||||
out = mesh.adopt_state(state, conf)
|
||||
assert out["private_key"] == "KEEP="
|
||||
|
||||
|
||||
def test_ddns_name_empty_falls_back():
|
||||
assert mesh.ddns_name("") == "node.secubox.in"
|
||||
|
||||
|
||||
def test_host_ip_only_from_slash32():
|
||||
assert mesh._host_ip("10.10.0.2/32") == "10.10.0.2"
|
||||
assert mesh._host_ip("10.10.0.0/24") == ""
|
||||
assert mesh._host_ip("") == ""
|
||||
|
||||
|
||||
def test_peer_nodes_uses_name_and_mesh_ip():
|
||||
state = {"peers": [
|
||||
{"public_key": "AAA=", "name": "c3box", "mesh_ip": "10.10.0.2", "allowed_ips": "10.10.0.2/32"},
|
||||
{"public_key": "BBB=", "allowed_ips": "10.10.0.3/32"}, # no name/mesh_ip -> derive ip
|
||||
{"public_key": "CCC=", "name": "gk2", "mesh_ip": "10.10.0.1", "allowed_ips": "10.10.0.0/24"},
|
||||
]}
|
||||
nodes = mesh.peer_nodes(state)
|
||||
assert [n["name"] for n in nodes] == ["c3box", "10.10.0.3", "gk2"]
|
||||
assert [n["address"] for n in nodes] == ["10.10.0.2", "10.10.0.3", "10.10.0.1"]
|
||||
assert all(n["status"] == "online" for n in nodes)
|
||||
|
||||
|
||||
def test_peer_nodes_empty():
|
||||
assert mesh.peer_nodes({"peers": []}) == []
|
||||
|
|
@ -737,7 +737,7 @@
|
|||
document.getElementById('status-value').className = 'value ' + (status.online ? 'online' : 'offline');
|
||||
document.getElementById('peer-count').textContent = status.total_peers || 0;
|
||||
document.getElementById('active-peer-count').textContent = status.active_peers || 0;
|
||||
document.getElementById('service-count').textContent = status.services || 0;
|
||||
document.getElementById('service-count').textContent = status.service_count || status.services || 0;
|
||||
}
|
||||
|
||||
const activity = await apiGet('/activity');
|
||||
|
|
@ -758,9 +758,10 @@
|
|||
|
||||
// Load Peers
|
||||
async function loadPeers() {
|
||||
const peers = await apiGet('/peers');
|
||||
const data = await apiGet('/peers');
|
||||
const peers = Array.isArray(data) ? data : (data && Array.isArray(data.peers) ? data.peers : []);
|
||||
const tbody = document.getElementById('peers-table');
|
||||
if (peers && peers.length > 0) {
|
||||
if (peers.length > 0) {
|
||||
tbody.innerHTML = peers.map(peer => `
|
||||
<tr>
|
||||
<td>${escapeHtml(peer.id)}</td>
|
||||
|
|
@ -781,9 +782,10 @@
|
|||
|
||||
// Load Services
|
||||
async function loadServices() {
|
||||
const services = await apiGet('/services');
|
||||
const data = await apiGet('/services');
|
||||
const services = Array.isArray(data) ? data : (data && Array.isArray(data.services) ? data.services : []);
|
||||
const tbody = document.getElementById('services-table');
|
||||
if (services && services.length > 0) {
|
||||
if (services.length > 0) {
|
||||
tbody.innerHTML = services.map(svc => `
|
||||
<tr>
|
||||
<td>${escapeHtml(svc.name)}</td>
|
||||
|
|
@ -803,9 +805,18 @@
|
|||
|
||||
// Load Threats
|
||||
async function loadThreats() {
|
||||
const threats = await apiGet('/threats');
|
||||
const data = await apiGet('/threats');
|
||||
let threats = [];
|
||||
if (Array.isArray(data)) threats = data;
|
||||
else if (data && typeof data === 'object') threats = Object.entries(data).map(([ip, t]) => ({
|
||||
type: (t && t.type) || 'threat',
|
||||
severity: (t && t.severity) || 'info',
|
||||
source: (t && t.source) || ip,
|
||||
target: (t && t.target) || ip,
|
||||
description: (t && (t.description || t.reason)) || 'shared via mesh'
|
||||
}));
|
||||
const container = document.getElementById('threats-list');
|
||||
if (threats && threats.length > 0) {
|
||||
if (threats.length > 0) {
|
||||
container.innerHTML = threats.map(threat => `
|
||||
<div class="threat-item ${threat.severity}">
|
||||
<div class="threat-header">
|
||||
|
|
@ -824,48 +835,50 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Mesh Visualization
|
||||
function initMesh() {
|
||||
// Mesh Visualization (wired to /mesh — real nodes from the wg-mesh)
|
||||
async function initMesh() {
|
||||
const canvas = document.getElementById('mesh-canvas');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Set canvas size
|
||||
canvas.width = canvas.parentElement.clientWidth;
|
||||
canvas.height = canvas.parentElement.clientHeight;
|
||||
|
||||
// Light theme colors
|
||||
const bgColor = '#f1f8f2';
|
||||
const nodeColor = '#006622';
|
||||
const lineColor = '#a7c4a0';
|
||||
const textColor = '#1b4332';
|
||||
|
||||
// Draw placeholder mesh
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.fillStyle = nodeColor;
|
||||
ctx.font = '14px Courier New';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
// Center node
|
||||
const centerX = canvas.width / 2;
|
||||
const centerY = canvas.height / 2;
|
||||
|
||||
drawNode(ctx, centerX, centerY, 'LOCAL', true, nodeColor, textColor);
|
||||
const data = await apiGet('/mesh');
|
||||
const allNodes = (data && Array.isArray(data.nodes)) ? data.nodes : [];
|
||||
const local = allNodes.find(n => n.is_local) || { name: 'LOCAL' };
|
||||
const peers = allNodes.filter(n => !n.is_local);
|
||||
|
||||
// Placeholder peer nodes
|
||||
const peers = [
|
||||
{ x: centerX - 150, y: centerY - 100, name: 'Peer 1' },
|
||||
{ x: centerX + 150, y: centerY - 100, name: 'Peer 2' },
|
||||
{ x: centerX - 150, y: centerY + 100, name: 'Peer 3' },
|
||||
{ x: centerX + 150, y: centerY + 100, name: 'Peer 4' }
|
||||
];
|
||||
|
||||
peers.forEach(peer => {
|
||||
drawConnection(ctx, centerX, centerY, peer.x, peer.y, lineColor);
|
||||
drawNode(ctx, peer.x, peer.y, peer.name, false, nodeColor, textColor);
|
||||
const radius = Math.max(60, Math.min(centerX, centerY) - 60);
|
||||
peers.forEach((peer, i) => {
|
||||
const angle = (Math.PI * 2 * i) / Math.max(peers.length, 1) - Math.PI / 2;
|
||||
const px = centerX + radius * Math.cos(angle);
|
||||
const py = centerY + radius * Math.sin(angle);
|
||||
drawConnection(ctx, centerX, centerY, px, py, lineColor);
|
||||
const label = (peer.name || peer.address || peer.id || 'peer') +
|
||||
(peer.address && peer.name !== peer.address ? ' (' + peer.address + ')' : '');
|
||||
drawNode(ctx, px, py, label, false, nodeColor, textColor);
|
||||
});
|
||||
|
||||
const localLabel = (local.name || 'LOCAL') + (local.address ? ' (' + local.address + ')' : '');
|
||||
drawNode(ctx, centerX, centerY, localLabel, true, nodeColor, textColor);
|
||||
|
||||
if (peers.length === 0) {
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.fillText('No mesh peers', centerX, centerY + 70);
|
||||
}
|
||||
}
|
||||
|
||||
function drawNode(ctx, x, y, label, isCenter, nodeColor, textColor) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user