04417cf05c
- 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
1047 lines
40 KiB
Python
1047 lines
40 KiB
Python
"""Cross-subsystem sync event bus for Vacuum Wall.
|
|
|
|
Subsystems emit events after mutations, and subscribers react by keeping
|
|
other subsystems' configs in sync. An in-process event bus decouples
|
|
subsystems with no direct cross-calls between handlers.
|
|
"""
|
|
|
|
import logging
|
|
from collections.abc import Callable
|
|
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__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Event / result types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SyncEvent:
|
|
"""An event emitted after a subsystem mutation.
|
|
|
|
Attributes:
|
|
subsystem: Originating subsystem name.
|
|
action: Action type (currently only ``"config_saved"``).
|
|
payload: Context data describing the mutation.
|
|
"""
|
|
|
|
subsystem: str
|
|
action: str
|
|
payload: dict = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class SyncResult:
|
|
"""Outcome of processing one or more subscribers for an event.
|
|
|
|
Attributes:
|
|
affected_subsystems: Subsystems whose configs changed.
|
|
changes: Human-readable descriptions of what changed.
|
|
applied: Whether any external command was invoked.
|
|
"""
|
|
|
|
affected_subsystems: list[str] = field(default_factory=list)
|
|
changes: list[str] = field(default_factory=list)
|
|
applied: bool = False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Type aliases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SyncHandler = Callable[[SyncEvent], SyncResult | None]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EventBus
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class EventBus:
|
|
"""Registry-based event system for cross-subsystem sync.
|
|
|
|
Subscribers register with ``(subsystem, action)`` pairs. ``emit()``
|
|
dispatches to all matching subscribers.
|
|
|
|
Guard rails:
|
|
- Idempotency: tracks ``(subsystem, action)`` per dispatch cycle.
|
|
Re-entrant emits for the same key are silently dropped.
|
|
- Error containment: subscriber exceptions are caught, logged, and
|
|
never abort the originating handler.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._subscribers: dict[tuple[str, str], list[SyncHandler]] = {}
|
|
self._targets: dict[str, set[str]] = {}
|
|
self._emitting: set[tuple[str, str]] | None = None
|
|
|
|
@property
|
|
def _current_emitting(self) -> set[tuple[str, str]]:
|
|
"""Get or create the per-dispatch cycle set."""
|
|
if self._emitting is None:
|
|
self._emitting = set()
|
|
return self._emitting
|
|
|
|
def subscribe(
|
|
self,
|
|
subsystem: str,
|
|
action: str,
|
|
handler: SyncHandler,
|
|
*,
|
|
targets: set[str] | None = None,
|
|
) -> None:
|
|
"""Register *handler* for ``(subsystem, action)`` events.
|
|
|
|
Args:
|
|
subsystem: Subsystem to listen for (e.g. ``"dnsmasq"``).
|
|
action: Action to listen for (e.g. ``"config_saved"``).
|
|
handler: Callable that receives ``SyncEvent`` and returns
|
|
``SyncResult`` (or ``None`` for no-op).
|
|
targets: Subsystems this handler may affect. Used to build
|
|
the static dependency graph for ``get_affected()``.
|
|
"""
|
|
key = (subsystem, action)
|
|
self._subscribers.setdefault(key, []).append(handler)
|
|
if targets:
|
|
self._targets.setdefault(subsystem, set()).update(targets)
|
|
|
|
def emit(self, event: SyncEvent) -> SyncResult:
|
|
"""Dispatch *event* to all matching subscribers.
|
|
|
|
Tracks ``(subsystem, action)`` keys at the bus level to prevent
|
|
infinite loops from re-entrant emits.
|
|
|
|
Args:
|
|
event: The sync event to dispatch.
|
|
|
|
Returns:
|
|
Aggregated ``SyncResult`` from all subscribers.
|
|
"""
|
|
return self._dispatch(event)
|
|
|
|
def _dispatch(self, event: SyncEvent) -> SyncResult:
|
|
"""Internal dispatch with loop guard.
|
|
|
|
Returns:
|
|
Aggregated ``SyncResult``.
|
|
"""
|
|
key = (event.subsystem, event.action)
|
|
emitting = self._current_emitting
|
|
|
|
if key in emitting:
|
|
return SyncResult()
|
|
|
|
emitting.add(key)
|
|
try:
|
|
return self._dispatch_inner(event)
|
|
finally:
|
|
emitting.discard(key)
|
|
if not emitting:
|
|
self._emitting = None
|
|
|
|
def _dispatch_inner(self, event: SyncEvent) -> SyncResult:
|
|
"""Core dispatch logic (called within try/finally of _dispatch)."""
|
|
result = SyncResult()
|
|
|
|
# Iterate subscribers for this (subsystem, action) pair.
|
|
# Each subscriber is called in registration order.
|
|
for handler in self._subscribers.get((event.subsystem, event.action), []):
|
|
try:
|
|
sub_result = handler(event)
|
|
except Exception:
|
|
# Error containment: subscriber failures are logged, never abort
|
|
# the originating handler or other subscribers.
|
|
logger.warning(
|
|
"Sync subscriber %s failed for %s.%s",
|
|
_safe_name(handler),
|
|
event.subsystem,
|
|
event.action,
|
|
exc_info=True,
|
|
)
|
|
continue
|
|
|
|
if sub_result is None:
|
|
continue
|
|
|
|
# Accumulate results from this subscriber into the aggregate.
|
|
result.affected_subsystems.extend(sub_result.affected_subsystems)
|
|
result.changes.extend(sub_result.changes)
|
|
result.applied = result.applied or sub_result.applied
|
|
|
|
# Cascade: for each affected subsystem (that isn't the source),
|
|
# emit a new event so downstream subscribers react. The cascade
|
|
# event carries _cascade=source so subscribers can detect loops.
|
|
for affected in sub_result.affected_subsystems:
|
|
if affected == event.subsystem:
|
|
continue
|
|
cascade = SyncEvent(
|
|
subsystem=affected,
|
|
action=event.action,
|
|
payload={"_cascade": event.subsystem, **event.payload},
|
|
)
|
|
cascaded = self._dispatch(cascade)
|
|
result.affected_subsystems.extend(cascaded.affected_subsystems)
|
|
result.changes.extend(cascaded.changes)
|
|
result.applied = result.applied or cascaded.applied
|
|
|
|
# Dedupe affected list before returning (cascade events may cause
|
|
# the same subsystem to appear multiple times).
|
|
result.affected_subsystems = _dedupe(result.affected_subsystems)
|
|
return result
|
|
|
|
|
|
def get_affected(base: list[str], event_bus: EventBus | None = None) -> set[str]:
|
|
"""Compute the full set of affected subsystems, including transitive deps.
|
|
|
|
Walks the dependency graph built from ``subscribe`` ``targets``
|
|
parameter. If A→B and B→C, an event on A returns ``{A, B, C}``.
|
|
|
|
Args:
|
|
base: Originating subsystem(s).
|
|
event_bus: EventBus to resolve from. Defaults to module singleton.
|
|
|
|
Returns:
|
|
Set of all affected subsystem names.
|
|
"""
|
|
eb = event_bus if event_bus is not None else _bus
|
|
seen: set[str] = set(base)
|
|
queue = list(base)
|
|
while queue:
|
|
sub = queue.pop(0)
|
|
for target in eb._targets.get(sub, ()):
|
|
if target not in seen:
|
|
seen.add(target)
|
|
queue.append(target)
|
|
return seen
|
|
|
|
|
|
def _dedupe(items: list[str]) -> list[str]:
|
|
"""Remove duplicates while preserving order."""
|
|
seen: set[str] = set()
|
|
result: list[str] = []
|
|
for item in items:
|
|
if item not in seen:
|
|
seen.add(item)
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def _safe_name(obj: object) -> str:
|
|
"""Get a readable name for *obj*, falling back to repr."""
|
|
name = getattr(obj, "__name__", None)
|
|
if name:
|
|
return name
|
|
qual = getattr(obj, "__qualname__", None)
|
|
if qual:
|
|
return qual
|
|
return repr(obj)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subscriber classes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class DnsToFirewallSync:
|
|
"""Sync subscriber: dnsmasq config_saved → update firewall config.
|
|
|
|
Adds/removes dhcp and dns services on firewall zones based on
|
|
which interfaces have DHCP ranges. Back-propagates: ensures
|
|
DHCP ranges carry the gateway (interface IP) so clients get
|
|
their default route.
|
|
"""
|
|
|
|
@classmethod
|
|
def on_dnsmasq_config_saved(cls, event: SyncEvent) -> SyncResult | None:
|
|
"""Sync subscriber: dnsmasq config_saved → update firewall zone services.
|
|
|
|
When DHCP ranges are added/removed on interfaces, this subscriber
|
|
ensures the corresponding firewall zones have ``dhcp`` and ``dns``
|
|
services enabled/disabled to match. Back-propagates: ensures
|
|
DHCP ranges carry the gateway (interface IP) so clients get
|
|
their default route.
|
|
|
|
Skips processing if event originated as a cascade from ``firewall``
|
|
to prevent infinite loops.
|
|
|
|
Args:
|
|
event: Sync event with ``config_saved`` action from dnsmasq.
|
|
|
|
Returns:
|
|
SyncResult listing firewall and dnsmasq as affected subsystems,
|
|
with human-readable change descriptions. ``None`` if skipped
|
|
due to cascade guard.
|
|
"""
|
|
if event.payload.get("_cascade") == "firewall":
|
|
return None
|
|
|
|
try:
|
|
from lib.dnsmasq import (
|
|
get_config as _get_dnsmasq_cfg,
|
|
)
|
|
from lib.dnsmasq import (
|
|
save_config as _save_dnsmasq_cfg,
|
|
)
|
|
from lib.firewall import get_config as _get_fw_cfg
|
|
from lib.firewall import save_config as _save_fw_cfg
|
|
|
|
dnsmasq_cfg = _get_dnsmasq_cfg()
|
|
fw_cfg = _get_fw_cfg()
|
|
|
|
# Interfaces that serve DHCP ranges
|
|
dhcp_ifaces = {
|
|
r["interface"]
|
|
for r in dnsmasq_cfg.get("dhcp", {}).get("ranges", [])
|
|
if r.get("interface")
|
|
}
|
|
|
|
# Build interface → zone map from firewall config
|
|
iface_to_zone: dict[str, str] = {}
|
|
zones = fw_cfg.get("zones", {})
|
|
for zname, zdata in zones.items():
|
|
if not isinstance(zdata, dict):
|
|
continue
|
|
for iface in zdata.get("interfaces", []):
|
|
iface_to_zone[iface] = zname
|
|
|
|
# Zones that have at least one DHCP-served interface
|
|
active_zones: dict[str, set[str]] = {}
|
|
for iface in dhcp_ifaces:
|
|
zname = iface_to_zone.get(iface)
|
|
if zname:
|
|
active_zones.setdefault(zname, set()).add(iface)
|
|
|
|
# Determine all zone-interfaces for checking "no longer has DHCP"
|
|
zone_ifaces: dict[str, set[str]] = {}
|
|
for zname, zdata in zones.items():
|
|
if not isinstance(zdata, dict):
|
|
continue
|
|
zone_ifaces[zname] = set(zdata.get("interfaces", []))
|
|
|
|
firewall_changes: list[str] = []
|
|
dnsmasq_changes: list[str] = []
|
|
|
|
# For active zones: ensure dhcp/dns services
|
|
for zname, _ifaces in active_zones.items():
|
|
zdata = zones.get(zname, {})
|
|
if not isinstance(zdata, dict):
|
|
continue
|
|
services = list(zdata.get("services", []))
|
|
added = []
|
|
for svc in ("dhcp", "dns"):
|
|
if svc not in services:
|
|
services.append(svc)
|
|
added.append(svc)
|
|
zdata["services"] = services
|
|
if added:
|
|
firewall_changes.append(
|
|
f"Added {', '.join(added)} service(s) to zone '{zname}'"
|
|
)
|
|
|
|
# Back-propagate: ensure DHCP ranges have gateway set
|
|
cls._ensure_gateways(dnsmasq_cfg, zname, _ifaces, dnsmasq_changes)
|
|
|
|
# For inactive zones: remove dhcp/dns services
|
|
for zname, zdata in zones.items():
|
|
if not isinstance(zdata, dict):
|
|
continue
|
|
if zname in active_zones:
|
|
continue
|
|
if not zone_ifaces.get(zname):
|
|
continue
|
|
|
|
services = list(zdata.get("services", []))
|
|
removed = []
|
|
for svc in ("dhcp", "dns"):
|
|
if svc in services:
|
|
services.remove(svc)
|
|
removed.append(svc)
|
|
zdata["services"] = services
|
|
if removed:
|
|
firewall_changes.append(
|
|
f"Removed {', '.join(removed)} service(s) from zone '{zname}'"
|
|
)
|
|
|
|
result = SyncResult(changes=firewall_changes + dnsmasq_changes)
|
|
|
|
# Save firewall config if changed
|
|
if firewall_changes:
|
|
fw_cfg["zones"] = zones
|
|
_save_fw_cfg(fw_cfg)
|
|
result.affected_subsystems.append("firewall")
|
|
|
|
# Save dnsmasq config if gateways were set
|
|
if dnsmasq_changes:
|
|
_save_dnsmasq_cfg(dnsmasq_cfg)
|
|
result.affected_subsystems.append("dnsmasq")
|
|
|
|
return result
|
|
except Exception:
|
|
logger.exception("DnsToFirewallSync failed")
|
|
return SyncResult()
|
|
|
|
@staticmethod
|
|
def _ensure_gateways(
|
|
dnsmasq_cfg: dict[str, Any],
|
|
zone_name: str,
|
|
zone_ifaces: set[str],
|
|
changes: list[str],
|
|
) -> None:
|
|
"""Ensure DHCP ranges for *zone_ifaces* carry the gateway (interface IP)."""
|
|
ranges = dnsmasq_cfg.get("dhcp", {}).get("ranges", [])
|
|
for r in ranges:
|
|
iface = r.get("interface", "")
|
|
if iface not in zone_ifaces:
|
|
continue
|
|
if r.get("gateway"):
|
|
continue
|
|
ip = get_interface_ip(iface)
|
|
if ip:
|
|
r["gateway"] = ip
|
|
changes.append(
|
|
f"Set DHCP gateway {ip} for range on '{iface}' (zone '{zone_name}')"
|
|
)
|
|
|
|
|
|
class WgToFirewallSync:
|
|
"""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(
|
|
wg_cfg: dict[str, Any],
|
|
vpn_zone: dict[str, Any],
|
|
zones: dict[str, Any],
|
|
changes: list[str],
|
|
) -> None:
|
|
"""Add inter-zone rich rules for peer allowed_ips subnets."""
|
|
import re
|
|
|
|
all_subnets: set[str] = set()
|
|
for _name, peer_info in wg_cfg.get("peers", {}).items():
|
|
if not isinstance(peer_info, dict):
|
|
continue
|
|
for item in peer_info.get("allowed_ips", []):
|
|
if isinstance(item, str) and item.strip():
|
|
all_subnets.add(item.strip())
|
|
|
|
existing_rules = vpn_zone.get("rich_rules", [])
|
|
covered_subnets: set[str] = set()
|
|
for rule_entry in existing_rules:
|
|
rule_str = (
|
|
rule_entry.get("rule", "")
|
|
if isinstance(rule_entry, dict)
|
|
else str(rule_entry)
|
|
)
|
|
match = re.search(r'destination\s+address="([^"]+)"', str(rule_str))
|
|
if match:
|
|
covered_subnets.add(match.group(1))
|
|
|
|
for subnet in sorted(all_subnets):
|
|
if subnet in covered_subnets:
|
|
continue
|
|
rule_entry = {
|
|
"rule": f'rule family="ipv4" destination address="{subnet}" accept',
|
|
"_source": "wg",
|
|
}
|
|
vpn_zone.setdefault("rich_rules", []).append(rule_entry)
|
|
changes.append(
|
|
f"Added inter-zone rule for allowed_ips '{subnet}' to zone 'vpn'"
|
|
)
|
|
|
|
@classmethod
|
|
def on_wireguard_config_saved(cls, event: SyncEvent) -> SyncResult | None:
|
|
"""Sync subscriber: wireguard config_saved → update firewall zones per class.
|
|
|
|
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``.
|
|
|
|
Args:
|
|
event: Sync event with ``config_saved`` action from wireguard.
|
|
|
|
Returns:
|
|
SyncResult listing firewall as affected subsystem with change
|
|
descriptions. ``None`` if skipped due cascade guard.
|
|
"""
|
|
if event.payload.get("_cascade") == "firewall":
|
|
return None
|
|
|
|
try:
|
|
from lib.firewall import get_config as _get_fw_cfg
|
|
from lib.firewall import save_config as _save_fw_cfg
|
|
from lib.wireguard import get_config as _get_wg_cfg
|
|
|
|
wg_cfg = _get_wg_cfg()
|
|
fw_cfg = _get_fw_cfg()
|
|
zones = fw_cfg.get("zones", {})
|
|
|
|
changes: list[str] = []
|
|
active_class_keys: set[str] = set()
|
|
|
|
# --- 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
|
|
|
|
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'")
|
|
|
|
if not vpn_zone.get("masquerade"):
|
|
vpn_zone["masquerade"] = True
|
|
changes.append("Enabled masquerade on zone 'vpn'")
|
|
|
|
rich_rules = list(vpn_zone.get("rich_rules", []))
|
|
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 udp_rule_str not in rule_strings:
|
|
rich_rules.append(udp_rule)
|
|
vpn_zone["rich_rules"] = rich_rules
|
|
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)
|
|
|
|
# --- 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:
|
|
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)
|
|
vpn_zone["interfaces"] = current_ifaces
|
|
changes.append(
|
|
f"Removed interface '{wg_iface}' from zone 'vpn'"
|
|
)
|
|
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'")
|
|
|
|
if vpn_zone.get("masquerade"):
|
|
vpn_zone["masquerade"] = False
|
|
changes.append("Disabled masquerade on zone 'vpn'")
|
|
|
|
rich_rules = list(vpn_zone.get("rich_rules", []))
|
|
wg_rule_ids: set[int] = set()
|
|
for idx, r in enumerate(rich_rules):
|
|
if isinstance(r, dict) and r.get("_source") == "wg":
|
|
wg_rule_ids.add(idx)
|
|
if wg_rule_ids:
|
|
rich_rules = [
|
|
r for i, r in enumerate(rich_rules) if i not in wg_rule_ids
|
|
]
|
|
vpn_zone["rich_rules"] = rich_rules
|
|
changes.append(
|
|
f"Removed {len(wg_rule_ids)} WireGuard rich rule(s) "
|
|
f"from zone 'vpn'"
|
|
)
|
|
|
|
fw_cfg["zones"] = zones
|
|
if changes:
|
|
_save_fw_cfg(fw_cfg)
|
|
return SyncResult(
|
|
affected_subsystems=["firewall"],
|
|
changes=changes,
|
|
)
|
|
return SyncResult(changes=changes)
|
|
except Exception:
|
|
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.
|
|
|
|
Removes DHCP ranges whose interface no longer belongs to any firewall
|
|
zone. When masquerade is enabled on a zone, ensures DHCP ranges on
|
|
that zone's interfaces carry the gateway (interface IP).
|
|
Logs warnings for zones with dhcp service but no range.
|
|
"""
|
|
|
|
@classmethod
|
|
def on_firewall_config_saved(cls, event: SyncEvent) -> SyncResult | None:
|
|
"""Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges.
|
|
|
|
Removes DHCP ranges whose interface no longer belongs to any firewall
|
|
zone. When masquerade is enabled on a zone, ensures DHCP ranges on
|
|
that zone's interfaces carry the gateway (interface IP). Logs warnings
|
|
for zones with dhcp service but no range.
|
|
|
|
Skips processing if event originated as a cascade from ``dnsmasq``.
|
|
|
|
Args:
|
|
event: Sync event with ``config_saved`` action from firewall.
|
|
|
|
Returns:
|
|
SyncResult listing dnsmasq as affected subsystem when ranges were
|
|
modified, with change descriptions. ``None`` if skipped due to
|
|
cascade guard.
|
|
"""
|
|
if event.payload.get("_cascade") == "dnsmasq":
|
|
return None
|
|
|
|
try:
|
|
from lib.dnsmasq import get_config as _get_dnsmasq_cfg
|
|
from lib.dnsmasq import save_config as _save_dnsmasq_cfg
|
|
from lib.firewall import get_config as _get_fw_cfg
|
|
|
|
fw_cfg = _get_fw_cfg()
|
|
dnsmasq_cfg = _get_dnsmasq_cfg()
|
|
|
|
# Build set of all interfaces in any zone
|
|
all_zone_ifaces: set[str] = set()
|
|
zones = fw_cfg.get("zones", {})
|
|
for zdata in zones.values():
|
|
if not isinstance(zdata, dict):
|
|
continue
|
|
all_zone_ifaces.update(zdata.get("interfaces", []))
|
|
|
|
# Build set of interfaces with dhcp service enabled
|
|
dhcp_service_ifaces: set[str] = set()
|
|
for _zname, zdata in zones.items():
|
|
if not isinstance(zdata, dict):
|
|
continue
|
|
if "dhcp" in zdata.get("services", []):
|
|
dhcp_service_ifaces.update(zdata.get("interfaces", []))
|
|
|
|
# Build set of interfaces with DHCP ranges
|
|
range_ifaces = {
|
|
r["interface"]
|
|
for r in dnsmasq_cfg.get("dhcp", {}).get("ranges", [])
|
|
if r.get("interface")
|
|
}
|
|
|
|
changes: list[str] = []
|
|
|
|
# Auto-remove stale DHCP ranges (interface no longer in any zone)
|
|
stale_ifaces = range_ifaces - all_zone_ifaces
|
|
if stale_ifaces:
|
|
ranges = dnsmasq_cfg.get("dhcp", {}).get("ranges", [])
|
|
remaining = [
|
|
r
|
|
for r in ranges
|
|
if not r.get("interface") or r["interface"] not in stale_ifaces
|
|
]
|
|
dnsmasq_cfg.setdefault("dhcp", {})["ranges"] = remaining
|
|
_save_dnsmasq_cfg(dnsmasq_cfg)
|
|
for iface in sorted(stale_ifaces):
|
|
logger.info(
|
|
"Removed stale DHCP range on '%s' (no firewall zone)",
|
|
iface,
|
|
)
|
|
changes.append(f"Removed stale DHCP range on interface '{iface}'")
|
|
|
|
# When masquerade is enabled on a zone, ensure DHCP ranges have gateway
|
|
changed = False
|
|
for zname, zdata in zones.items():
|
|
if not isinstance(zdata, dict):
|
|
continue
|
|
if not zdata.get("masquerade"):
|
|
continue
|
|
# Find DHCP ranges on this zone's interfaces
|
|
z_ifaces = set(zdata.get("interfaces", []))
|
|
ranges = dnsmasq_cfg.get("dhcp", {}).get("ranges", [])
|
|
for r in ranges:
|
|
iface = r.get("interface", "")
|
|
if not iface or iface not in z_ifaces:
|
|
continue
|
|
if r.get("gateway"):
|
|
continue
|
|
ip = get_interface_ip(iface)
|
|
if ip:
|
|
r["gateway"] = ip
|
|
changed = True
|
|
logger.info(
|
|
"Set DHCP gateway %s on '%s' (masquerade on zone '%s')",
|
|
ip,
|
|
iface,
|
|
zname,
|
|
)
|
|
changes.append(
|
|
f"Set DHCP gateway {ip} for range on '{iface}' (zone '{zname}')"
|
|
)
|
|
|
|
if changed:
|
|
_save_dnsmasq_cfg(dnsmasq_cfg)
|
|
|
|
# Zones with dhcp service but no matching range (warn only)
|
|
for iface in sorted(dhcp_service_ifaces - range_ifaces):
|
|
logger.info(
|
|
"Interface '%s' has dhcp service but no DHCP range configured",
|
|
iface,
|
|
)
|
|
changes.append(f"Zone has dhcp service on '{iface}' but no DHCP range")
|
|
|
|
return SyncResult(
|
|
affected_subsystems=["dnsmasq"] if changed or stale_ifaces else [],
|
|
changes=changes,
|
|
)
|
|
except Exception:
|
|
logger.exception("FirewallToDhcpSync failed")
|
|
return SyncResult()
|
|
|
|
|
|
class NetworkToAllSync:
|
|
"""Sync subscriber: network config_saved → update firewall zone interfaces."""
|
|
|
|
@classmethod
|
|
def on_network_config_saved(cls, event: SyncEvent) -> SyncResult | None:
|
|
"""Sync subscriber: network config_saved → update firewall zone interfaces.
|
|
|
|
Suggests DHCP ranges for static-IP interfaces without ranges.
|
|
Removes interfaces from firewall zones that are no longer present
|
|
in the network config. Logs warnings for interfaces not assigned
|
|
to any zone.
|
|
|
|
Args:
|
|
event: Sync event with ``config_saved`` action from network.
|
|
|
|
Returns:
|
|
SyncResult listing firewall as affected subsystem when zone
|
|
interfaces were modified, with change descriptions.
|
|
"""
|
|
try:
|
|
from lib.dnsmasq import get_config as _get_dnsmasq_cfg
|
|
from lib.firewall import get_config as _get_fw_cfg
|
|
from lib.firewall import save_config as _save_fw_cfg
|
|
from lib.network import get_config as _get_net_cfg
|
|
|
|
net_cfg = _get_net_cfg()
|
|
fw_cfg = _get_fw_cfg()
|
|
dnsmasq_cfg = _get_dnsmasq_cfg()
|
|
|
|
zones = fw_cfg.get("zones", {})
|
|
|
|
# Build interface → zone map
|
|
iface_to_zone: dict[str, str] = {}
|
|
for zname, zdata in zones.items():
|
|
if not isinstance(zdata, dict):
|
|
continue
|
|
for iface in zdata.get("interfaces", []):
|
|
iface_to_zone[iface] = zname
|
|
|
|
# Collect network config interface names
|
|
net_ifaces = set(net_cfg.get("interfaces", {}).keys())
|
|
|
|
# Interfaces with DHCP ranges
|
|
range_ifaces = {
|
|
r["interface"]
|
|
for r in dnsmasq_cfg.get("dhcp", {}).get("ranges", [])
|
|
if r.get("interface")
|
|
}
|
|
|
|
changes: list[str] = []
|
|
|
|
# Suggest DHCP ranges for static-IP interfaces without ranges
|
|
for iface, entry in net_cfg.get("interfaces", {}).items():
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
addresses = entry.get("addresses", [])
|
|
if not addresses:
|
|
continue
|
|
# Check if it has a static IP (not DHCP)
|
|
if entry.get("dhcp") in ("yes", "ipv4"):
|
|
continue
|
|
if iface not in range_ifaces:
|
|
logger.info(
|
|
"Interface '%s' has static IP but no DHCP range — consider adding one",
|
|
iface,
|
|
)
|
|
changes.append(
|
|
f"Interface '{iface}' has static IP but no DHCP range"
|
|
)
|
|
|
|
# Detect zone interface changes: compare network config interfaces
|
|
# against firewall zone interfaces
|
|
fw_zone_ifaces: set[str] = set()
|
|
for zdata in zones.values():
|
|
if not isinstance(zdata, dict):
|
|
continue
|
|
fw_zone_ifaces.update(zdata.get("interfaces", []))
|
|
|
|
# New interfaces from network config not in any zone
|
|
new_ifaces = net_ifaces - fw_zone_ifaces
|
|
# Interfaces no longer in network config but still in zones
|
|
gone_ifaces = fw_zone_ifaces - net_ifaces
|
|
|
|
zone_changed = False
|
|
|
|
for iface in new_ifaces:
|
|
zone_changed = True
|
|
logger.info(
|
|
"Network interface '%s' not in any firewall zone",
|
|
iface,
|
|
)
|
|
changes.append(
|
|
f"Interface '{iface}' added to network but not in any zone"
|
|
)
|
|
|
|
for iface in gone_ifaces:
|
|
zone_changed = True
|
|
zname = iface_to_zone.get(iface)
|
|
if zname:
|
|
zdata = zones.get(zname)
|
|
if isinstance(zdata, dict):
|
|
current = zdata.get("interfaces", [])
|
|
if iface in current:
|
|
current.remove(iface)
|
|
zdata["interfaces"] = current
|
|
logger.info(
|
|
"Removed interface '%s' from zone '%s' (no longer in network config)",
|
|
iface,
|
|
zname,
|
|
)
|
|
changes.append(f"Removed '{iface}' from zone '{zname}'")
|
|
|
|
if zone_changed:
|
|
fw_cfg["zones"] = zones
|
|
_save_fw_cfg(fw_cfg)
|
|
|
|
return SyncResult(
|
|
affected_subsystems=["firewall"] if zone_changed else [],
|
|
changes=changes,
|
|
)
|
|
except Exception:
|
|
logger.exception("NetworkToAllSync failed")
|
|
return SyncResult()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module singleton
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_bus = EventBus()
|
|
bus = _bus
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_bus.subscribe(
|
|
"dnsmasq",
|
|
"config_saved",
|
|
DnsToFirewallSync.on_dnsmasq_config_saved,
|
|
targets={"firewall"},
|
|
)
|
|
_bus.subscribe(
|
|
"wireguard",
|
|
"config_saved",
|
|
WgToFirewallSync.on_wireguard_config_saved,
|
|
targets={"firewall"},
|
|
)
|
|
_bus.subscribe(
|
|
"firewall",
|
|
"config_saved",
|
|
FirewallToDhcpSync.on_firewall_config_saved,
|
|
targets={"dnsmasq"},
|
|
)
|
|
_bus.subscribe(
|
|
"networkd",
|
|
"config_saved",
|
|
NetworkToAllSync.on_network_config_saved,
|
|
targets={"firewall", "dnsmasq"},
|
|
)
|
|
|
|
__all__ = [
|
|
"DnsToFirewallSync",
|
|
"EventBus",
|
|
"FirewallToDhcpSync",
|
|
"NetworkToAllSync",
|
|
"SyncEvent",
|
|
"SyncHandler",
|
|
"SyncResult",
|
|
"WgToFirewallSync",
|
|
"bus",
|
|
"get_affected",
|
|
]
|