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",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
+200
-52
@@ -11,6 +11,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from lib.common import get_interface_ip
|
||||
from lib.state import state as _state_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -392,8 +393,6 @@ class DnsToFirewallSync:
|
||||
changes: list[str],
|
||||
) -> None:
|
||||
"""Ensure DHCP ranges for *zone_ifaces* carry the gateway (interface IP)."""
|
||||
from lib.common import get_interface_ip
|
||||
|
||||
ranges = dnsmasq_cfg.get("dhcp", {}).get("ranges", [])
|
||||
for r in ranges:
|
||||
iface = r.get("interface", "")
|
||||
@@ -410,7 +409,23 @@ class DnsToFirewallSync:
|
||||
|
||||
|
||||
class WgToFirewallSync:
|
||||
"""Sync subscriber: wireguard config_saved → update firewall config."""
|
||||
"""Sync subscriber: wireguard config_saved → update firewall zones per access class.
|
||||
|
||||
Each access class with peers gets its own firewall zone (``vpn-<key>``).
|
||||
Classes with ``lan_access=True`` get inter-zone accept rules for all
|
||||
internal subnets. Classes with ``lan_access=False`` (internet-only) get
|
||||
no internal subnet rules.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _wg_class_interface_name(class_key: str) -> str:
|
||||
"""Derive interface name for an access class."""
|
||||
return f"wg-{class_key}"
|
||||
|
||||
@staticmethod
|
||||
def _wg_class_zone_name(class_key: str) -> str:
|
||||
"""Derive firewall zone name for an access class."""
|
||||
return f"vpn-{class_key}"
|
||||
|
||||
@staticmethod
|
||||
def _sync_allowed_ips(
|
||||
@@ -419,15 +434,9 @@ class WgToFirewallSync:
|
||||
zones: dict[str, Any],
|
||||
changes: list[str],
|
||||
) -> None:
|
||||
"""Ensure inter-zone rich rules exist for peer allowed_ips subnets.
|
||||
|
||||
For each peer's allowed_ips subnet that is not already covered
|
||||
by a vpn-zone rich rule, adds a destination accept rule so
|
||||
traffic from the VPN can reach those subnets.
|
||||
"""
|
||||
"""Add inter-zone rich rules for peer allowed_ips subnets."""
|
||||
import re
|
||||
|
||||
# Collect all unique allowed_ips subnets across peers
|
||||
all_subnets: set[str] = set()
|
||||
for _name, peer_info in wg_cfg.get("peers", {}).items():
|
||||
if not isinstance(peer_info, dict):
|
||||
@@ -436,7 +445,6 @@ class WgToFirewallSync:
|
||||
if isinstance(item, str) and item.strip():
|
||||
all_subnets.add(item.strip())
|
||||
|
||||
# Parse existing rule strings to find which subnets are already covered
|
||||
existing_rules = vpn_zone.get("rich_rules", [])
|
||||
covered_subnets: set[str] = set()
|
||||
for rule_entry in existing_rules:
|
||||
@@ -445,19 +453,15 @@ class WgToFirewallSync:
|
||||
if isinstance(rule_entry, dict)
|
||||
else str(rule_entry)
|
||||
)
|
||||
match = re.search(
|
||||
r'destination\s+address="([^"]+)"',
|
||||
str(rule_str),
|
||||
)
|
||||
match = re.search(r'destination\s+address="([^"]+)"', str(rule_str))
|
||||
if match:
|
||||
covered_subnets.add(match.group(1))
|
||||
|
||||
# Add rules for uncovered subnets
|
||||
for subnet in sorted(all_subnets):
|
||||
if subnet in covered_subnets:
|
||||
continue
|
||||
rule_entry = {
|
||||
"rule": (f'rule family="ipv4" destination address="{subnet}" accept'),
|
||||
"rule": f'rule family="ipv4" destination address="{subnet}" accept',
|
||||
"_source": "wg",
|
||||
}
|
||||
vpn_zone.setdefault("rich_rules", []).append(rule_entry)
|
||||
@@ -467,13 +471,17 @@ class WgToFirewallSync:
|
||||
|
||||
@classmethod
|
||||
def on_wireguard_config_saved(cls, event: SyncEvent) -> SyncResult | None:
|
||||
"""Sync subscriber: wireguard config_saved → update firewall config.
|
||||
"""Sync subscriber: wireguard config_saved → update firewall zones per class.
|
||||
|
||||
When WireGuard is active (has peers and interface), this subscriber
|
||||
ensures the ``vpn`` zone exists with the WG interface assigned,
|
||||
masquerade enabled, UDP 51820 accept rule, and inter-zone rich rules
|
||||
for peer allowed_ips subnets. When WireGuard becomes inactive,
|
||||
cleans up WireGuard-created entries from the vpn zone.
|
||||
For each access class with peers, ensures a ``vpn-<key>`` zone exists
|
||||
with the WG interface assigned, masquerade enabled, and a UDP port
|
||||
accept rule. Classes with ``lan_access=True`` also get inter-zone
|
||||
accept rules for internal subnets.
|
||||
|
||||
Also handles legacy single-interface mode: when no classes have peers
|
||||
but peers exist without access_class, manages a single ``vpn`` zone.
|
||||
|
||||
Cleans up zones/classes when empty.
|
||||
|
||||
Skips processing if event originated as a cascade from ``firewall``.
|
||||
|
||||
@@ -482,7 +490,7 @@ class WgToFirewallSync:
|
||||
|
||||
Returns:
|
||||
SyncResult listing firewall as affected subsystem with change
|
||||
descriptions. ``None`` if skipped due to cascade guard.
|
||||
descriptions. ``None`` if skipped due cascade guard.
|
||||
"""
|
||||
if event.payload.get("_cascade") == "firewall":
|
||||
return None
|
||||
@@ -496,54 +504,148 @@ class WgToFirewallSync:
|
||||
fw_cfg = _get_fw_cfg()
|
||||
zones = fw_cfg.get("zones", {})
|
||||
|
||||
wg_iface = wg_cfg.get("interface", {}).get("name", "")
|
||||
peers = wg_cfg.get("peers", {})
|
||||
is_active = bool(peers) and bool(wg_iface)
|
||||
|
||||
changes: list[str] = []
|
||||
active_class_keys: set[str] = set()
|
||||
|
||||
if is_active:
|
||||
# --- Per-class zone management ---
|
||||
classes = wg_cfg.get("access_classes", {})
|
||||
|
||||
for class_key, class_cfg in classes.items():
|
||||
if not isinstance(class_cfg, dict):
|
||||
continue
|
||||
class_peers = {
|
||||
n: p
|
||||
for n, p in wg_cfg.get("peers", {}).items()
|
||||
if isinstance(p, dict) and p.get("access_class") == class_key
|
||||
}
|
||||
if not class_peers:
|
||||
continue
|
||||
|
||||
active_class_keys.add(class_key)
|
||||
zone_name = cls._wg_class_zone_name(class_key)
|
||||
iface_name = cls._wg_class_interface_name(class_key)
|
||||
|
||||
listen_port = class_cfg.get("listen_port", 51820)
|
||||
lan_access = class_cfg.get("lan_access", False)
|
||||
|
||||
zone = zones.setdefault(zone_name, {})
|
||||
if not isinstance(zone, dict):
|
||||
zones[zone_name] = zone = {}
|
||||
|
||||
# Ensure interface assigned
|
||||
current_ifaces = list(zone.get("interfaces", []))
|
||||
if iface_name not in current_ifaces:
|
||||
current_ifaces.append(iface_name)
|
||||
zone["interfaces"] = current_ifaces
|
||||
changes.append(
|
||||
f"Assigned interface '{iface_name}' to zone '{zone_name}'"
|
||||
)
|
||||
|
||||
# Ensure masquerade
|
||||
if not zone.get("masquerade"):
|
||||
zone["masquerade"] = True
|
||||
changes.append(f"Enabled masquerade on zone '{zone_name}'")
|
||||
|
||||
# Ensure UDP port rule
|
||||
rich_rules = list(zone.get("rich_rules", []))
|
||||
udp_rule_str = f'rule family="ipv4" port protocol="udp" port="{listen_port}" accept'
|
||||
udp_rule = {
|
||||
"rule": udp_rule_str,
|
||||
"_source": "wg",
|
||||
}
|
||||
rule_strings = {r.get("rule") for r in rich_rules}
|
||||
if udp_rule_str not in rule_strings:
|
||||
rich_rules.append(udp_rule)
|
||||
zone["rich_rules"] = rich_rules
|
||||
changes.append(
|
||||
f"Added UDP {listen_port} accept rule to zone '{zone_name}'"
|
||||
)
|
||||
|
||||
# LAN access rules: add inter-zone accept rules for internal
|
||||
# subnets derived from firewall zones that have masquerade=false
|
||||
if lan_access:
|
||||
cls._add_lan_rules(zone, fw_cfg, changes, zone_name)
|
||||
|
||||
zones[zone_name] = zone
|
||||
|
||||
# --- Legacy single-interface zone (back compat) ---
|
||||
# When peers exist without access_class, manage a "vpn" zone
|
||||
unassigned_peers = {
|
||||
n: p
|
||||
for n, p in wg_cfg.get("peers", {}).items()
|
||||
if isinstance(p, dict) and not p.get("access_class")
|
||||
}
|
||||
wg_iface = wg_cfg.get("interface", {}).get("name", "wg0")
|
||||
if unassigned_peers:
|
||||
vpn_zone = zones.get("vpn", {})
|
||||
if not isinstance(vpn_zone, dict):
|
||||
vpn_zone = {}
|
||||
zones["vpn"] = vpn_zone
|
||||
|
||||
# Ensure interface is assigned
|
||||
current_ifaces = list(vpn_zone.get("interfaces", []))
|
||||
if wg_iface not in current_ifaces:
|
||||
current_ifaces.append(wg_iface)
|
||||
vpn_zone["interfaces"] = current_ifaces
|
||||
changes.append(f"Assigned interface '{wg_iface}' to zone 'vpn'")
|
||||
|
||||
# Ensure masquerade
|
||||
if not vpn_zone.get("masquerade"):
|
||||
vpn_zone["masquerade"] = True
|
||||
changes.append("Enabled masquerade on zone 'vpn'")
|
||||
|
||||
# Ensure UDP 51820 rich rule exists
|
||||
rich_rules = list(vpn_zone.get("rich_rules", []))
|
||||
expected_rule = {
|
||||
"rule": 'rule family="ipv4" port protocol="udp" port="51820" accept',
|
||||
"_source": "wg",
|
||||
}
|
||||
udp_rule_str = (
|
||||
'rule family="ipv4" port protocol="udp" port="51820" accept'
|
||||
)
|
||||
udp_rule = {"rule": udp_rule_str, "_source": "wg"}
|
||||
rule_strings = {r.get("rule") for r in rich_rules}
|
||||
if expected_rule["rule"] not in rule_strings:
|
||||
rich_rules.append(expected_rule)
|
||||
if udp_rule_str not in rule_strings:
|
||||
rich_rules.append(udp_rule)
|
||||
vpn_zone["rich_rules"] = rich_rules
|
||||
changes.append("Added UDP 51820 accept rich rule to zone 'vpn'")
|
||||
changes.append("Added UDP 51820 accept rule to zone 'vpn'")
|
||||
|
||||
zones["vpn"] = vpn_zone
|
||||
|
||||
# Add inter-zone rules for peer allowed_ips subnets
|
||||
cls._sync_allowed_ips(wg_cfg, vpn_zone, zones, changes)
|
||||
|
||||
zones["vpn"] = vpn_zone
|
||||
else:
|
||||
# Not active — selectively clean up WireGuard-created entries
|
||||
# from the vpn zone without removing the zone itself.
|
||||
# --- Cleanup: remove empty class zones ---
|
||||
has_any_peers = bool(wg_cfg.get("peers"))
|
||||
if has_any_peers:
|
||||
for zone_name in list(zones.keys()):
|
||||
if not zone_name.startswith("vpn-"):
|
||||
continue
|
||||
ckey = zone_name[4:]
|
||||
if ckey and ckey not in active_class_keys:
|
||||
zone = zones[zone_name]
|
||||
if isinstance(zone, dict):
|
||||
rules = [
|
||||
r
|
||||
for r in zone.get("rich_rules", [])
|
||||
if not (
|
||||
isinstance(r, dict) and r.get("_source") == "wg"
|
||||
)
|
||||
]
|
||||
cleaned = False
|
||||
if len(rules) < len(zone.get("rich_rules", [])):
|
||||
zone["rich_rules"] = rules
|
||||
cleaned = True
|
||||
if zone.get("masquerade"):
|
||||
zone["masquerade"] = False
|
||||
cleaned = True
|
||||
if zone.get("interfaces"):
|
||||
zone["interfaces"] = []
|
||||
cleaned = True
|
||||
if cleaned:
|
||||
changes.append(
|
||||
f"Cleaned up stale rules from zone '{zone_name}'"
|
||||
)
|
||||
elif not unassigned_peers and not active_class_keys:
|
||||
# WireGuard inactive — clean up legacy vpn zone
|
||||
vpn_zone = zones.get("vpn")
|
||||
if not isinstance(vpn_zone, dict):
|
||||
pass
|
||||
else:
|
||||
# Remove wg interface from vpn zone
|
||||
wg_iface = wg_cfg.get("interface", {}).get("name", "")
|
||||
current_ifaces = list(vpn_zone.get("interfaces", []))
|
||||
if wg_iface and wg_iface in current_ifaces:
|
||||
current_ifaces.remove(wg_iface)
|
||||
@@ -551,19 +653,15 @@ class WgToFirewallSync:
|
||||
changes.append(
|
||||
f"Removed interface '{wg_iface}' from zone 'vpn'"
|
||||
)
|
||||
# Also clean up any residual wg0 that was in the original vpn zone but
|
||||
# is no longer the configured WireGuard interface
|
||||
if "wg0" in current_ifaces and (wg_iface or "") != "wg0":
|
||||
if "wg0" in current_ifaces and wg_iface != "wg0":
|
||||
current_ifaces.remove("wg0")
|
||||
vpn_zone["interfaces"] = current_ifaces
|
||||
changes.append("Removed interface 'wg0' from zone 'vpn'")
|
||||
|
||||
# Disable masquerade (only WireGuard relied on it)
|
||||
if vpn_zone.get("masquerade"):
|
||||
vpn_zone["masquerade"] = False
|
||||
changes.append("Disabled masquerade on zone 'vpn'")
|
||||
|
||||
# Remove WireGuard-specific rich rules (only those with _source="wg")
|
||||
rich_rules = list(vpn_zone.get("rich_rules", []))
|
||||
wg_rule_ids: set[int] = set()
|
||||
for idx, r in enumerate(rich_rules):
|
||||
@@ -575,11 +673,12 @@ class WgToFirewallSync:
|
||||
]
|
||||
vpn_zone["rich_rules"] = rich_rules
|
||||
changes.append(
|
||||
f"Removed {len(wg_rule_ids)} WireGuard rich rule(s) from zone 'vpn'"
|
||||
f"Removed {len(wg_rule_ids)} WireGuard rich rule(s) "
|
||||
f"from zone 'vpn'"
|
||||
)
|
||||
|
||||
fw_cfg["zones"] = zones
|
||||
if changes:
|
||||
fw_cfg["zones"] = zones
|
||||
_save_fw_cfg(fw_cfg)
|
||||
return SyncResult(
|
||||
affected_subsystems=["firewall"],
|
||||
@@ -590,6 +689,55 @@ class WgToFirewallSync:
|
||||
logger.exception("WgToFirewallSync failed")
|
||||
return SyncResult()
|
||||
|
||||
@staticmethod
|
||||
def _add_lan_rules(
|
||||
zone: dict[str, Any],
|
||||
fw_cfg: dict[str, Any],
|
||||
changes: list[str],
|
||||
zone_name: str,
|
||||
) -> None:
|
||||
"""Add inter-zone accept rules for internal LAN subnets."""
|
||||
rich_rules = list(zone.get("rich_rules", []))
|
||||
rule_strings = {r.get("rule") for r in rich_rules}
|
||||
|
||||
# Collect internal subnets from zones without masquerade (except VPN zones)
|
||||
for zname, zdata in fw_cfg.get("zones", {}).items():
|
||||
if not isinstance(zdata, dict):
|
||||
continue
|
||||
if zname.startswith("vpn"):
|
||||
continue
|
||||
if zdata.get("masquerade"):
|
||||
continue
|
||||
for iface_name in zdata.get("interfaces", []):
|
||||
# Try to get the subnet from network state
|
||||
net_state = _state_store.get("networkd")
|
||||
if net_state:
|
||||
for if_key, if_data in net_state.get("interfaces", {}).items():
|
||||
if isinstance(if_data, dict) and if_key == iface_name:
|
||||
for addr in if_data.get("addresses", []):
|
||||
if isinstance(addr, dict):
|
||||
addr_str = addr.get("address", "")
|
||||
else:
|
||||
addr_str = str(addr)
|
||||
if "/" in addr_str:
|
||||
rule_str = (
|
||||
f'rule family="ipv4" destination '
|
||||
f'address="{addr_str}" accept'
|
||||
)
|
||||
if rule_str not in rule_strings:
|
||||
rule_entry = {
|
||||
"rule": rule_str,
|
||||
"_source": "wg",
|
||||
}
|
||||
rich_rules.append(rule_entry)
|
||||
rule_strings.add(rule_str)
|
||||
changes.append(
|
||||
f"Added inter-zone rule for '{addr_str}' "
|
||||
f"to zone '{zone_name}' (LAN access)"
|
||||
)
|
||||
|
||||
zone["rich_rules"] = rich_rules
|
||||
|
||||
|
||||
class FirewallToDhcpSync:
|
||||
"""Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges.
|
||||
@@ -878,7 +1026,7 @@ _bus.subscribe(
|
||||
targets={"dnsmasq"},
|
||||
)
|
||||
_bus.subscribe(
|
||||
"network",
|
||||
"networkd",
|
||||
"config_saved",
|
||||
NetworkToAllSync.on_network_config_saved,
|
||||
targets={"firewall", "dnsmasq"},
|
||||
|
||||
+413
-86
@@ -1,7 +1,8 @@
|
||||
"""WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
|
||||
|
||||
Generates wg-quick configurations, manages peers, and controls
|
||||
the WireGuard tunnel interface.
|
||||
the WireGuard tunnel interface. Supports multi-interface mode where
|
||||
each access class gets its own WireGuard interface.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -19,7 +20,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
|
||||
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
|
||||
WG_QUICK_BIN = "wg-quick"
|
||||
WG_BIN = "wg"
|
||||
|
||||
@@ -30,6 +30,25 @@ ENV = Environment(
|
||||
trim_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
def _wg_conf_path(ifname: str) -> str:
|
||||
"""Return the system WG conf path for an interface name."""
|
||||
return f"/etc/wireguard/{ifname}.conf"
|
||||
|
||||
|
||||
def _default_class_fields() -> dict[str, Any]:
|
||||
"""Return the default set of fields for an access class entry."""
|
||||
return {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"subnet": None,
|
||||
"listen_port": None,
|
||||
"lan_access": False,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"interface": {
|
||||
"name": "wg0",
|
||||
@@ -37,9 +56,31 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
"addresses": ["10.137.0.1/24"],
|
||||
"server_endpoint": "",
|
||||
"description": "",
|
||||
"post_up": None,
|
||||
"post_down": None,
|
||||
},
|
||||
"access_classes": {
|
||||
"full": {
|
||||
"name": "Full LAN Access",
|
||||
"description": "Peers get full access to internal networks",
|
||||
"subnet": "10.137.0.0/24",
|
||||
"listen_port": 51820,
|
||||
"lan_access": True,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
},
|
||||
"internet": {
|
||||
"name": "Internet Only",
|
||||
"description": "Peers can only reach the internet",
|
||||
"subnet": "10.137.1.0/24",
|
||||
"listen_port": 51821,
|
||||
"lan_access": False,
|
||||
"private_key": "",
|
||||
"public_key": "",
|
||||
},
|
||||
},
|
||||
"peers": {},
|
||||
}
|
||||
|
||||
@@ -62,23 +103,114 @@ def save_config(cfg: dict[str, Any]) -> None:
|
||||
|
||||
def generate_keypair() -> tuple[str, str]:
|
||||
"""Generate a WireGuard private/public key pair using ``wg`` CLI."""
|
||||
res = run_proc([WG_BIN, "genkey"], sudo=True)
|
||||
res = run_proc([WG_BIN, "genkey"], sudo=False)
|
||||
private_key = res.stdout.strip()
|
||||
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key)
|
||||
res2 = run_proc([WG_BIN, "pubkey"], sudo=False, input=private_key)
|
||||
public_key = res2.stdout.strip()
|
||||
return private_key, public_key
|
||||
|
||||
|
||||
# --- wg0.conf generation ---
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
def _class_interface_name(class_key: str) -> str:
|
||||
"""Derive interface name for an access class key."""
|
||||
return f"wg-{class_key}"
|
||||
|
||||
|
||||
def _class_zone_name(class_key: str) -> str:
|
||||
"""Derive firewall zone name for an access class key."""
|
||||
return f"vpn-{class_key}"
|
||||
|
||||
|
||||
def get_class_interface_name(class_key: str) -> str:
|
||||
"""Public wrapper for `_class_interface_name`."""
|
||||
return _class_interface_name(class_key)
|
||||
|
||||
|
||||
def get_class_zone_name(class_key: str) -> str:
|
||||
"""Public wrapper for `_class_zone_name`."""
|
||||
return _class_zone_name(class_key)
|
||||
|
||||
|
||||
def _class_peers(cfg: dict[str, Any], class_key: str) -> dict[str, Any]:
|
||||
"""Return peers assigned to a given access class."""
|
||||
return {
|
||||
name: info
|
||||
for name, info in cfg.get("peers", {}).items()
|
||||
if isinstance(info, dict) and info.get("access_class") == class_key
|
||||
}
|
||||
|
||||
|
||||
# --- wg-<class>.conf generation ---
|
||||
|
||||
|
||||
def generate_class_conf(cfg: dict[str, Any], class_key: str) -> str | None:
|
||||
"""Render a wg-quick config file for a single access class.
|
||||
|
||||
Returns ``None`` when the class has no peers assigned.
|
||||
"""
|
||||
classes = cfg.get("access_classes", {})
|
||||
class_cfg = classes.get(class_key)
|
||||
if not class_cfg or not isinstance(class_cfg, dict):
|
||||
return None
|
||||
|
||||
peers = _class_peers(cfg, class_key)
|
||||
if not peers:
|
||||
return None
|
||||
|
||||
ifname = _class_interface_name(class_key)
|
||||
subnet = class_cfg.get("subnet")
|
||||
if not subnet:
|
||||
subnet = "10.137.0.0/24"
|
||||
|
||||
_, prefix = subnet.rsplit("/", 1)
|
||||
base = subnet.rsplit(".", 1)[0]
|
||||
addr = f"{base}.1/{prefix}"
|
||||
|
||||
listen_port = class_cfg.get("listen_port")
|
||||
if not listen_port:
|
||||
listen_port = 51820
|
||||
|
||||
private_key = class_cfg.get("private_key", "")
|
||||
if not private_key:
|
||||
raise ValueError(
|
||||
f"Access class '{class_key}' has no private key — generate one first."
|
||||
)
|
||||
|
||||
class_iface = {
|
||||
"name": ifname,
|
||||
"listen_port": listen_port,
|
||||
"private_key": private_key,
|
||||
"addresses": [addr],
|
||||
"post_up": cfg.get("interface", {}).get("post_up"),
|
||||
"post_down": cfg.get("interface", {}).get("post_down"),
|
||||
}
|
||||
|
||||
tmpl = ENV.get_template("wireguard.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interface=class_iface,
|
||||
peers=peers,
|
||||
)
|
||||
|
||||
|
||||
def generate_conf(cfg: dict[str, Any]) -> str:
|
||||
"""Render a valid wg-quick config file from *cfg* using Jinja2."""
|
||||
"""Render a valid wg-quick config file from *cfg* using Jinja2.
|
||||
|
||||
Legacy single-interface mode — uses the top-level ``interface`` block
|
||||
and peers without an ``access_class`` assigned.
|
||||
"""
|
||||
fallback_peers = {
|
||||
n: p
|
||||
for n, p in cfg.get("peers", {}).items()
|
||||
if isinstance(p, dict) and not p.get("access_class")
|
||||
}
|
||||
tmpl = ENV.get_template("wireguard.conf")
|
||||
return tmpl.render(
|
||||
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
interface=cfg["interface"],
|
||||
peers=cfg.get("peers", {}),
|
||||
peers=fallback_peers if fallback_peers else {},
|
||||
)
|
||||
|
||||
|
||||
@@ -86,55 +218,187 @@ def generate_conf(cfg: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def apply() -> None:
|
||||
"""Write the current config to disk and bring the tunnel up with wg-quick."""
|
||||
"""Write the current config to disk and bring tunnels up with wg-quick.
|
||||
|
||||
In multi-interface mode, applies each access class's interface independently.
|
||||
Falls back to legacy single-interface mode when no classes have peers.
|
||||
"""
|
||||
cfg = get_config()
|
||||
classes = cfg.get("access_classes", {})
|
||||
applied = False
|
||||
|
||||
for class_key in classes:
|
||||
class_cfg = classes.get(class_key)
|
||||
if not class_cfg or not isinstance(class_cfg, dict):
|
||||
continue
|
||||
if not _class_peers(cfg, class_key):
|
||||
continue
|
||||
|
||||
try:
|
||||
conf_text = generate_class_conf(cfg, class_key)
|
||||
if not conf_text:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
ifname = _class_interface_name(class_key)
|
||||
conf_path = _wg_conf_path(ifname)
|
||||
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_tmp = local_dir / f"{ifname}.conf.tmp"
|
||||
with open(local_tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
|
||||
run(["chown", "root:root", conf_path], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
|
||||
applied = True
|
||||
|
||||
if applied:
|
||||
save_config(cfg)
|
||||
elif applied is False:
|
||||
# Legacy single-interface fallback
|
||||
legacy_apply(cfg)
|
||||
|
||||
|
||||
def legacy_apply(cfg: dict[str, Any]) -> None:
|
||||
"""Legacy single-interface apply."""
|
||||
conf_text = generate_conf(cfg)
|
||||
save_config(cfg)
|
||||
|
||||
ifname = cfg["interface"]["name"]
|
||||
conf_path = _wg_conf_path(ifname)
|
||||
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_tmp = local_dir / "wg0.conf.tmp"
|
||||
local_tmp = local_dir / f"{ifname}.conf.tmp"
|
||||
with open(local_tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
|
||||
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
|
||||
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
|
||||
run(["chown", "root:root", conf_path], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought up", ifname)
|
||||
|
||||
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
|
||||
|
||||
def apply_class(class_key: str) -> None:
|
||||
"""Apply config for a single access class interface."""
|
||||
cfg = get_config()
|
||||
class_cfg = cfg.get("access_classes", {}).get(class_key)
|
||||
if not class_cfg or not isinstance(class_cfg, dict):
|
||||
raise ValueError(f"Access class '{class_key}' not found")
|
||||
conf_text = generate_class_conf(cfg, class_key)
|
||||
if not conf_text:
|
||||
raise ValueError(f"No peers assigned to class '{class_key}'")
|
||||
ifname = _class_interface_name(class_key)
|
||||
conf_path = _wg_conf_path(ifname)
|
||||
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_tmp = local_dir / f"{ifname}.conf.tmp"
|
||||
with open(local_tmp, "w") as f:
|
||||
f.write(conf_text)
|
||||
os.chmod(local_tmp, 0o600)
|
||||
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
|
||||
run(["chown", "root:root", conf_path], sudo=True, check=False)
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", ifname], sudo=True)
|
||||
save_config(cfg)
|
||||
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
|
||||
|
||||
|
||||
def down() -> None:
|
||||
"""Bring the WireGuard tunnel interface down."""
|
||||
"""Bring all WireGuard tunnel interfaces down.
|
||||
|
||||
In multi-interface mode, brings down each class interface with peers.
|
||||
"""
|
||||
cfg = get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
run([WG_QUICK_BIN, "down", name], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", name)
|
||||
classes = cfg.get("access_classes", {})
|
||||
for class_key in classes:
|
||||
class_cfg = classes.get(class_key)
|
||||
if not class_cfg or not isinstance(class_cfg, dict):
|
||||
continue
|
||||
if ifname := _class_interface_name(class_key):
|
||||
try:
|
||||
run([WG_QUICK_BIN, "down", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", ifname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also try legacy interface (skip if name matches any class interface)
|
||||
ifname = cfg["interface"].get("name", "")
|
||||
if ifname:
|
||||
class_names = {_class_interface_name(k) for k in classes}
|
||||
if ifname not in class_names:
|
||||
try:
|
||||
run([WG_QUICK_BIN, "down", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", ifname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def down_class(class_key: str) -> None:
|
||||
"""Bring down a single access class interface."""
|
||||
ifname = _class_interface_name(class_key)
|
||||
run([WG_QUICK_BIN, "down", ifname], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", ifname)
|
||||
|
||||
|
||||
# --- Status ---
|
||||
|
||||
|
||||
def status() -> dict[str, Any]:
|
||||
"""Query the live tunnel state via ``wg show``."""
|
||||
"""Query the live tunnel state via ``wg show``.
|
||||
|
||||
In multi-interface mode, collects status for all class interfaces.
|
||||
Returns combined status dict keyed by interface name.
|
||||
"""
|
||||
cfg = get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
result: dict[str, Any] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
"classes": {},
|
||||
}
|
||||
|
||||
# Collect per-class status
|
||||
classes = cfg.get("access_classes", {})
|
||||
for class_key in classes:
|
||||
ifname = _class_interface_name(class_key)
|
||||
try:
|
||||
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
|
||||
if res.returncode != 0:
|
||||
result["classes"][class_key] = {"up": False, "peers": []}
|
||||
continue
|
||||
class_status = _parse_wg_show_output(res.stdout.strip())
|
||||
result["classes"][class_key] = class_status
|
||||
if class_status["up"]:
|
||||
result["up"] = True
|
||||
except Exception:
|
||||
result["classes"][class_key] = {"up": False, "peers": []}
|
||||
|
||||
# Legacy single-interface status (still collected for backward compat)
|
||||
try:
|
||||
ifname = cfg["interface"].get("name", "wg0")
|
||||
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
|
||||
if res.returncode == 0:
|
||||
parsed = _parse_wg_show_output(res.stdout.strip())
|
||||
result["up"] = parsed["up"]
|
||||
result["interface"] = parsed.get("interface", {})
|
||||
result["peers"] = parsed.get("peers", [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_wg_show_output(raw: str) -> dict[str, Any]:
|
||||
"""Parse ``wg show`` output into structured dict."""
|
||||
result: dict[str, Any] = {
|
||||
"up": False,
|
||||
"interface": {},
|
||||
"peers": [],
|
||||
}
|
||||
|
||||
try:
|
||||
res = run_proc([WG_BIN, "show", name], sudo=True, check=False)
|
||||
if res.returncode != 0:
|
||||
return result
|
||||
|
||||
raw = res.stdout.strip()
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
current_peer: dict[str, Any] | None = None
|
||||
peers: list[dict[str, Any]] = []
|
||||
|
||||
@@ -169,8 +433,8 @@ def status() -> dict[str, Any]:
|
||||
"endpoint": None,
|
||||
"allowed_ips": [],
|
||||
"latest_handshake": None,
|
||||
"transfer_received": 0,
|
||||
"transfer_sent": 0,
|
||||
"transfer_received": "0",
|
||||
"transfer_sent": "0",
|
||||
"persistent_keepalive": None,
|
||||
}
|
||||
peers.append(current_peer)
|
||||
@@ -214,36 +478,50 @@ def status() -> dict[str, Any]:
|
||||
|
||||
# --- Peer management ---
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def add_peer(
|
||||
name: str,
|
||||
endpoint: str | None = None,
|
||||
allowed_ips: list[str] | None = None,
|
||||
persistent_keepalive: int | None = None,
|
||||
endpoint: str | None | object = _UNSET,
|
||||
allowed_ips: list[str] | None | object = _UNSET,
|
||||
persistent_keepalive: int | None | object = _UNSET,
|
||||
preshared_key: str | None = None,
|
||||
description: str | None = None,
|
||||
access_class: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add (or update) a peer in the configuration."""
|
||||
cfg = get_config()
|
||||
peers = cfg.setdefault("peers", {})
|
||||
allowed_ips = allowed_ips or []
|
||||
|
||||
if name in peers:
|
||||
peer = peers[name]
|
||||
peer["endpoint"] = endpoint
|
||||
peer["allowed_ips"] = allowed_ips
|
||||
peer["persistent_keepalive"] = persistent_keepalive
|
||||
if endpoint is not _UNSET:
|
||||
peer["endpoint"] = endpoint
|
||||
if allowed_ips is not _UNSET:
|
||||
peer["allowed_ips"] = allowed_ips if allowed_ips is not None else []
|
||||
if persistent_keepalive is not _UNSET:
|
||||
peer["persistent_keepalive"] = persistent_keepalive
|
||||
if preshared_key is not None:
|
||||
peer["preshared_key"] = preshared_key
|
||||
if description is not None:
|
||||
peer["description"] = description
|
||||
if access_class is not None:
|
||||
peer["access_class"] = access_class
|
||||
logger.info("WireGuard peer '%s' updated", name)
|
||||
else:
|
||||
priv, pub = generate_keypair()
|
||||
peer = {
|
||||
"public_key": pub,
|
||||
"private_key": priv,
|
||||
"endpoint": endpoint,
|
||||
"allowed_ips": allowed_ips,
|
||||
"persistent_keepalive": persistent_keepalive,
|
||||
"endpoint": endpoint if endpoint is not _UNSET else None,
|
||||
"allowed_ips": allowed_ips if allowed_ips is not _UNSET else [],
|
||||
"persistent_keepalive": persistent_keepalive
|
||||
if persistent_keepalive is not _UNSET
|
||||
else None,
|
||||
"preshared_key": preshared_key,
|
||||
"description": description,
|
||||
"access_class": access_class,
|
||||
}
|
||||
peers[name] = peer
|
||||
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
|
||||
@@ -275,9 +553,17 @@ def get_peers() -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def get_peer_status() -> list[dict[str, Any]]:
|
||||
"""Return live peer status from ``wg show``."""
|
||||
"""Return live peer status from ``wg show`` for all interfaces."""
|
||||
st = status()
|
||||
return st.get("peers", [])
|
||||
all_peers: list[dict[str, Any]] = []
|
||||
for _class_key, class_st in st.get("classes", {}).items():
|
||||
for p in class_st.get("peers", []):
|
||||
merged = dict(p)
|
||||
merged["access_class"] = _class_key
|
||||
all_peers.append(merged)
|
||||
if not all_peers:
|
||||
all_peers = st.get("peers", [])
|
||||
return all_peers
|
||||
|
||||
|
||||
# --- Client config generation ---
|
||||
@@ -286,9 +572,13 @@ def get_peer_status() -> list[dict[str, Any]]:
|
||||
def generate_client_conf(
|
||||
peer_name: str,
|
||||
server_endpoint: str,
|
||||
server_pubkey: str,
|
||||
server_pubkey: str | None = None,
|
||||
) -> str:
|
||||
"""Build a client-side wg-quick config snippet for *peer_name*."""
|
||||
"""Build a client-side wg-quick config snippet for *peer_name*.
|
||||
|
||||
Uses the peer's access class to derive server address from the class
|
||||
subnet and the class's listen port.
|
||||
"""
|
||||
cfg = get_config()
|
||||
iface = cfg["interface"]
|
||||
peer = cfg["peers"].get(peer_name)
|
||||
@@ -301,12 +591,30 @@ def generate_client_conf(
|
||||
f"Peer '{peer_name}' has no private key — cannot generate client config."
|
||||
)
|
||||
|
||||
# Determine class info for address/port
|
||||
access_class = peer.get("access_class")
|
||||
sorted_peers = sorted(cfg.get("peers", {}).keys())
|
||||
peer_index = sorted_peers.index(peer_name) + 2
|
||||
srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24"
|
||||
addr_part, prefix = srv_addr.rsplit("/", 1)
|
||||
prefix_base = addr_part.rsplit(".", 1)[0]
|
||||
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
|
||||
|
||||
if access_class and access_class in cfg.get("access_classes", {}):
|
||||
class_cfg = cfg["access_classes"][access_class]
|
||||
subnet = class_cfg.get("subnet", "10.137.0.0/24")
|
||||
listen_port = class_cfg.get("listen_port", 51820)
|
||||
else:
|
||||
subnet = iface.get("addresses", ["10.137.0.1/24"])[0]
|
||||
listen_port = iface.get("listen_port", 51820)
|
||||
|
||||
if "/" not in subnet:
|
||||
subnet = f"{subnet}/24"
|
||||
addr = subnet.rsplit(".", 1)[0]
|
||||
prefix = subnet.rsplit("/", 1)[1]
|
||||
client_addr = f"{addr}.{peer_index}/{prefix}"
|
||||
|
||||
sk = server_pubkey or iface.get("public_key", "")
|
||||
ep = server_endpoint or iface.get("server_endpoint", "")
|
||||
if ep and listen_port:
|
||||
host = ep.split(":")[0]
|
||||
ep = f"{host}:{listen_port}"
|
||||
|
||||
tmpl = ENV.get_template("wireguard-client.conf")
|
||||
conf = tmpl.render(
|
||||
@@ -314,8 +622,8 @@ def generate_client_conf(
|
||||
peer_name=peer_name,
|
||||
client_priv=client_priv,
|
||||
client_addr=client_addr,
|
||||
server_pubkey=server_pubkey,
|
||||
server_endpoint=server_endpoint,
|
||||
server_pubkey=sk,
|
||||
server_endpoint=ep,
|
||||
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
|
||||
preshared_key=peer.get("preshared_key"),
|
||||
persistent_keepalive=peer.get("persistent_keepalive"),
|
||||
@@ -351,66 +659,85 @@ def set_post_down(cmd: str | None) -> None:
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
# --- Class key generation ---
|
||||
|
||||
|
||||
def generate_class_keypair(class_key: str) -> tuple[str, str]:
|
||||
"""Generate a key pair for an access class interface."""
|
||||
cfg = get_config()
|
||||
classes = cfg.setdefault("access_classes", {})
|
||||
if class_key not in classes:
|
||||
raise ValueError(f"Access class '{class_key}' not found")
|
||||
class_cfg = classes[class_key]
|
||||
if class_cfg.get("private_key"):
|
||||
return class_cfg["private_key"], class_cfg["public_key"]
|
||||
priv, pub = generate_keypair()
|
||||
class_cfg["private_key"] = priv
|
||||
class_cfg["public_key"] = pub
|
||||
save_config(cfg)
|
||||
logger.info("Key pair generated for class '%s'", class_key)
|
||||
return priv, pub
|
||||
|
||||
|
||||
# --- Initialise ---
|
||||
|
||||
|
||||
def _ensure_class_defaults(cfg: dict[str, Any]) -> None:
|
||||
"""Ensure access classes have required Phase-2 fields."""
|
||||
classes = cfg.setdefault("access_classes", {})
|
||||
for _key, c in classes.items():
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
for field, default in _default_class_fields().items():
|
||||
if field not in c:
|
||||
c[field] = default
|
||||
|
||||
|
||||
def _ensure_access_classes(cfg: dict[str, Any]) -> None:
|
||||
"""Pre-seed default access classes if missing or empty (idempotent).
|
||||
|
||||
Also upgrades existing classes with Phase-2 fields.
|
||||
"""
|
||||
classes = cfg.setdefault("access_classes", {})
|
||||
if not classes:
|
||||
full_defaults = deepcopy(DEFAULT_CONFIG["access_classes"])
|
||||
classes.update(full_defaults)
|
||||
return
|
||||
_ensure_class_defaults(cfg)
|
||||
|
||||
|
||||
def initialize() -> dict[str, Any]:
|
||||
"""Perform first-time WireGuard setup."""
|
||||
cfg = get_config()
|
||||
|
||||
if cfg["interface"].get("private_key"):
|
||||
_ensure_access_classes(cfg)
|
||||
save_config(cfg)
|
||||
return cfg
|
||||
|
||||
priv, pub = generate_keypair()
|
||||
cfg["interface"]["private_key"] = priv
|
||||
cfg["interface"]["public_key"] = pub
|
||||
_ensure_access_classes(cfg)
|
||||
save_config(cfg)
|
||||
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
|
||||
return cfg
|
||||
|
||||
|
||||
# --- Utility: parse wg show into structured peer map ---
|
||||
|
||||
|
||||
def _parse_wg_show(output: str) -> dict[str, Any]:
|
||||
"""Internal parser for ``wg show`` multiline output."""
|
||||
peers: dict[str, dict[str, Any]] = {}
|
||||
current: dict[str, Any] | None = None
|
||||
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("peer:"):
|
||||
key = line.split(":", 1)[1].strip()
|
||||
current = {"_key": key}
|
||||
peers[key] = current
|
||||
continue
|
||||
|
||||
if current is None:
|
||||
continue
|
||||
|
||||
if line.startswith("endpoint:"):
|
||||
val = line.split(":", 1)[1].strip()
|
||||
current["endpoint"] = val
|
||||
elif line.startswith("allowed ips:"):
|
||||
current["allowed_ips"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("latest handshake:"):
|
||||
current["latest_handshake"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("transfer:"):
|
||||
current["transfer_raw"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("persistent-keepalive:"):
|
||||
current["persistent_keepalive"] = line.split(":", 1)[1].strip()
|
||||
|
||||
return peers
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CONFIG",
|
||||
"add_peer",
|
||||
"apply",
|
||||
"apply_class",
|
||||
"down",
|
||||
"down_class",
|
||||
"generate_class_conf",
|
||||
"generate_class_keypair",
|
||||
"generate_client_conf",
|
||||
"generate_conf",
|
||||
"generate_keypair",
|
||||
"get_class_interface_name",
|
||||
"get_class_zone_name",
|
||||
"get_config",
|
||||
"get_peer_status",
|
||||
"get_peers",
|
||||
|
||||
Reference in New Issue
Block a user