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:
+75
-15
@@ -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
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -479,3 +479,19 @@ 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
@@ -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
@@ -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",
|
||||||
|
]
|
||||||
+13
-8
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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];
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user