sync: add cross-subsystem event bus for config consistency

Add EventBus with loop guards to keep firewall, dnsmasq, wireguard,
and network configs consistent. Handlers emit SyncEvent after mutations;
subscribers compute diffs and write JSON without manual cascade loops.
This commit is contained in:
2026-06-29 20:44:36 +00:00
parent 348bbfbca6
commit 9088f34345
15 changed files with 2117 additions and 59 deletions
+75 -15
View File
@@ -26,7 +26,8 @@ from daemon.iface import (
POST_DNSMASQ_UPSTREAMS, POST_DNSMASQ_UPSTREAMS,
) )
from daemon.server import NotFoundError, refresh_state, registry 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__) logger = logging.getLogger(__name__)
@@ -156,7 +157,10 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
_save_config(body) _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} return {"config_saved": True}
@@ -172,7 +176,10 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_config() current = _get_config()
merged = deep_merge(current, body) merged = deep_merge(current, body)
_save_config(merged) _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} return {"config_saved": True}
@@ -198,8 +205,11 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = _get_config() cfg_after = _get_config()
cfg_after[_APPLY_HASH_KEY] = _config_hash(cfg_after) cfg_after[_APPLY_HASH_KEY] = _config_hash(cfg_after)
_save_config(cfg_after) _save_config(cfg_after)
refresh_state(["dnsmasq"]) sync_result = bus.emit(
return {"applied": True} 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) @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"] entry["dns"] = body["dns"]
ranges.append(entry) ranges.append(entry)
_save_config(cfg) _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} 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" f"DHCP range for interface '{iface}' ({start}-{end}) not found"
) )
_save_config(cfg) _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} 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: if hostname is not None:
leases[i]["hostname"] = hostname leases[i]["hostname"] = hostname
_save_config(cfg) _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} return {"mac": mac, "ip": ip, "hostname": hostname}
entry: dict[str, Any] = {"mac": mac, "ip": ip} entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname: if hostname:
entry["hostname"] = hostname entry["hostname"] = hostname
leases.append(entry) leases.append(entry)
_save_config(cfg) _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} 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: if len(cfg["dhcp"]["static_leases"]) == before:
raise NotFoundError(f"Static lease for MAC '{mac}' not found") raise NotFoundError(f"Static lease for MAC '{mac}' not found")
_save_config(cfg) _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} 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: if hostname is not None:
records[i]["hostname"] = hostname records[i]["hostname"] = hostname
_save_config(cfg) _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} return {"name": name, "address": address, "hostname": hostname}
entry: dict[str, Any] = {"name": name, "address": address} entry: dict[str, Any] = {"name": name, "address": address}
if hostname: if hostname:
entry["hostname"] = hostname entry["hostname"] = hostname
records.append(entry) records.append(entry)
_save_config(cfg) _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} 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: if len(cfg["dns"]["custom_records"]) == before:
raise NotFoundError(f"DNS record '{name}' not found") raise NotFoundError(f"DNS record '{name}' not found")
_save_config(cfg) _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} return {"name": name}
@@ -438,7 +492,10 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config() cfg = _get_config()
cfg["dns"]["upstreams"] = list(body["servers"]) cfg["dns"]["upstreams"] = list(body["servers"])
_save_config(cfg) _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"]} 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 = _get_config()
cfg["dns"]["domain"] = domain if domain else None cfg["dns"]["domain"] = domain if domain else None
_save_config(cfg) _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"]} return {"domain": cfg["dns"]["domain"]}
+64 -12
View File
@@ -43,6 +43,7 @@ from lib.firewall import (
from lib.firewall import ( from lib.firewall import (
save_backup as _save_backup, save_backup as _save_backup,
) )
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__) 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") raise ValueError("'zones' must be a dict")
_save_config(body) _save_config(body)
logger.info("Firewall config saved (%d zones)", len(body["zones"])) 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} 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) merged = deep_merge(current, body)
_save_config(merged) _save_config(merged)
logger.info("Firewall config patched: %s", sorted(body.keys())) 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} 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]: def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
result = _config_apply() result = _config_apply()
logger.info("Firewall config applied: %s", result.get("applied_zones", [])) 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 return result
@@ -404,7 +415,12 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
) )
_reload() _reload()
logger.info("Zone '%s' created (target=%s)", zone_name, target) 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} 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) run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload() _reload()
logger.info("Zone '%s' deleted", zone) 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} return {"zone": zone}
@@ -489,7 +508,12 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
_save_config(cfg) _save_config(cfg)
logger.info("Zone '%s' interfaces set to %s", zone, interfaces) 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} 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, sudo=True,
) )
_reload() _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} 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} entry = {"id": rule_id, "rule": rule}
cfg["zones"][zone]["rich_rules"].append(entry) cfg["zones"][zone]["rich_rules"].append(entry)
_save_config(cfg) _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} 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 r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
] ]
_save_config(cfg) _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} 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" action = "--add-masquerade" if enable else "--remove-masquerade"
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True) run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload() _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)} 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.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
cfg["zones"][zone]["forward_ports"].append(entry) cfg["zones"][zone]["forward_ports"].append(entry)
_save_config(cfg) _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} 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) fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
] ]
_save_config(cfg) _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} return {"zone": zone, "port": int(port), "proto": proto}
+23 -2
View File
@@ -20,7 +20,7 @@ from daemon.iface import (
POST_NETWORK_INTERFACE_RELOAD, POST_NETWORK_INTERFACE_RELOAD,
POST_NETWORK_SYSCTL_SET, 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.common import run, validate_interface_name
from lib.dnsmasq import set_upstreams from lib.dnsmasq import set_upstreams
from lib.network import ( from lib.network import (
@@ -34,6 +34,7 @@ from lib.network import (
render_network_file, render_network_file,
save_config, save_config,
) )
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__) 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) 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) @registry.register(POST_NETWORK_INTERFACE_RELOAD)
@@ -251,6 +262,11 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
except Exception: except Exception:
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True) 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( logger.info(
"Network config applied: %d interfaces, %d stale cleaned", "Network config applied: %d interfaces, %d stale cleaned",
len(generated), len(generated),
@@ -260,6 +276,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"applied": len(generated), "applied": len(generated),
"files": [str(p) for p in generated], "files": [str(p) for p in generated],
"cleaned": [str(p) for p in cleaned], "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) 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} return {"name": name, "value": value}
+6 -1
View File
@@ -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 removal: if body has `path` key (string) but no `paths`/`backend`/`headers`
path_to_remove = body.get("path") 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", {}) paths = entry.get("paths", {})
if path_to_remove in paths: if path_to_remove in paths:
del paths[path_to_remove] del paths[path_to_remove]
+36 -8
View File
@@ -25,6 +25,7 @@ from daemon.iface import (
) )
from daemon.server import NotFoundError, refresh_state, registry from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, load_json, run, run_proc, save_json from lib.common import deep_merge, load_json, run, run_proc, save_json
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -126,7 +127,10 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
if current_key: if current_key:
body.setdefault("interface", {})["private_key"] = current_key body.setdefault("interface", {})["private_key"] = current_key
_save_config(body) _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} return {"config_saved": True}
@@ -146,7 +150,10 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_config() current = _get_config()
merged = deep_merge(current, body) merged = deep_merge(current, body)
_save_config(merged) _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} return {"config_saved": True}
@@ -167,8 +174,11 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
local_tmp.unlink(missing_ok=True) local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True) run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"]) logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
refresh_state(["wireguard"]) sync_result = bus.emit(
return {"applied": True} 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) @registry.register(POST_WIREGUARD_DOWN)
@@ -178,7 +188,10 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
name = cfg["interface"]["name"] name = cfg["interface"]["name"]
run([WG_QUICK_BIN, "down", name], sudo=True) run([WG_QUICK_BIN, "down", name], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", name) 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} return {"down": True}
@@ -205,7 +218,10 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
cfg["interface"]["public_key"] = public_key cfg["interface"]["public_key"] = public_key
_save_config(cfg) _save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16]) 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 = dict(cfg)
safe["interface"] = dict(safe["interface"]) safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None) 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: if body.get("preshared_key") is not None:
peer["preshared_key"] = body["preshared_key"] peer["preshared_key"] = body["preshared_key"]
logger.info("WireGuard peer '%s' updated", name) logger.info("WireGuard peer '%s' updated", name)
_peer_action = "peer_updated"
else: else:
res = run_proc([WG_BIN, "genkey"], sudo=True) res = run_proc([WG_BIN, "genkey"], sudo=True)
priv = res.stdout.strip() 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"), "preshared_key": body.get("preshared_key"),
} }
logger.info("WireGuard peer '%s' added", name) logger.info("WireGuard peer '%s' added", name)
_peer_action = "peer_added"
_save_config(cfg) _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 = dict(peers[name])
peer_out.pop("private_key", None) peer_out.pop("private_key", None)
return peer_out return peer_out
@@ -276,7 +299,12 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
del peers[name] del peers[name]
_save_config(cfg) _save_config(cfg)
logger.info("WireGuard peer '%s' removed", name) 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} return {"name": name}
+28 -5
View File
@@ -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. 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 #### 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. 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 ### Status
@@ -1114,7 +1125,12 @@ POST /api/wireguard/apply
Write the current configuration to `wg0.conf` and bring the tunnel up. 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`). 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 ### Status
@@ -1329,7 +1350,8 @@ Save network config for an interface, render the `.network` file, copy it to `/e
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `name` | `string` | Interface name | | `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. 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 | | `applied` | `number` | Number of interfaces applied |
| `files` | `[string, ...]` | Paths of generated files | | `files` | `[string, ...]` | Paths of generated files |
| `cleaned` | `[string, ...]` | Paths of removed stale files | | `cleaned` | `[string, ...]` | Paths of removed stale files |
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
### Helpers ### Helpers
+40
View File
@@ -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`. 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 ## Directory Structure
### Config — Declarative Settings ### Config — Declarative Settings
+17 -1
View File
@@ -478,4 +478,20 @@ When `POST /api/network/apply` is called, the handler automatically collects pub
### Generated Files ### Generated Files
Each interface config entry produces a `50-<name>.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]`). Each interface config entry produces a `50-<name>.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.
+1 -3
View File
@@ -155,9 +155,7 @@ def _parse_all_zones_output(output: str) -> dict[str, dict[str, Any]]:
# Finalize last zone # Finalize last zone
if current_name is not None and current_lines: if current_name is not None and current_lines:
zones[current_name] = _parse_zone_output( zones[current_name] = _parse_zone_output(current_name, "\n".join(current_lines))
current_name, "\n".join(current_lines)
)
return zones return zones
+748
View File
@@ -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 AB and BC, 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",
]
+13 -8
View File
@@ -615,16 +615,21 @@ class TestParseAllZonesOutput:
def test_all_default_fields_present(self): def test_all_default_fields_present(self):
result = firewall._parse_all_zones_output( result = firewall._parse_all_zones_output(
"dmz\n" "dmz\n target: default\n interfaces: \n services: \n rich rules: \n"
" target: default\n"
" interfaces: \n"
" services: \n"
" rich rules: \n"
) )
zone = result["dmz"] zone = result["dmz"]
for field in ( for field in (
"interfaces", "sources", "services", "ports", "protocols", "interfaces",
"forward-ports", "masquerade", "ics", "icmp-blocks", "module", "sources",
"target", "rich-rules", "services",
"ports",
"protocols",
"forward-ports",
"masquerade",
"ics",
"icmp-blocks",
"module",
"target",
"rich-rules",
): ):
assert field in zone, f"Missing field: {field}" assert field in zone, f"Missing field: {field}"
+1031
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -238,7 +238,13 @@ export function apiSubmit(opts) {
} }
const res = await apiFetch(url, { method, body: b }); const res = await apiFetch(url, { method, body: b });
if (res.ok) { 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 (closeModal) closeModal();
if (refresh) { if (refresh) {
const models = Array.isArray(refresh) ? refresh : [refresh]; const models = Array.isArray(refresh) ? refresh : [refresh];
+21 -2
View File
@@ -64,6 +64,9 @@ export function Card(props = {}) {
/** /**
* A Remove button that confirms, deletes via API, toasts, and refreshes models. * 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 {object} props
* @param {string} props.url - API DELETE URL * @param {string} props.url - API DELETE URL
@@ -81,7 +84,13 @@ export function ConfirmDelete(props = {}) {
if (!confirm(props.message)) return; if (!confirm(props.message)) return;
const r = await apiFetch(props.url, opts); const r = await apiFetch(props.url, opts);
if (r.ok) { 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) { if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh]; const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n)); 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, * An action button that POSTs to an API endpoint, toasts on result,
* and optionally refreshes models. Supports toggle labels for on/off buttons. * 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 {object} props
* @param {string} props.url - API URL * @param {string} props.url - API URL
@@ -125,7 +137,14 @@ export function ActionButton(props = {}) {
if (body !== undefined) opts.body = body; if (body !== undefined) opts.body = body;
const resp = await apiFetch(props.url, opts); const resp = await apiFetch(props.url, opts);
if (resp.ok) { 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) { if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh]; const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n)); names.forEach(n => modelFetch(n));
+7 -1
View File
@@ -182,7 +182,13 @@ export default definePage({
'on:click': async () => { 'on:click': async () => {
const res = await apiFetch('/api/dhcp/apply', { method: 'POST' }); const res = await apiFetch('/api/dhcp/apply', { method: 'POST' });
if (res.ok) { 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'); modelFetch('dnsmasq');
} else { } else {
toast(res.error || 'Apply failed', 'error'); toast(res.error || 'Apply failed', 'error');