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:
2026-07-20 03:57:16 +00:00
parent dadabd7954
commit 04417cf05c
19 changed files with 2688 additions and 455 deletions
+186 -76
View File
@@ -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",
}
),
)