Files
vacuum-wall/lib/sync.py
T
mteehan 803258cf18 dhcp: auto-populate gateway from interface IP for DHCP ranges
Add get_interface_ip() helper to resolve an interface's IPv4 address
via 'ip -o addr show'.  Use it to back-propagate gateway into DHCP
ranges so clients receive their default route.

- set_dhcp_range() resolves gateway: explicit > existing range > iface IP
- DnsToFirewallSync and FirewallToDhcpSync sync ensure gateways are set
- Remove automatic masquerade toggle from DnsToFirewallSync
- Fix dnsmasq lease file path to /var/lib/misc/dnsmasq.leases
- Rename lease state field expires_at -> expires (ISO string)
- Add 'ip -o addr show' to sudo whitelist
2026-07-09 00:58:09 +00:00

822 lines
30 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
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",
]