"""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 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() for handler in self._subscribers.get((event.subsystem, event.action), []): try: sub_result = handler(event) except Exception: 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 result.affected_subsystems.extend(sub_result.affected_subsystems) result.changes.extend(sub_result.changes) result.applied = result.applied or sub_result.applied 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 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: 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).""" from lib.common import get_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 config.""" @staticmethod def _sync_allowed_ips( wg_cfg: dict[str, Any], vpn_zone: dict[str, Any], 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. """ 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): continue for item in peer_info.get("allowed_ips", []): 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: 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)) # 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'), "_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: 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", {}) wg_iface = wg_cfg.get("interface", {}).get("name", "") peers = wg_cfg.get("peers", {}) is_active = bool(peers) and bool(wg_iface) changes: list[str] = [] if is_active: 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", } rule_strings = {r.get("rule") for r in rich_rules} if expected_rule["rule"] not in rule_strings: rich_rules.append(expected_rule) vpn_zone["rich_rules"] = rich_rules changes.append("Added UDP 51820 accept rich rule to zone 'vpn'") # 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. vpn_zone = zones.get("vpn") if not isinstance(vpn_zone, dict): pass else: # Remove wg interface from vpn zone 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'" ) # 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": 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): 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) from zone 'vpn'" ) if changes: fw_cfg["zones"] = zones _save_fw_cfg(fw_cfg) return SyncResult( affected_subsystems=["firewall"], changes=changes, ) return SyncResult(changes=changes) except Exception: logger.exception("WgToFirewallSync failed") return SyncResult() 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: 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: 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( "network", "config_saved", NetworkToAllSync.on_network_config_saved, targets={"firewall", "dnsmasq"}, ) __all__ = [ "DnsToFirewallSync", "EventBus", "FirewallToDhcpSync", "NetworkToAllSync", "SyncEvent", "SyncHandler", "SyncResult", "WgToFirewallSync", "bus", "get_affected", ]