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:
+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"},
|
||||
|
||||
Reference in New Issue
Block a user