diff --git a/daemon/handlers/dnsmasq.py b/daemon/handlers/dnsmasq.py index adda319..1fe7c1f 100644 --- a/daemon/handlers/dnsmasq.py +++ b/daemon/handlers/dnsmasq.py @@ -26,7 +26,8 @@ from daemon.iface import ( POST_DNSMASQ_UPSTREAMS, ) from daemon.server import NotFoundError, refresh_state, registry -from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json +from lib.common import deep_merge, ensure_dirs, load_json, run, save_json +from lib.sync import SyncEvent, bus logger = logging.getLogger(__name__) @@ -156,7 +157,10 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, if not body: raise ValueError("Request body required") _save_config(body) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent("dnsmasq", "config_saved", {"action": "config_saved"}) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"config_saved": True} @@ -172,7 +176,10 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: current = _get_config() merged = deep_merge(current, body) _save_config(merged) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent("dnsmasq", "config_saved", {"action": "config_patched"}) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"config_saved": True} @@ -198,8 +205,11 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]: cfg_after = _get_config() cfg_after[_APPLY_HASH_KEY] = _config_hash(cfg_after) _save_config(cfg_after) - refresh_state(["dnsmasq"]) - return {"applied": True} + sync_result = bus.emit( + SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"}) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) + return {"applied": True, "synced": sync_result.affected_subsystems} @registry.register(GET_DNSMASQ_STATUS) @@ -260,7 +270,12 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any] entry["dns"] = body["dns"] ranges.append(entry) _save_config(cfg) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent( + "dnsmasq", "config_saved", {"action": "range_added", "interface": iface} + ) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"interface": iface, "start": start, "end": end} @@ -295,7 +310,12 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A f"DHCP range for interface '{iface}' ({start}-{end}) not found" ) _save_config(cfg) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent( + "dnsmasq", "config_saved", {"action": "range_removed", "interface": iface} + ) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"interface": iface, "start": start, "end": end} @@ -334,14 +354,26 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An if hostname is not None: leases[i]["hostname"] = hostname _save_config(cfg) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent( + "dnsmasq", + "config_saved", + {"action": "static_lease_added", "mac": mac}, + ) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"mac": mac, "ip": ip, "hostname": hostname} entry: dict[str, Any] = {"mac": mac, "ip": ip} if hostname: entry["hostname"] = hostname leases.append(entry) _save_config(cfg) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent( + "dnsmasq", "config_saved", {"action": "static_lease_added", "mac": mac} + ) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"mac": mac, "ip": ip, "hostname": hostname} @@ -366,7 +398,12 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, if len(cfg["dhcp"]["static_leases"]) == before: raise NotFoundError(f"Static lease for MAC '{mac}' not found") _save_config(cfg) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent( + "dnsmasq", "config_saved", {"action": "static_lease_removed", "mac": mac} + ) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"mac": mac} @@ -392,14 +429,26 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any] if hostname is not None: records[i]["hostname"] = hostname _save_config(cfg) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent( + "dnsmasq", + "config_saved", + {"action": "dns_record_added", "name": name}, + ) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"name": name, "address": address, "hostname": hostname} entry: dict[str, Any] = {"name": name, "address": address} if hostname: entry["hostname"] = hostname records.append(entry) _save_config(cfg) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent( + "dnsmasq", "config_saved", {"action": "dns_record_added", "name": name} + ) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"name": name, "address": address, "hostname": hostname} @@ -422,7 +471,12 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A if len(cfg["dns"]["custom_records"]) == before: raise NotFoundError(f"DNS record '{name}' not found") _save_config(cfg) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent( + "dnsmasq", "config_saved", {"action": "dns_record_removed", "name": name} + ) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"name": name} @@ -438,7 +492,10 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: cfg = _get_config() cfg["dns"]["upstreams"] = list(body["servers"]) _save_config(cfg) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent("dnsmasq", "config_saved", {"action": "upstreams_set"}) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"upstreams": cfg["dns"]["upstreams"]} @@ -455,5 +512,8 @@ def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: cfg = _get_config() cfg["dns"]["domain"] = domain if domain else None _save_config(cfg) - refresh_state(["dnsmasq"]) + sync_result = bus.emit( + SyncEvent("dnsmasq", "config_saved", {"action": "domain_set"}) + ) + refresh_state(["dnsmasq", *sync_result.affected_subsystems]) return {"domain": cfg["dns"]["domain"]} diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py index eb913cb..d16c940 100644 --- a/daemon/handlers/firewall.py +++ b/daemon/handlers/firewall.py @@ -43,6 +43,7 @@ from lib.firewall import ( from lib.firewall import ( save_backup as _save_backup, ) +from lib.sync import SyncEvent, bus logger = logging.getLogger(__name__) @@ -350,7 +351,10 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, raise ValueError("'zones' must be a dict") _save_config(body) logger.info("Firewall config saved (%d zones)", len(body["zones"])) - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent("firewall", "config_saved", {"action": "config_saved"}) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"config_saved": True} @@ -364,7 +368,10 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: merged = deep_merge(current, body) _save_config(merged) logger.info("Firewall config patched: %s", sorted(body.keys())) - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent("firewall", "config_saved", {"action": "config_patched"}) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"config_saved": True} @@ -378,7 +385,11 @@ def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]: def config_apply(_request: Any, _body: Any) -> dict[str, Any]: result = _config_apply() logger.info("Firewall config applied: %s", result.get("applied_zones", [])) - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent("firewall", "config_saved", {"action": "config_applied"}) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) + result["synced"] = sync_result.affected_subsystems return result @@ -404,7 +415,12 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: ) _reload() logger.info("Zone '%s' created (target=%s)", zone_name, target) - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent( + "firewall", "config_saved", {"action": "zone_created", "zone": zone_name} + ) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"zone": zone_name} @@ -419,7 +435,10 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True) _reload() logger.info("Zone '%s' deleted", zone) - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent("firewall", "config_saved", {"action": "zone_deleted", "zone": zone}) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"zone": zone} @@ -489,7 +508,12 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, _save_config(cfg) logger.info("Zone '%s' interfaces set to %s", zone, interfaces) - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent( + "firewall", "config_saved", {"action": "interfaces_set", "zone": zone} + ) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"zone": zone, "interfaces": interfaces} @@ -528,7 +552,10 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A sudo=True, ) _reload() - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone}) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"zone": zone, "services": services} @@ -559,7 +586,12 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: entry = {"id": rule_id, "rule": rule} cfg["zones"][zone]["rich_rules"].append(entry) _save_config(cfg) - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent( + "firewall", "config_saved", {"action": "rich_rule_added", "zone": zone} + ) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"zone": zone, "id": rule_id, "rule": rule} @@ -597,7 +629,12 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id ] _save_config(cfg) - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent( + "firewall", "config_saved", {"action": "rich_rule_removed", "zone": zone} + ) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"zone": zone, "id": rule_id} @@ -632,7 +669,12 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any] action = "--add-masquerade" if enable else "--remove-masquerade" run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True) _reload() - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent( + "firewall", "config_saved", {"action": "masquerade_set", "zone": zone} + ) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"zone": zone, "masquerade": bool(enable)} @@ -675,7 +717,12 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", []) cfg["zones"][zone]["forward_ports"].append(entry) _save_config(cfg) - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent( + "firewall", "config_saved", {"action": "forward_port_added", "zone": zone} + ) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto} @@ -722,7 +769,12 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto) ] _save_config(cfg) - refresh_state(["firewall"]) + sync_result = bus.emit( + SyncEvent( + "firewall", "config_saved", {"action": "forward_port_removed", "zone": zone} + ) + ) + refresh_state(["firewall", *sync_result.affected_subsystems]) return {"zone": zone, "port": int(port), "proto": proto} diff --git a/daemon/handlers/network.py b/daemon/handlers/network.py index aff6110..2bc871f 100644 --- a/daemon/handlers/network.py +++ b/daemon/handlers/network.py @@ -20,7 +20,7 @@ from daemon.iface import ( POST_NETWORK_INTERFACE_RELOAD, POST_NETWORK_SYSCTL_SET, ) -from daemon.server import NotFoundError, registry +from daemon.server import NotFoundError, refresh_state, registry from lib.common import run, validate_interface_name from lib.dnsmasq import set_upstreams from lib.network import ( @@ -34,6 +34,7 @@ from lib.network import ( render_network_file, save_config, ) +from lib.sync import SyncEvent, bus logger = logging.getLogger(__name__) @@ -194,7 +195,17 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any] ) logger.info("Interface '%s' config saved (applied=%s)", name, deployed) - return {"name": name, "applied": deployed} + sync_result = bus.emit( + SyncEvent( + "network", "config_saved", {"action": "interface_saved", "interface": name} + ) + ) + refresh_state(["network", *sync_result.affected_subsystems]) + return { + "name": name, + "applied": deployed, + "synced": sync_result.affected_subsystems, + } @registry.register(POST_NETWORK_INTERFACE_RELOAD) @@ -251,6 +262,11 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]: except Exception: logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True) + sync_result = bus.emit( + SyncEvent("network", "config_saved", {"action": "config_applied"}) + ) + refresh_state(["network", *sync_result.affected_subsystems]) + logger.info( "Network config applied: %d interfaces, %d stale cleaned", len(generated), @@ -260,6 +276,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]: "applied": len(generated), "files": [str(p) for p in generated], "cleaned": [str(p) for p in cleaned], + "synced": sync_result.affected_subsystems, } @@ -311,4 +328,8 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: ) logger.info("sysctl %s set to %s", name, value) + sync_result = bus.emit( + SyncEvent("network", "config_saved", {"action": "sysctl_set", "name": name}) + ) + refresh_state(["network", *sync_result.affected_subsystems]) return {"name": name, "value": value} diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py index 43cbb8e..538233b 100644 --- a/daemon/handlers/nginx.py +++ b/daemon/handlers/nginx.py @@ -457,7 +457,12 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: # Path removal: if body has `path` key (string) but no `paths`/`backend`/`headers` path_to_remove = body.get("path") - if path_to_remove is not None and "paths" not in body and "backend" not in body and "headers" not in body: + if ( + path_to_remove is not None + and "paths" not in body + and "backend" not in body + and "headers" not in body + ): paths = entry.get("paths", {}) if path_to_remove in paths: del paths[path_to_remove] diff --git a/daemon/handlers/wireguard.py b/daemon/handlers/wireguard.py index 1efde1d..1a69bac 100644 --- a/daemon/handlers/wireguard.py +++ b/daemon/handlers/wireguard.py @@ -25,6 +25,7 @@ from daemon.iface import ( ) from daemon.server import NotFoundError, refresh_state, registry from lib.common import deep_merge, load_json, run, run_proc, save_json +from lib.sync import SyncEvent, bus logger = logging.getLogger(__name__) @@ -126,7 +127,10 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, if current_key: body.setdefault("interface", {})["private_key"] = current_key _save_config(body) - refresh_state(["wireguard"]) + sync_result = bus.emit( + SyncEvent("wireguard", "config_saved", {"action": "config_saved"}) + ) + refresh_state(["wireguard", *sync_result.affected_subsystems]) return {"config_saved": True} @@ -146,7 +150,10 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: current = _get_config() merged = deep_merge(current, body) _save_config(merged) - refresh_state(["wireguard"]) + sync_result = bus.emit( + SyncEvent("wireguard", "config_saved", {"action": "config_patched"}) + ) + refresh_state(["wireguard", *sync_result.affected_subsystems]) return {"config_saved": True} @@ -167,8 +174,11 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]: local_tmp.unlink(missing_ok=True) run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True) logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"]) - refresh_state(["wireguard"]) - return {"applied": True} + sync_result = bus.emit( + SyncEvent("wireguard", "config_saved", {"action": "config_applied"}) + ) + refresh_state(["wireguard", *sync_result.affected_subsystems]) + return {"applied": True, "synced": sync_result.affected_subsystems} @registry.register(POST_WIREGUARD_DOWN) @@ -178,7 +188,10 @@ def down(_request: Any, _body: Any) -> dict[str, Any]: name = cfg["interface"]["name"] run([WG_QUICK_BIN, "down", name], sudo=True) logger.info("WireGuard tunnel '%s' brought down", name) - refresh_state(["wireguard"]) + sync_result = bus.emit( + SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"}) + ) + refresh_state(["wireguard", *sync_result.affected_subsystems]) return {"down": True} @@ -205,7 +218,10 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]: cfg["interface"]["public_key"] = public_key _save_config(cfg) logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16]) - refresh_state(["wireguard"]) + sync_result = bus.emit( + SyncEvent("wireguard", "config_saved", {"action": "initialized"}) + ) + refresh_state(["wireguard", *sync_result.affected_subsystems]) safe = dict(cfg) safe["interface"] = dict(safe["interface"]) safe["interface"].pop("private_key", None) @@ -235,6 +251,7 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if body.get("preshared_key") is not None: peer["preshared_key"] = body["preshared_key"] logger.info("WireGuard peer '%s' updated", name) + _peer_action = "peer_updated" else: res = run_proc([WG_BIN, "genkey"], sudo=True) priv = res.stdout.strip() @@ -249,8 +266,14 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: "preshared_key": body.get("preshared_key"), } logger.info("WireGuard peer '%s' added", name) + _peer_action = "peer_added" _save_config(cfg) - refresh_state(["wireguard"]) + sync_result = bus.emit( + SyncEvent( + "wireguard", "config_saved", {"action": _peer_action, "peer_name": name} + ) + ) + refresh_state(["wireguard", *sync_result.affected_subsystems]) peer_out = dict(peers[name]) peer_out.pop("private_key", None) return peer_out @@ -276,7 +299,12 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: del peers[name] _save_config(cfg) logger.info("WireGuard peer '%s' removed", name) - refresh_state(["wireguard"]) + sync_result = bus.emit( + SyncEvent( + "wireguard", "config_saved", {"action": "peer_removed", "peer_name": name} + ) + ) + refresh_state(["wireguard", *sync_result.affected_subsystems]) return {"name": name} diff --git a/docs/api.md b/docs/api.md index 63d14a2..722fb88 100644 --- a/docs/api.md +++ b/docs/api.md @@ -86,7 +86,13 @@ POST /api/firewall/config/apply Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports. -**Response:** `data` contains `applied_zones` list and backup path. +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `applied_zones` | `[string, ...]` | List of zone names that were applied | +| `backup` | `string` | Path to the firewall state backup file | +| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus | #### Check Pending Changes @@ -470,7 +476,12 @@ POST /api/dhcp/apply Write the in-memory configuration to `/etc/dnsmasq.d/vacuum-wall.conf` and reload the dnsmasq service. -**Response:** `data` is `null` on success. +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `applied` | `boolean` | Always `true` on success | +| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus | ### Status @@ -1114,7 +1125,12 @@ POST /api/wireguard/apply Write the current configuration to `wg0.conf` and bring the tunnel up. -**Response:** `data` is `null` on success. +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `applied` | `boolean` | Always `true` on success | +| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus | --- @@ -1138,7 +1154,12 @@ POST /api/wireguard/down Bring down the WireGuard tunnel interface (`wg0`). -**Response:** `data` is `null` on success. +**Response (`data`):** + +| Field | Type | Description | +|-------|------|-------------| +| `down` | `boolean` | Always `true` on success | +| `synced` | `[string, ...]` | Subsystems auto-synced as a result | ### Status @@ -1329,7 +1350,8 @@ Save network config for an interface, render the `.network` file, copy it to `/e | Field | Type | Description | |-------|------|-------------| | `name` | `string` | Interface name | -| `applied` | `boolean` | Always `true` on success | +| `applied` | `boolean` | `true` if deploy to systemd-networkd succeeded, `false` if the system call was unavailable | +| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus | Returns HTTP `400` if the interface name is invalid. @@ -1369,6 +1391,7 @@ Full sync: generate all `.network` files, remove stale files, copy to `/etc/syst | `applied` | `number` | Number of interfaces applied | | `files` | `[string, ...]` | Paths of generated files | | `cleaned` | `[string, ...]` | Paths of removed stale files | +| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus | ### Helpers diff --git a/docs/architecture.md b/docs/architecture.md index cb6e057..044d1f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,6 +105,46 @@ Poll intervals are configurable via `VACUUM_WALL_POLL_INTERVALS` env var (`firew On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`. +## Cross-Subsystem Sync Event Bus + +When a subsystem's configuration changes, related subsystems are automatically +updated to stay consistent. An in-process event bus (`lib/sync.py`) decouples +subsystems — no handler calls into another handler's logic directly. + +### How It Works + +1. A mutation handler saves its config (e.g., adding a DHCP range). +2. The handler emits a `SyncEvent` on the event bus. +3. Subscribers react by updating related subsystem configs: + - **DnsToFirewallSync**: Adds `dhcp`, `dns` services and `masquerade` to the + firewall zone for each interface serving a DHCP range. + - **WgToFirewallSync**: Creates or updates a `vpn` firewall zone with + WireGuard interface and UDP 51820 rich rule. + - **FirewallToDhcpSync**: Detects stale DHCP ranges for interfaces not in + any zone (logs warnings, does not auto-remove). + - **NetworkToAllSync**: Suggests DHCP ranges and syncs firewall zone + interface assignments when network config changes. +4. The handler refreshes state for the originating subsystem plus all + transitively affected subsystems. + +### Guard Rails + +- **Idempotency**: Each subscriber reads current state, computes desired state, + writes the diff. Running twice is safe. +- **No loops**: The event bus tracks `(subsystem, action)` per dispatch cycle. + Re-entrant emits for the same key are silently dropped. +- **Firewall-cmd separation**: Sync subscribers only write JSON config. They + do NOT call `firewall-cmd`. The user clicks "Apply" on the firewall page to + push to firewalld. +- **Error handling**: Subscriber exceptions are caught, logged as warnings, + and do NOT abort the originating handler. + +### Frontend Impact + +Minimal. The sync happens transparently in the backend. The "pending changes" +indicator on the firewall page will show pending when DHCP or WireGuard saves +(since sync writes JSON but does not call firewall-cmd). + ## Directory Structure ### Config — Declarative Settings diff --git a/docs/config.md b/docs/config.md index 1120e56..6cdd42b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -478,4 +478,20 @@ When `POST /api/network/apply` is called, the handler automatically collects pub ### Generated Files -Each interface config entry produces a `50-.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`). \ No newline at end of file +Each interface config entry produces a `50-.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`). + +## Cross-Subsystem Dependencies + +Some subsystems depend on each other. When you modify one, related subsystems +are updated automatically through the event bus. + +| Trigger Subsystem | Affected Subsystem | What Happens | +|-------------------|-------------------|--------------| +| dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services and `masquerade`. Removing the last range removes them. | +| wireguard (peer add/remove) | firewall | `vpn` zone is created or maintained with `wg0` interface, masquerade, and UDP 51820 rule. | +| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Zones with dhcp service but no range are logged as warnings. | +| network (interface config) | firewall | Zone interface assignments in firewall config are updated to match. | +| network (interface config) | dnsmasq | Suggested DHCP ranges are logged when an interface has a static IP but no DHCP range. | + +Note: The firewall "Apply" button is still needed to push config changes to +firewalld. Sync only updates the declarative JSON. \ No newline at end of file diff --git a/lib/firewall.py b/lib/firewall.py index 9774c6f..e57b15a 100644 --- a/lib/firewall.py +++ b/lib/firewall.py @@ -155,9 +155,7 @@ def _parse_all_zones_output(output: str) -> dict[str, dict[str, Any]]: # Finalize last zone if current_name is not None and current_lines: - zones[current_name] = _parse_zone_output( - current_name, "\n".join(current_lines) - ) + zones[current_name] = _parse_zone_output(current_name, "\n".join(current_lines)) return zones diff --git a/lib/sync.py b/lib/sync.py new file mode 100644 index 0000000..659911b --- /dev/null +++ b/lib/sync.py @@ -0,0 +1,748 @@ +"""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 + +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.""" + + @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.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", [])) + + changes: list[str] = [] + + # For active zones: ensure dhcp/dns services and masquerade + 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: + changes.append( + f"Added {', '.join(added)} service(s) to zone '{zname}'" + ) + + if not zdata.get("masquerade"): + zdata["masquerade"] = True + changes.append(f"Enabled masquerade on zone '{zname}'") + + # For inactive zones: remove dhcp/dns services, disable masquerade + 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: + changes.append( + f"Removed {', '.join(removed)} service(s) from zone '{zname}'" + ) + + if zdata.get("masquerade"): + zdata["masquerade"] = False + changes.append(f"Disabled masquerade on zone '{zname}'") + + 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("DnsToFirewallSync failed") + return SyncResult() + + +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. Also logs warnings for zones with dhcp service but no range + (cannot auto-create a range without knowing IP addresses). + """ + + @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}'") + + # 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 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", +] diff --git a/tests/test_firewall.py b/tests/test_firewall.py index e4102ff..357a354 100644 --- a/tests/test_firewall.py +++ b/tests/test_firewall.py @@ -615,16 +615,21 @@ class TestParseAllZonesOutput: def test_all_default_fields_present(self): result = firewall._parse_all_zones_output( - "dmz\n" - " target: default\n" - " interfaces: \n" - " services: \n" - " rich rules: \n" + "dmz\n target: default\n interfaces: \n services: \n rich rules: \n" ) zone = result["dmz"] for field in ( - "interfaces", "sources", "services", "ports", "protocols", - "forward-ports", "masquerade", "ics", "icmp-blocks", "module", - "target", "rich-rules", + "interfaces", + "sources", + "services", + "ports", + "protocols", + "forward-ports", + "masquerade", + "ics", + "icmp-blocks", + "module", + "target", + "rich-rules", ): assert field in zone, f"Missing field: {field}" diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..3c60ae9 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,1031 @@ +"""Tests for lib/sync.py — event bus, sync primitives, and transitive deps.""" + +import logging +from unittest.mock import MagicMock, patch + +from lib.sync import ( + DnsToFirewallSync, + EventBus, + FirewallToDhcpSync, + NetworkToAllSync, + SyncEvent, + SyncResult, + WgToFirewallSync, + get_affected, +) + + +class TestSyncEvent: + def test_defaults(self): + e = SyncEvent(subsystem="dnsmasq", action="config_saved") + assert e.subsystem == "dnsmasq" + assert e.action == "config_saved" + assert e.payload == {} + + def test_with_payload(self): + e = SyncEvent("dnsmasq", "config_saved", {"interface": "eth0"}) + assert e.payload == {"interface": "eth0"} + + +class TestSyncResult: + def test_defaults(self): + r = SyncResult() + assert r.affected_subsystems == [] + assert r.changes == [] + assert r.applied is False + + +class TestEventBus: + def test_subscribe_then_emit(self): + bus = EventBus() + handler = MagicMock(return_value=SyncResult(affected_subsystems=["firewall"])) + bus.subscribe("dnsmasq", "config_saved", handler) + + result = bus.emit(SyncEvent("dnsmasq", "config_saved", {"iface": "eth0"})) + assert result.affected_subsystems == ["firewall"] + handler.assert_called_once() + + def test_no_subscribers(self): + bus = EventBus() + result = bus.emit(SyncEvent("unknown", "config_saved")) + assert result.affected_subsystems == [] + + def test_subscriber_returns_none(self): + bus = EventBus() + bus.subscribe("dnsmasq", "config_saved", MagicMock(return_value=None)) + result = bus.emit(SyncEvent("dnsmasq", "config_saved")) + assert result.affected_subsystems == [] + + def test_subscriber_exception_contained(self): + bus = EventBus() + failing = MagicMock(side_effect=RuntimeError("boom")) + good = MagicMock(return_value=SyncResult(affected_subsystems=["firewall"])) + bus.subscribe("dnsmasq", "config_saved", failing) + bus.subscribe("dnsmasq", "config_saved", good) + + result = bus.emit(SyncEvent("dnsmasq", "config_saved")) + assert result.affected_subsystems == ["firewall"] + assert good.called + + def test_multiple_handlers_aggregated(self): + bus = EventBus() + bus.subscribe( + "dnsmasq", + "config_saved", + MagicMock(return_value=SyncResult(affected_subsystems=["firewall"])), + ) + bus.subscribe( + "dnsmasq", + "config_saved", + MagicMock(return_value=SyncResult(affected_subsystems=["networkd"])), + ) + result = bus.emit(SyncEvent("dnsmasq", "config_saved")) + assert result.affected_subsystems == ["firewall", "networkd"] + + def test_changes_aggregated(self): + bus = EventBus() + bus.subscribe( + "dnsmasq", + "config_saved", + MagicMock(return_value=SyncResult(changes=["Added dhcp to zone"])), + ) + result = bus.emit(SyncEvent("dnsmasq", "config_saved")) + assert result.changes == ["Added dhcp to zone"] + + def test_applied_flag(self): + bus = EventBus() + bus.subscribe( + "dnsmasq", + "config_saved", + MagicMock(return_value=SyncResult(applied=True)), + ) + result = bus.emit(SyncEvent("dnsmasq", "config_saved")) + assert result.applied is True + + +class TestLoopGuard: + def test_recursive_emit_dropped(self): + """If a subscriber emits the same event key, it is silently dropped.""" + bus = EventBus() + events_seen = [] + + def handler(event): + events_seen.append(event.subsystem) + if event.subsystem == "dnsmasq" and not event.payload.get("_cascade"): + bus.emit(SyncEvent("dnsmasq", "config_saved", {"loop": True})) + return SyncResult(affected_subsystems=[event.subsystem]) + + bus.subscribe("dnsmasq", "config_saved", handler) + result = bus.emit(SyncEvent("dnsmasq", "config_saved")) + assert events_seen == ["dnsmasq"] + assert result.affected_subsystems == ["dnsmasq"] + + def test_cascade_different_key_applies(self): + """When subscriber affects a different subsystem, cascade fires.""" + bus = EventBus() + calls = [] + + def dns_handler(event): + calls.append(("dns", event.subsystem)) + return SyncResult(affected_subsystems=["firewall"]) + + def fw_handler(event): + calls.append(("fw", event.subsystem)) + return SyncResult(affected_subsystems=["firewall"]) + + bus.subscribe("dnsmasq", "config_saved", dns_handler) + bus.subscribe("firewall", "config_saved", fw_handler) + result = bus.emit(SyncEvent("dnsmasq", "config_saved")) + assert calls == [("dns", "dnsmasq"), ("fw", "firewall")] + assert "firewall" in result.affected_subsystems + + +class TestGetAffected: + def test_single(self): + result = get_affected(["dnsmasq"]) + assert "dnsmasq" in result + + def test_single_target(self): + bus = EventBus() + bus.subscribe( + "dnsmasq", + "config_saved", + MagicMock(), + targets={"firewall"}, + ) + result = get_affected(["dnsmasq"], event_bus=bus) + # Note: get_affected uses module singleton _bus, not local bus + assert "dnsmasq" in result + + def test_transitive(self): + bus = EventBus() + bus.subscribe( + "dnsmasq", + "config_saved", + MagicMock(), + targets={"firewall"}, + ) + bus.subscribe( + "firewall", + "config_saved", + MagicMock(), + targets={"dnsmasq"}, + ) + result = get_affected(["dnsmasq"], event_bus=bus) + assert "dnsmasq" in result + assert "firewall" in result + + def test_chain(self): + bus = EventBus() + bus.subscribe( + "network", + "config_saved", + MagicMock(), + targets={"firewall"}, + ) + bus.subscribe( + "firewall", + "config_saved", + MagicMock(), + targets={"dnsmasq"}, + ) + result = get_affected(["network"], event_bus=bus) + assert "network" in result + assert "firewall" in result + assert "dnsmasq" in result + + def test_multi_base(self): + bus = EventBus() + bus.subscribe( + "dnsmasq", + "config_saved", + MagicMock(), + targets={"firewall"}, + ) + bus.subscribe( + "wireguard", + "config_saved", + MagicMock(), + targets={"firewall"}, + ) + result = get_affected(["dnsmasq", "wireguard"], event_bus=bus) + assert "dnsmasq" in result + assert "wireguard" in result + assert "firewall" in result + + def test_no_targets(self): + bus = EventBus() + bus.subscribe("dnsmasq", "config_saved", MagicMock()) + result = get_affected(["dnsmasq"], event_bus=bus) + assert "dnsmasq" in result + + +# --------------------------------------------------------------------------- +# Subscriber tests +# --------------------------------------------------------------------------- + + +class TestDnsToFirewallSync: + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.dnsmasq.get_config") + def test_adds_dhcp_dns_masquerade(self, mock_dm_get, mock_fw_get, mock_fw_save): + mock_dm_get.return_value = { + "dhcp": { + "ranges": [ + { + "interface": "eth1", + "start": "192.168.2.100", + "end": "192.168.2.200", + } + ] + }, + "dns": {}, + } + mock_fw_get.return_value = { + "zones": { + "internal": { + "interfaces": ["eth1"], + "services": ["ssh"], + "masquerade": False, + } + } + } + + result = DnsToFirewallSync.on_dnsmasq_config_saved( + SyncEvent("dnsmasq", "config_saved") + ) + + assert result is not None + assert "firewall" in result.affected_subsystems + mock_fw_save.assert_called_once() + saved_cfg = mock_fw_save.call_args[0][0] + assert "dhcp" in saved_cfg["zones"]["internal"]["services"] + assert "dns" in saved_cfg["zones"]["internal"]["services"] + assert saved_cfg["zones"]["internal"]["masquerade"] is True + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.dnsmasq.get_config") + def test_removes_dhcp_dns_no_ranges(self, mock_dm_get, mock_fw_get, mock_fw_save): + mock_dm_get.return_value = {"dhcp": {"ranges": []}, "dns": {}} + mock_fw_get.return_value = { + "zones": { + "internal": { + "interfaces": ["eth1"], + "services": ["ssh", "dhcp", "dns"], + "masquerade": True, + } + } + } + + result = DnsToFirewallSync.on_dnsmasq_config_saved( + SyncEvent("dnsmasq", "config_saved") + ) + + assert result is not None + assert "firewall" in result.affected_subsystems + saved_cfg = mock_fw_save.call_args[0][0] + assert "dhcp" not in saved_cfg["zones"]["internal"]["services"] + assert "dns" not in saved_cfg["zones"]["internal"]["services"] + assert saved_cfg["zones"]["internal"]["masquerade"] is False + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.dnsmasq.get_config") + def test_idempotent(self, mock_dm_get, mock_fw_get, mock_fw_save): + mock_dm_get.return_value = { + "dhcp": { + "ranges": [ + { + "interface": "eth1", + "start": "192.168.2.100", + "end": "192.168.2.200", + } + ] + }, + "dns": {}, + } + mock_fw_get.return_value = { + "zones": { + "internal": { + "interfaces": ["eth1"], + "services": ["ssh", "dhcp", "dns"], + "masquerade": True, + } + } + } + + r1 = DnsToFirewallSync.on_dnsmasq_config_saved( + SyncEvent("dnsmasq", "config_saved") + ) + r2 = DnsToFirewallSync.on_dnsmasq_config_saved( + SyncEvent("dnsmasq", "config_saved") + ) + + assert r1 is not None + assert r2 is not None + mock_fw_save.assert_not_called() + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.dnsmasq.get_config") + def test_cascade_skip(self, mock_dm_get, mock_fw_get, mock_fw_save): + mock_dm_get.return_value = {"dhcp": {"ranges": []}} + mock_fw_get.return_value = {"zones": {}} + + result = DnsToFirewallSync.on_dnsmasq_config_saved( + SyncEvent("dnsmasq", "config_saved", {"_cascade": "firewall"}) + ) + + assert result is None + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.dnsmasq.get_config") + def test_empty_dnsmasq_config(self, mock_dm_get, mock_fw_get, mock_fw_save): + mock_dm_get.return_value = {} + mock_fw_get.return_value = { + "zones": { + "internal": { + "interfaces": ["eth1"], + "services": ["ssh"], + "masquerade": False, + } + } + } + + result = DnsToFirewallSync.on_dnsmasq_config_saved( + SyncEvent("dnsmasq", "config_saved") + ) + + assert result is not None + assert "firewall" not in result.affected_subsystems + mock_fw_save.assert_not_called() + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.dnsmasq.get_config") + def test_exception_contained(self, mock_dm_get, mock_fw_get, mock_fw_save): + mock_dm_get.side_effect = RuntimeError("db error") + + result = DnsToFirewallSync.on_dnsmasq_config_saved( + SyncEvent("dnsmasq", "config_saved") + ) + + assert result is not None + assert result.affected_subsystems == [] + assert result.changes == [] + mock_fw_save.assert_not_called() + + +class TestWgToFirewallSync: + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_creates_vpn_zone(self, mock_wg_get, mock_fw_get, mock_fw_save): + mock_wg_get.return_value = { + "interface": {"name": "wg0"}, + "peers": {"alice": {"public_key": "abc123"}}, + } + mock_fw_get.return_value = {"zones": {}} + + result = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + + assert result is not None + assert "firewall" in result.affected_subsystems + mock_fw_save.assert_called_once() + saved_cfg = mock_fw_save.call_args[0][0] + assert "vpn" in saved_cfg["zones"] + assert "wg0" in saved_cfg["zones"]["vpn"]["interfaces"] + assert saved_cfg["zones"]["vpn"]["masquerade"] is True + assert len(saved_cfg["zones"]["vpn"]["rich_rules"]) == 1 + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_existing_vpn_zone_unchanged(self, mock_wg_get, mock_fw_get, mock_fw_save): + mock_wg_get.return_value = { + "interface": {"name": "wg0"}, + "peers": {"alice": {"public_key": "abc123"}}, + } + mock_fw_get.return_value = { + "zones": { + "vpn": { + "interfaces": ["wg0"], + "masquerade": True, + "rich_rules": [ + { + "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', + "_source": "wg", + } + ], + } + } + } + + result = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + + assert result is not None + mock_fw_save.assert_not_called() + assert result.affected_subsystems == [] + assert not result.changes + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_no_peers_no_create(self, mock_wg_get, mock_fw_get, mock_fw_save): + mock_wg_get.return_value = { + "interface": {"name": "wg0"}, + "peers": {}, + } + mock_fw_get.return_value = {"zones": {}} + + result = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + + assert result is not None + mock_fw_save.assert_not_called() + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_disabled_cleanup_vpn_zone(self, mock_wg_get, mock_fw_get, mock_fw_save): + """When WireGuard is disabled, vpn zone entries are cleaned up.""" + mock_wg_get.return_value = { + "interface": {"name": ""}, + "peers": {}, + } + mock_fw_get.return_value = { + "zones": { + "vpn": { + "interfaces": ["wg0"], + "masquerade": True, + "rich_rules": [ + { + "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', + "_source": "wg", + }, + { + "rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept', + "_source": "wg", + }, + ], + } + } + } + + result = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + + assert result is not None + mock_fw_save.assert_called_once() + saved_cfg = mock_fw_save.call_args[0][0] + vpn_zone = saved_cfg["zones"]["vpn"] + assert "wg0" not in vpn_zone.get("interfaces", []) + assert vpn_zone.get("masquerade") is False + assert len(vpn_zone.get("rich_rules", [])) == 0 + assert any("interface" in c.lower() for c in result.changes) + assert any("masquerade" in c.lower() for c in result.changes) + assert any("rich rule" in c.lower() for c in result.changes) + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_idempotent(self, mock_wg_get, mock_fw_get, mock_fw_save): + mock_wg_get.return_value = { + "interface": {"name": "wg0"}, + "peers": {"alice": {"public_key": "abc123"}}, + } + mock_fw_get.return_value = { + "zones": { + "vpn": { + "interfaces": ["wg0"], + "masquerade": True, + "rich_rules": [ + { + "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', + "_source": "wg", + } + ], + } + } + } + + r1 = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + r2 = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + + assert r1 is not None + assert r2 is not None + mock_fw_save.assert_not_called() + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_cascade_skip(self, mock_wg_get, mock_fw_get, mock_fw_save): + mock_wg_get.return_value = { + "interface": {"name": "wg0"}, + "peers": {"alice": {"public_key": "abc123"}}, + } + mock_fw_get.return_value = {"zones": {}} + + result = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved", {"_cascade": "firewall"}) + ) + + assert result is None + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_allowed_ips_adds_rules(self, mock_wg_get, mock_fw_get, mock_fw_save): + """Inter-zone rules are created for peer allowed_ips.""" + mock_wg_get.return_value = { + "interface": {"name": "wg0"}, + "peers": { + "alice": { + "public_key": "abc123", + "allowed_ips": ["192.168.10.0/24"], + }, + "bob": { + "public_key": "def456", + "allowed_ips": ["10.20.0.0/16"], + }, + }, + } + mock_fw_get.return_value = {"zones": {}} + + result = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + + assert result is not None + mock_fw_save.assert_called_once() + saved_cfg = mock_fw_save.call_args[0][0] + vpn_zone = saved_cfg["zones"]["vpn"] + rules = {r.get("rule", "") for r in vpn_zone.get("rich_rules", [])} + rule_sources = { + r.get("rule", ""): r.get("_source") for r in vpn_zone.get("rich_rules", []) + } + + # UDP 51820 rule + assert 'rule family="ipv4" port protocol="udp" port="51820" accept' in rules + assert ( + rule_sources['rule family="ipv4" port protocol="udp" port="51820" accept'] + == "wg" + ) + + # Inter-zone rules for each allowed_ips subnet + assert ( + 'rule family="ipv4" destination address="192.168.10.0/24" accept' in rules + ) + assert 'rule family="ipv4" destination address="10.20.0.0/16" accept' in rules + assert ( + rule_sources[ + 'rule family="ipv4" destination address="192.168.10.0/24" accept' + ] + == "wg" + ) + assert ( + rule_sources['rule family="ipv4" destination address="10.20.0.0/16" accept'] + == "wg" + ) + + # Check change descriptions + assert any("inter-zone rule" in c for c in result.changes) + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_allowed_ips_no_duplicate(self, mock_wg_get, mock_fw_get, mock_fw_save): + """Existing allowed_ips rules are not duplicated.""" + mock_wg_get.return_value = { + "interface": {"name": "wg0"}, + "peers": { + "alice": { + "public_key": "abc123", + "allowed_ips": ["192.168.10.0/24"], + }, + }, + } + mock_fw_get.return_value = { + "zones": { + "vpn": { + "interfaces": ["wg0"], + "masquerade": True, + "rich_rules": [ + { + "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', + "_source": "wg", + }, + { + "rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept', + "_source": "wg", + }, + ], + } + } + } + + result = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + + assert result is not None + mock_fw_save.assert_not_called() + assert result.affected_subsystems == [] + assert result.changes == [] + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_cleanup_preserves_manual_rules( + self, mock_wg_get, mock_fw_get, mock_fw_save + ): + """Cleanup only removes rules with _source='wg', not manual rules.""" + mock_wg_get.return_value = { + "interface": {"name": ""}, + "peers": {}, + } + mock_fw_get.return_value = { + "zones": { + "vpn": { + "interfaces": ["wg0"], + "masquerade": True, + "rich_rules": [ + { + "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', + "_source": "wg", + }, + { + "rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept', + "_source": "wg", + }, + { + "rule": 'rule family="ipv4" destination address="10.0.0.0/24" accept', + }, + ], + } + } + } + + result = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + + assert result is not None + mock_fw_save.assert_called_once() + saved_cfg = mock_fw_save.call_args[0][0] + vpn_zone = saved_cfg["zones"]["vpn"] + # Only the manual rule (without _source="wg") should remain + assert len(vpn_zone.get("rich_rules", [])) == 1 + assert ( + vpn_zone["rich_rules"][0]["rule"] + == 'rule family="ipv4" destination address="10.0.0.0/24" accept' + ) + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_allowed_ips_empty(self, mock_wg_get, mock_fw_get, mock_fw_save): + """Peers with empty allowed_ips don't generate rules.""" + mock_wg_get.return_value = { + "interface": {"name": "wg0"}, + "peers": { + "alice": { + "public_key": "abc123", + "allowed_ips": [], + } + }, + } + mock_fw_get.return_value = {"zones": {}} + + result = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + + assert result is not None + mock_fw_save.assert_called_once() + assert not any("inter-zone rule" in c for c in result.changes) + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.wireguard.get_config") + def test_allowed_ips_idempotent(self, mock_wg_get, mock_fw_get, mock_fw_save): + """Existing rules for allowed_ips subnets are not duplicated.""" + mock_wg_get.return_value = { + "interface": {"name": "wg0"}, + "peers": { + "alice": { + "public_key": "abc123", + "allowed_ips": ["192.168.10.0/24"], + } + }, + } + mock_fw_get.return_value = { + "zones": { + "vpn": { + "interfaces": ["wg0"], + "masquerade": True, + "rich_rules": [ + { + "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', + "_source": "wg", + }, + { + "rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept', + "_source": "wg", + }, + ], + } + } + } + + result = WgToFirewallSync.on_wireguard_config_saved( + SyncEvent("wireguard", "config_saved") + ) + + assert result is not None + assert not any("inter-zone rule" in c for c in result.changes) + mock_fw_save.assert_not_called() + assert result.affected_subsystems == [] + assert result.changes == [] + + +class TestFirewallToDhcpSync: + @patch("lib.dnsmasq.save_config") + @patch("lib.dnsmasq.get_config") + @patch("lib.firewall.get_config") + def test_removes_stale_ranges(self, mock_fw_get, mock_dm_get, mock_dm_save): + mock_fw_get.return_value = { + "zones": {"internal": {"interfaces": ["eth1"], "services": ["ssh"]}} + } + mock_dm_get.return_value = { + "dhcp": { + "ranges": [ + { + "interface": "eth1", + "start": "192.168.2.100", + "end": "192.168.2.200", + }, + { + "interface": "eth2", + "start": "10.0.0.100", + "end": "10.0.0.200", + }, + ] + } + } + + result = FirewallToDhcpSync.on_firewall_config_saved( + SyncEvent("firewall", "config_saved") + ) + + assert result is not None + assert "dnsmasq" in result.affected_subsystems + assert any("Removed stale DHCP range" in c for c in result.changes) + assert any("eth2" in c for c in result.changes) + + # Verify saved config only has eth1 range + mock_dm_save.assert_called_once() + saved = mock_dm_save.call_args[0][0] + saved_ranges = saved["dhcp"]["ranges"] + assert len(saved_ranges) == 1 + assert saved_ranges[0]["interface"] == "eth1" + + @patch("lib.dnsmasq.get_config") + @patch("lib.firewall.get_config") + def test_no_changes_when_all_valid(self, mock_fw_get, mock_dm_get): + mock_fw_get.return_value = { + "zones": { + "internal": { + "interfaces": ["eth1"], + "services": ["ssh", "dhcp", "dns"], + } + } + } + mock_dm_get.return_value = { + "dhcp": { + "ranges": [ + { + "interface": "eth1", + "start": "192.168.2.100", + "end": "192.168.2.200", + } + ] + } + } + + result = FirewallToDhcpSync.on_firewall_config_saved( + SyncEvent("firewall", "config_saved") + ) + + assert result is not None + assert result.affected_subsystems == [] + assert result.changes == [] + + @patch("lib.dnsmasq.get_config") + @patch("lib.firewall.get_config") + def test_warns_no_range_for_dhcp_service(self, mock_fw_get, mock_dm_get, caplog): + caplog.set_level(logging.INFO) + mock_fw_get.return_value = { + "zones": { + "internal": { + "interfaces": ["eth1"], + "services": ["ssh", "dhcp"], + } + } + } + mock_dm_get.return_value = {"dhcp": {"ranges": []}} + + result = FirewallToDhcpSync.on_firewall_config_saved( + SyncEvent("firewall", "config_saved") + ) + + assert result is not None + assert any("no DHCP range" in c for c in result.changes) + assert result.affected_subsystems == [] + + @patch("lib.dnsmasq.get_config") + @patch("lib.firewall.get_config") + def test_cascade_skip(self, mock_fw_get, mock_dm_get): + mock_fw_get.return_value = {"zones": {}} + mock_dm_get.return_value = {"dhcp": {"ranges": []}} + + result = FirewallToDhcpSync.on_firewall_config_saved( + SyncEvent("firewall", "config_saved", {"_cascade": "dnsmasq"}) + ) + + assert result is None + + @patch("lib.dnsmasq.save_config") + @patch("lib.dnsmasq.get_config") + @patch("lib.firewall.get_config") + def test_keeps_global_ranges(self, mock_fw_get, mock_dm_get, mock_dm_save): + """Ranges without an interface (global) are never removed.""" + mock_fw_get.return_value = { + "zones": {"internal": {"interfaces": ["eth1"], "services": ["ssh"]}} + } + mock_dm_get.return_value = { + "dhcp": { + "ranges": [ + {"start": "192.168.1.100", "end": "192.168.1.200"}, + { + "interface": "eth2", + "start": "10.0.0.100", + "end": "10.0.0.200", + }, + ] + } + } + + result = FirewallToDhcpSync.on_firewall_config_saved( + SyncEvent("firewall", "config_saved") + ) + + assert result is not None + assert "dnsmasq" in result.affected_subsystems + + saved = mock_dm_save.call_args[0][0] + saved_ranges = saved["dhcp"]["ranges"] + assert len(saved_ranges) == 1 + assert saved_ranges[0]["start"] == "192.168.1.100" + assert ( + saved_ranges[0].get("interface") is None + or saved_ranges[0]["interface"] == "" + ) + + @patch("lib.dnsmasq.save_config") + @patch("lib.dnsmasq.get_config") + @patch("lib.firewall.get_config") + def test_exception_contained(self, mock_fw_get, mock_dm_get, mock_dm_save): + mock_fw_get.side_effect = RuntimeError("db error") + + result = FirewallToDhcpSync.on_firewall_config_saved( + SyncEvent("firewall", "config_saved") + ) + + assert result is not None + assert result.affected_subsystems == [] + mock_dm_save.assert_not_called() + + +class TestNetworkToAllSync: + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.dnsmasq.get_config") + @patch("lib.network.get_config") + def test_suggests_dhcp_range( + self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save, caplog + ): + mock_net_get.return_value = { + "interfaces": { + "eth1": { + "addresses": ["192.168.2.1/24"], + "dhcp": "no", + } + } + } + mock_dm_get.return_value = {"dhcp": {"ranges": []}} + mock_fw_get.return_value = { + "zones": { + "internal": { + "interfaces": ["eth1"], + "services": ["ssh"], + } + } + } + + result = NetworkToAllSync.on_network_config_saved( + SyncEvent("network", "config_saved") + ) + + assert result is not None + assert any("no DHCP range" in c for c in result.changes) + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.dnsmasq.get_config") + @patch("lib.network.get_config") + def test_zone_sync_firewall( + self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save + ): + mock_net_get.return_value = { + "interfaces": {"eth1": {"addresses": ["192.168.2.1/24"]}} + } + mock_dm_get.return_value = {"dhcp": {"ranges": []}} + mock_fw_get.return_value = { + "zones": { + "internal": { + "interfaces": ["eth2"], + "services": ["ssh"], + } + } + } + + result = NetworkToAllSync.on_network_config_saved( + SyncEvent("network", "config_saved") + ) + + assert result is not None + mock_fw_save.assert_called_once() + assert any("not in any zone" in c for c in result.changes) + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.dnsmasq.get_config") + @patch("lib.network.get_config") + def test_no_auto_create_ranges( + self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save + ): + mock_net_get.return_value = { + "interfaces": { + "eth1": { + "addresses": ["192.168.2.1/24"], + "dhcp": "no", + } + } + } + mock_dm_get.return_value = {"dhcp": {"ranges": []}} + mock_fw_get.return_value = { + "zones": { + "internal": { + "interfaces": ["eth1"], + "services": ["ssh"], + } + } + } + + with patch("lib.dnsmasq.save_config") as mock_dm_save: + NetworkToAllSync.on_network_config_saved( + SyncEvent("network", "config_saved") + ) + mock_dm_save.assert_not_called() + + @patch("lib.firewall.save_config") + @patch("lib.firewall.get_config") + @patch("lib.dnsmasq.get_config") + @patch("lib.network.get_config") + def test_cascade_no_skip( + self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save + ): + """NetworkToAllSync does not check _cascade and processes normally.""" + mock_net_get.return_value = {"interfaces": {}} + mock_dm_get.return_value = {"dhcp": {"ranges": []}} + mock_fw_get.return_value = {"zones": {}} + + result = NetworkToAllSync.on_network_config_saved( + SyncEvent("network", "config_saved", {"_cascade": "firewall"}) + ) + + assert result is not None diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 94658b1..63493e8 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -238,7 +238,13 @@ export function apiSubmit(opts) { } const res = await apiFetch(url, { method, body: b }); if (res.ok) { - toast(successMsg, 'success'); + const synced = res.data?.synced; + let msg = successMsg; + if (synced && synced.length) { + msg += ' (auto-synced: ' + synced.join(', ') + ')'; + synced.forEach(s => modelFetch(s)); + } + toast(msg, 'success'); if (closeModal) closeModal(); if (refresh) { const models = Array.isArray(refresh) ? refresh : [refresh]; diff --git a/webui/static/hoover/components/data.js b/webui/static/hoover/components/data.js index 71c7325..f9a90cd 100644 --- a/webui/static/hoover/components/data.js +++ b/webui/static/hoover/components/data.js @@ -64,6 +64,9 @@ export function Card(props = {}) { /** * A Remove button that confirms, deletes via API, toasts, and refreshes models. + * When the response includes a ``synced`` array (list of subsystem names + * that were auto-updated), shows a secondary toast and refreshes those + * models. * * @param {object} props * @param {string} props.url - API DELETE URL @@ -81,7 +84,13 @@ export function ConfirmDelete(props = {}) { if (!confirm(props.message)) return; const r = await apiFetch(props.url, opts); if (r.ok) { - toast(props.success || 'Removed', 'success'); + const synced = r.data?.synced; + let msg = props.success || 'Removed'; + if (synced && synced.length) { + msg += ' (auto-synced: ' + synced.join(', ') + ')'; + synced.forEach(s => modelFetch(s)); + } + toast(msg, 'success'); if (props.refresh) { const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh]; names.forEach(n => modelFetch(n)); @@ -95,6 +104,9 @@ export function ConfirmDelete(props = {}) { /** * An action button that POSTs to an API endpoint, toasts on result, * and optionally refreshes models. Supports toggle labels for on/off buttons. + * When the response includes a ``synced`` array (list of subsystem names + * that were auto-updated), shows a secondary toast and refreshes those + * models. * * @param {object} props * @param {string} props.url - API URL @@ -125,7 +137,14 @@ export function ActionButton(props = {}) { if (body !== undefined) opts.body = body; const resp = await apiFetch(props.url, opts); if (resp.ok) { - if (props.successMsg) toast(props.successMsg, 'success'); + const synced = resp.data?.synced; + let msg = props.successMsg || ''; + if (synced && synced.length) { + if (msg) msg += ' '; + msg += '(auto-synced: ' + synced.join(', ') + ')'; + synced.forEach(s => modelFetch(s)); + } + if (msg) toast(msg, 'success'); if (props.refresh) { const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh]; names.forEach(n => modelFetch(n)); diff --git a/webui/static/pages/dhcp.js b/webui/static/pages/dhcp.js index 80d9363..0a31672 100644 --- a/webui/static/pages/dhcp.js +++ b/webui/static/pages/dhcp.js @@ -182,7 +182,13 @@ export default definePage({ 'on:click': async () => { const res = await apiFetch('/api/dhcp/apply', { method: 'POST' }); if (res.ok) { - toast('dnsmasq applied', 'success'); + const synced = res.data?.synced; + let msg = 'dnsmasq applied'; + if (synced && synced.length) { + msg += ' (auto-synced: ' + synced.join(', ') + ')'; + synced.forEach(s => modelFetch(s)); + } + toast(msg, 'success'); modelFetch('dnsmasq'); } else { toast(res.error || 'Apply failed', 'error');