WireGuard access classes, firewall nftables fixes, network sync event refactor
- WireGuard: refactor to multi-interface 'access classes' model; extract config generation and helpers into lib/wireguard.py; add per-class up/down endpoints and API routes; update UI with class management pages and QR code component - Firewall: fix zone creation with --new-zone before --set-target; skip masquerade on public zone; add masquerade propagation for nftables backend so NAT works when internal zones exit via public - Network: rename sync event subsystem 'network' -> 'networkd'; always stamp config hash even when deployment fails (fixes pending-changes detection) - DHCP: add new API endpoint and update frontend page - State/Sync: update state collectors and sync buses for new subsystems - Docs: update API and config documentation for new endpoints and schemas
This commit is contained in:
+186
-76
@@ -279,64 +279,72 @@ def _strip_volatile(
|
||||
for k in pop_keys:
|
||||
stripped.pop(k, None)
|
||||
for vpath in volatile:
|
||||
# Determine if this path uses list-of-dicts pattern (e.g. "peers[].transfer").
|
||||
# The [] marker signals that the parent key holds a list of dicts, and we
|
||||
# must strip the volatile sub-key from each dict in the list.
|
||||
list_marker = vpath.index("[]") if "[]" in vpath else -1
|
||||
if list_marker != -1:
|
||||
# Split into prefix (path before []), item keys (path after []).
|
||||
# e.g. "status.peers[].transfer_received" → prefix=["status","peers"],
|
||||
# item_keys=["transfer_received"]
|
||||
prefix = vpath[:list_marker].split(".")
|
||||
item_keys = (
|
||||
vpath[list_marker + 3 :].split(".")
|
||||
if list_marker + 3 < len(vpath)
|
||||
else []
|
||||
)
|
||||
# Navigate to the list container via the prefix path
|
||||
parent = stripped
|
||||
for seg in prefix:
|
||||
_strip_volatile_path(stripped, vpath)
|
||||
return stripped
|
||||
|
||||
|
||||
def _strip_volatile_item(item: dict[str, Any], keys: list[str]) -> None:
|
||||
"""Recursively strip volatile keys from *item*, handling nested ``[]`` markers."""
|
||||
for i, k in enumerate(keys):
|
||||
if "[]" in k:
|
||||
base_key = k.replace("[]", "")
|
||||
rest = keys[i + 1 :]
|
||||
target = item.get(base_key, [])
|
||||
if isinstance(target, list):
|
||||
for t in target:
|
||||
if isinstance(t, dict):
|
||||
_strip_volatile_item(t, rest)
|
||||
elif isinstance(target, dict):
|
||||
for v in target.values():
|
||||
if isinstance(v, dict):
|
||||
_strip_volatile_item(v, rest)
|
||||
return
|
||||
elif i == len(keys) - 1:
|
||||
item[k] = None
|
||||
return
|
||||
else:
|
||||
if isinstance(item, dict) and k in item:
|
||||
item = item[k]
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
def _strip_volatile_path(stripped: dict[str, Any], vpath: str) -> None:
|
||||
"""Strip a single volatile path from *stripped*, supporting nested ``[]`` markers."""
|
||||
list_marker = vpath.index("[]") if "[]" in vpath else -1
|
||||
if list_marker == -1:
|
||||
segments = vpath.split(".")
|
||||
parent = stripped
|
||||
for i, seg in enumerate(segments):
|
||||
if i == len(segments) - 1:
|
||||
if isinstance(parent, dict) and seg in parent:
|
||||
parent[seg] = None
|
||||
else:
|
||||
if isinstance(parent, dict) and seg in parent:
|
||||
parent = parent[seg]
|
||||
else:
|
||||
break
|
||||
if isinstance(parent, list):
|
||||
items = parent
|
||||
elif isinstance(parent, dict):
|
||||
logger.debug(
|
||||
"_strip_volatile: %s resolved to dict, falling back to .values()",
|
||||
vpath,
|
||||
)
|
||||
items = parent.values()
|
||||
else:
|
||||
continue
|
||||
|
||||
for item in items:
|
||||
# parent should now be a list; iterate each dict and strip sub-keys
|
||||
if isinstance(item, dict):
|
||||
curr = item
|
||||
for i, ik in enumerate(item_keys):
|
||||
if i == len(item_keys) - 1:
|
||||
curr[ik] = None
|
||||
else:
|
||||
if isinstance(curr, dict) and ik in curr:
|
||||
curr = curr[ik]
|
||||
else:
|
||||
break
|
||||
return
|
||||
return
|
||||
# Split into prefix and item keys.
|
||||
prefix = vpath[:list_marker].split(".")
|
||||
item_keys = (
|
||||
vpath[list_marker + 3 :].split(".") if list_marker + 3 < len(vpath) else []
|
||||
)
|
||||
parent = stripped
|
||||
for seg in prefix:
|
||||
if isinstance(parent, dict) and seg in parent:
|
||||
parent = parent[seg]
|
||||
else:
|
||||
# Scalar/dict path: navigate via segments and set final key to None
|
||||
segments = vpath.split(".")
|
||||
parent = stripped
|
||||
for i, seg in enumerate(segments):
|
||||
if i == len(segments) - 1:
|
||||
if isinstance(parent, dict) and seg in parent:
|
||||
parent[seg] = None
|
||||
else:
|
||||
if isinstance(parent, dict) and seg in parent:
|
||||
parent = parent[seg]
|
||||
else:
|
||||
break
|
||||
return stripped
|
||||
return
|
||||
if isinstance(parent, list):
|
||||
items = parent
|
||||
elif isinstance(parent, dict):
|
||||
items = list(parent.values())
|
||||
else:
|
||||
return
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item_keys:
|
||||
_strip_volatile_item(item, item_keys)
|
||||
|
||||
|
||||
def _diff_layers(
|
||||
@@ -872,10 +880,11 @@ register_collector("acme", _collect_acme)
|
||||
|
||||
|
||||
def _collect_wireguard() -> dict[str, Any]:
|
||||
"""Collect WireGuard config, status, and peers.
|
||||
"""Collect WireGuard config, per-class status, and peers.
|
||||
|
||||
Returns:
|
||||
Dict containing interface config, runtime status, and peers.
|
||||
Dict containing interface config, per-class runtime status,
|
||||
combined peers, and overall tunnel status.
|
||||
"""
|
||||
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
||||
|
||||
@@ -886,9 +895,12 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"server_endpoint": "",
|
||||
"description": "",
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"access_classes": {},
|
||||
"peers": {},
|
||||
}
|
||||
|
||||
@@ -907,11 +919,18 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
cfg
|
||||
)
|
||||
|
||||
# Safe config (strip private key and internal hash)
|
||||
# Safe config (strip private keys from interface and access classes)
|
||||
safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
|
||||
if "interface" in safe:
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
if "access_classes" in safe:
|
||||
safe["access_classes"] = {}
|
||||
for ck, cv in cfg.get("access_classes", {}).items():
|
||||
if isinstance(cv, dict):
|
||||
entry = dict(cv)
|
||||
entry.pop("private_key", None)
|
||||
safe["access_classes"][ck] = entry
|
||||
|
||||
# Peers list (safe)
|
||||
peers: list[dict[str, Any]] = []
|
||||
@@ -921,35 +940,49 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
entry.pop("private_key", None)
|
||||
peers.append(entry)
|
||||
|
||||
# Runtime status
|
||||
status: dict[str, Any] = {"up": False, "interface": {}, "peers": []}
|
||||
name = cfg["interface"]["name"]
|
||||
peer_name = name if isinstance(name, str) else "wg0"
|
||||
try:
|
||||
res = run_proc(["wg", "show", peer_name], sudo=True, check=False)
|
||||
if res.returncode == 0:
|
||||
# Runtime status — per-class interfaces
|
||||
status: dict[str, Any] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
"classes": {},
|
||||
}
|
||||
classes = cfg.get("access_classes", {})
|
||||
any_up = False
|
||||
|
||||
for class_key in classes:
|
||||
class_cfg = classes.get(class_key)
|
||||
if not isinstance(class_cfg, dict):
|
||||
continue
|
||||
ifname = f"wg-{class_key}"
|
||||
try:
|
||||
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
|
||||
if res.returncode != 0:
|
||||
status["classes"][class_key] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
}
|
||||
continue
|
||||
raw = res.stdout.strip()
|
||||
current_peer: dict[str, Any] | None = None
|
||||
status_peers: list[dict[str, Any]] = []
|
||||
class_peers: list[dict[str, Any]] = []
|
||||
cls_up = False
|
||||
cls_iface: dict[str, Any] = {}
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("interface:"):
|
||||
status["up"] = True
|
||||
status["interface"] = {}
|
||||
cls_up = True
|
||||
cls_iface = {}
|
||||
current_peer = None
|
||||
continue
|
||||
if line.startswith("public key:"):
|
||||
status["interface"]["public_key"] = line.split(":", 1)[1].strip()
|
||||
cls_iface["public_key"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
if line.startswith("listening port:"):
|
||||
status["interface"]["listen_port"] = int(
|
||||
line.split(":", 1)[1].strip()
|
||||
)
|
||||
continue
|
||||
if line.startswith("fwmark:"):
|
||||
status["interface"]["fwmark"] = line.split(":", 1)[1].strip()
|
||||
cls_iface["listen_port"] = int(line.split(":", 1)[1].strip())
|
||||
continue
|
||||
if line.startswith("peer:"):
|
||||
cur_key = line.split(":", 1)[1].strip()
|
||||
@@ -962,7 +995,7 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
"transfer_sent": "0",
|
||||
"persistent_keepalive": None,
|
||||
}
|
||||
status_peers.append(current_peer)
|
||||
class_peers.append(current_peer)
|
||||
continue
|
||||
if current_peer is None:
|
||||
continue
|
||||
@@ -985,10 +1018,84 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
current_peer["persistent_keepalive"] = int(
|
||||
line.split(":", 1)[1].strip()
|
||||
)
|
||||
status["peers"] = status_peers
|
||||
status["classes"][class_key] = {
|
||||
"up": cls_up,
|
||||
"interface": cls_iface,
|
||||
"peers": class_peers,
|
||||
}
|
||||
if cls_up:
|
||||
any_up = True
|
||||
except Exception:
|
||||
status["classes"][class_key] = {"up": False, "interface": {}, "peers": []}
|
||||
|
||||
# Also collect legacy single-interface status
|
||||
try:
|
||||
ifname = cfg["interface"].get("name", "wg0")
|
||||
legacy_peers: list[dict[str, Any]] = []
|
||||
current_peer: dict[str, Any] | None = None
|
||||
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
|
||||
if res.returncode == 0:
|
||||
raw = res.stdout.strip()
|
||||
status["up"] = True
|
||||
status["interface"] = {}
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("interface:"):
|
||||
status["interface"] = {}
|
||||
current_peer = None
|
||||
continue
|
||||
if line.startswith("public key:"):
|
||||
status["interface"]["public_key"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
if line.startswith("listening port:"):
|
||||
status["interface"]["listen_port"] = int(
|
||||
line.split(":", 1)[1].strip()
|
||||
)
|
||||
continue
|
||||
if line.startswith("peer:"):
|
||||
cur_key = line.split(":", 1)[1].strip()
|
||||
current_peer = {
|
||||
"public_key": cur_key,
|
||||
"endpoint": None,
|
||||
"allowed_ips": [],
|
||||
"latest_handshake": None,
|
||||
"transfer_received": "0",
|
||||
"transfer_sent": "0",
|
||||
"persistent_keepalive": None,
|
||||
}
|
||||
legacy_peers.append(current_peer)
|
||||
continue
|
||||
if current_peer is None:
|
||||
continue
|
||||
if line.startswith("endpoint:"):
|
||||
current_peer["endpoint"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("allowed ips:"):
|
||||
current_peer["allowed_ips"] = (
|
||||
line.split(":", 1)[1].strip().split(", ")
|
||||
)
|
||||
elif line.startswith("latest handshake:"):
|
||||
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("transfer:"):
|
||||
rest = line.split(":", 1)[1].strip().split(", ")
|
||||
if rest:
|
||||
current_peer["transfer_received"] = rest[0].strip()
|
||||
if len(rest) > 1:
|
||||
current_peer["transfer_sent"] = rest[1].strip()
|
||||
elif line.startswith("persistent-keepalive:"):
|
||||
with contextlib.suppress(ValueError):
|
||||
current_peer["persistent_keepalive"] = int(
|
||||
line.split(":", 1)[1].strip()
|
||||
)
|
||||
status["peers"] = legacy_peers
|
||||
any_up = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if any_up:
|
||||
status["up"] = True
|
||||
|
||||
status["pending_changes"] = pending_changes
|
||||
return {
|
||||
"config": safe,
|
||||
@@ -1006,6 +1113,9 @@ register_volatile(
|
||||
"status.peers[].transfer_received",
|
||||
"status.peers[].transfer_sent",
|
||||
"status.peers[].latest_handshake",
|
||||
"status.classes[].peers[].transfer_received",
|
||||
"status.classes[].peers[].transfer_sent",
|
||||
"status.classes[].peers[].latest_handshake",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user