refactor: daemon collectors, thin webui proxies, pure config reads

- move state collectors from lib/state.py to daemon/collectors/ (7
  modules, registration side-effect; daemon/server.py imports the
  package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
  collapses the try/except daemon-proxy boilerplate in all 8
  blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
  truth for zone interfaces (absent key = empty, no hands-off
  zones); pure validate_coverage() enforced at save (400) and apply
  (409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
  new lib/bootstrap.py creates runtime dirs and persists the
  one-shot nginx legacy migration at daemon start, after
  system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
  /status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
  log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
  HARDEN.md dropped (plan implemented); apply-confirm force wording

Tests: 917 passed; ruff check + format clean.
This commit is contained in:
2026-09-03 00:40:56 +00:00
parent 89b64960f3
commit faa076370d
49 changed files with 2834 additions and 3821 deletions
+5 -5
View File
@@ -592,7 +592,7 @@ def _check_acme_account() -> tuple[bool, str]:
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
from lib.state import _parse_account_conf
from daemon.collectors.acme import _parse_account_conf
info = _parse_account_conf(_ACME_HOME)
if info.get("registered"):
@@ -607,11 +607,11 @@ def _check_acme_account() -> tuple[bool, str]:
def _check_account_registered() -> tuple[bool, str]:
"""Blocking check: verify an ACME account is registered.
Delegates to ``lib.state._parse_account_conf()`` which checks both
Delegates to ``daemon.collectors.acme._parse_account_conf()`` which checks both
the legacy .account.conf and the declarative config/acme/config.json
used by modern acme.sh (v3.x).
"""
from lib.state import _parse_account_conf
from daemon.collectors.acme import _parse_account_conf
info = _parse_account_conf(_ACME_HOME)
if info.get("registered"):
@@ -622,10 +622,10 @@ def _check_account_registered() -> tuple[bool, str]:
def _get_account_info() -> dict[str, Any]:
"""Read and return the ACME account info dict.
Delegates to ``lib.state._parse_account_conf()`` for a single
Delegates to ``daemon.collectors.acme._parse_account_conf()`` for a single
source of truth.
"""
from lib.state import _parse_account_conf
from daemon.collectors.acme import _parse_account_conf
return _parse_account_conf(_ACME_HOME)
+26
View File
@@ -0,0 +1,26 @@
"""Shared helpers for daemon handlers."""
from typing import Any
from daemon.server import refresh_state
from lib.sync import SyncEvent, bus
def emit_and_refresh(
subsystem: str, payload: dict[str, Any] | None = None
) -> list[str]:
"""Emit a ``config_saved`` sync event and refresh the affected state.
All mutation handlers end with the same tail: emit the event, refresh
the source subsystem plus every subsystem the sync touched.
Args:
subsystem: Source subsystem name.
payload: Event payload (e.g. ``{"action": "zone_created"}``).
Returns:
Subsystems affected by the sync event.
"""
sync_result = bus.emit(SyncEvent(subsystem, "config_saved", payload or {}))
refresh_state([subsystem, *sync_result.affected_subsystems])
return sync_result.affected_subsystems
+16 -75
View File
@@ -8,6 +8,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
DELETE_DNSMASQ_DNS_RECORD_REMOVE,
DELETE_DNSMASQ_RANGES_REMOVE,
@@ -24,7 +25,7 @@ from daemon.iface import (
POST_DNSMASQ_STATIC_LEASE_ADD,
POST_DNSMASQ_UPSTREAMS,
)
from daemon.server import NotFoundError, refresh_state, registry
from daemon.server import NotFoundError, registry
from lib.common import (
deep_merge,
ensure_dirs,
@@ -35,7 +36,6 @@ from lib.common import (
stamp_applied,
strip_apply_meta,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -152,10 +152,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
if not body:
raise ValueError("Request body required")
_save_config(body)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_saved"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "config_saved"})
return {"config_saved": True}
@@ -171,10 +168,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_config()
merged = deep_merge(current, body)
_save_config(merged)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_patched"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "config_patched"})
return {"config_saved": True}
@@ -201,11 +195,8 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = _get_config()
stamp_applied(cfg_after)
_save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"applied": True, "synced": sync_result.affected_subsystems}
synced = emit_and_refresh("dnsmasq", {"action": "config_applied"})
return {"applied": True, "synced": synced}
@registry.register(GET_DNSMASQ_STATUS)
@@ -281,12 +272,7 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
entry["dns"] = body["dns"]
ranges.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_added", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "range_added", "interface": iface})
return {"interface": iface, "start": start, "end": end}
@@ -321,12 +307,7 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_removed", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "range_removed", "interface": iface})
return {"interface": iface, "start": start, "end": end}
@@ -365,26 +346,14 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
if hostname is not None:
leases[i]["hostname"] = hostname
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "static_lease_added", "mac": mac},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
return {"mac": mac, "ip": ip, "hostname": hostname}
entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname:
entry["hostname"] = hostname
leases.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_added", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "static_lease_added", "mac": mac})
return {"mac": mac, "ip": ip, "hostname": hostname}
@@ -409,12 +378,7 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
if len(cfg["dhcp"]["static_leases"]) == before:
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_removed", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "static_lease_removed", "mac": mac})
return {"mac": mac}
@@ -440,26 +404,14 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
if hostname is not None:
records[i]["hostname"] = hostname
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "dns_record_added", "name": name},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
return {"name": name, "address": address, "hostname": hostname}
entry: dict[str, Any] = {"name": name, "address": address}
if hostname:
entry["hostname"] = hostname
records.append(entry)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_added", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "dns_record_added", "name": name})
return {"name": name, "address": address, "hostname": hostname}
@@ -482,12 +434,7 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
if len(cfg["dns"]["custom_records"]) == before:
raise NotFoundError(f"DNS record '{name}' not found")
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_removed", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "dns_record_removed", "name": name})
return {"name": name}
@@ -503,10 +450,7 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config()
cfg["dns"]["upstreams"] = list(body["servers"])
_save_config(cfg)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "upstreams_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "upstreams_set"})
return {"upstreams": cfg["dns"]["upstreams"]}
@@ -523,8 +467,5 @@ def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config()
cfg["dns"]["domain"] = domain if domain else None
_save_config(cfg)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "domain_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
emit_and_refresh("dnsmasq", {"action": "domain_set"})
return {"domain": cfg["dns"]["domain"]}
+105 -134
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
DELETE_FIREWALL_FORWARD_PORT_REMOVE,
DELETE_FIREWALL_RICH_RULES_REMOVE,
@@ -33,7 +34,7 @@ from daemon.iface import (
POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES,
)
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from daemon.server import ConflictError, NotFoundError, registry
from lib import network
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
from lib.firewall import (
@@ -43,11 +44,11 @@ from lib.firewall import (
_parse_all_zones_output,
_parse_zone_output,
fw_change_summary,
validate_coverage,
)
from lib.firewall import (
save_backup as _save_backup,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -83,6 +84,31 @@ def _save_config(cfg: dict[str, Any]) -> None:
save_json(CONFIG_FILE, cfg, indent=2)
def _check_coverage(cfg: dict[str, Any]) -> None:
"""Reject a config that leaves a managed interface without coverage.
Runs the pure ``validate_coverage`` invariant against the current
network config. ``lo`` and ``wg*`` are exempt, and interfaces declared
in the top-level ``unmanaged`` list are exempt.
Args:
cfg: The (merged or full) firewall config dict to validate.
Raises:
ValueError: If a network-managed interface is not covered by any
zone and is not declared under ``unmanaged``.
"""
uncovered = validate_coverage(cfg, network.get_config())
if uncovered:
raise ValueError(
"Refusing to save: "
f"{', '.join(repr(n) for n in uncovered)} "
f"have no firewall zone coverage and are not declared in the "
f"'unmanaged' list. Assign each interface to a zone, or add it "
f"to the top-level 'unmanaged' list."
)
def _reload() -> None:
"""Reload firewalld to apply permanent changes."""
run(["firewall-cmd", "--reload"], sudo=True)
@@ -150,11 +176,15 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
- the config would strip both https and ssh from the default zone
(management lockout);
- a network-subsystem-managed interface would end up with no firewall
zone coverage after apply (``lo`` and ``wg*`` interfaces are excluded).
Zones whose config omits the ``interfaces`` key are left hands-off, so
their current live interfaces count as coverage, as do the live
interfaces of zones that are live but absent from the config.
- the config leaves a network-subsystem-managed interface with no
firewall zone coverage (``lo`` and ``wg*`` interfaces are excluded).
The config is the source of truth for zone interfaces — an absent
``interfaces`` key counts as empty — so coverage is computed from the
config alone via ``validate_coverage`` with no live-state fallback.
Interfaces listed in the top-level ``unmanaged`` key are exempt. The
same invariant is enforced at save time (POST/PATCH /firewall/config),
so a conflict here means the network config changed after the firewall
config was saved (e.g. a new interface no zone covers).
"""
from lib.firewall import get_config as _get_lib_config
@@ -178,37 +208,20 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
f'to the zone\'s services, or pass {{"force": true}}.'
)
# Coverage guard: after apply, every network-managed interface must
# belong to a zone or traffic (and DHCP) on that segment is dropped.
live_active = _parse_active_zones(
run(["firewall-cmd", "--get-active-zones"], sudo=True)
)
covered: set[str] = set()
for zn, zc in cfg_zones.items():
if "interfaces" in (zc if isinstance(zc, dict) else {}):
covered.update(zc["interfaces"])
else:
covered.update(live_active.get(zn, []))
covered.update(
iface
for zn, ifaces in live_active.items()
if zn not in cfg_zones
for iface in ifaces
)
net_cfg = network.get_config()
guarded = [
name
for name in net_cfg.get("interfaces", {})
if name != "lo" and not name.startswith("wg")
]
uncovered = [name for name in guarded if name not in covered]
# Coverage invariant: every network-managed interface must be
# covered by a zone in the config (or declared unmanaged), or
# traffic (and DHCP) on that segment is dropped. Pure config check
# — the config is the source of truth, so no live-state comparison.
uncovered = validate_coverage(cfg, network.get_config())
if uncovered:
raise ConflictError(
"Refusing to apply: "
f"{', '.join(repr(n) for n in uncovered)} "
f"would have no firewall zone coverage after apply, so all "
f"traffic (including DHCP) from those segments would be "
f'dropped. Keep the interface in a zone, or pass {{"force": true}}.'
f"have no firewall zone coverage in the config and are not "
f"declared unmanaged, so all traffic (including DHCP) from "
f"those segments would be dropped. Assign each interface to "
f"a zone (or list it under the config's top-level 'unmanaged' "
f'key), or pass {{"force": true}}.'
)
# Pre-apply snapshot for disaster recovery: the permanent zone view plus
@@ -297,38 +310,36 @@ def _config_apply(force: bool = False) -> dict[str, Any]:
)
# Step 3: Reconcile interfaces — same remove-then-add pattern.
# Absent "interfaces" key = hands off (keep the zone's live
# interfaces); an explicit empty list = intentional unassign-all.
if "interfaces" in zone_cfg:
current_ifaces: list[str] = []
with suppress(Exception):
current_ifaces = _parse_zone_output(
zone_name,
run(
["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True
),
).get("interfaces", [])
for iface in current_ifaces:
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in zone_cfg.get("interfaces", []):
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--add-interface=" + iface,
"--permanent",
],
sudo=True,
)
# The config is the source of truth: an absent "interfaces" key
# counts as an empty list (unassign-all), matching the coverage
# invariant and the pending diff.
current_ifaces: list[str] = []
with suppress(Exception):
current_ifaces = _parse_zone_output(
zone_name,
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
).get("interfaces", [])
for iface in current_ifaces:
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in zone_cfg.get("interfaces", []):
run(
[
"firewall-cmd",
f"--zone={zone_name}",
"--add-interface=" + iface,
"--permanent",
],
sudo=True,
)
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
@@ -576,19 +587,20 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
Dict with ``config_saved`` flag set to ``True``.
Raises:
ValueError: If body is empty, missing ``zones`` key,
or ``zones`` is not a dict.
ValueError: If body is empty, missing ``zones`` key, ``zones`` is
not a dict, ``unmanaged`` is not a list, or the config leaves a
network-managed interface without zone coverage.
"""
if not body or "zones" not in body:
raise ValueError("'zones' key is required")
if not isinstance(body["zones"], dict):
raise ValueError("'zones' must be a dict")
if "unmanaged" in body and not isinstance(body["unmanaged"], list):
raise ValueError("'unmanaged' must be a list")
_check_coverage(body)
_save_config(body)
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_saved"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "config_saved"})
return {"config_saved": True}
@@ -604,20 +616,22 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
Dict with ``config_saved`` flag set to ``True``.
Raises:
ValueError: If body is empty.
ValueError: If body is empty, ``unmanaged`` is not a list, or the
merged config leaves a network-managed interface without zone
coverage.
"""
if not body:
raise ValueError("Request body must be a JSON object")
if "unmanaged" in body and not isinstance(body["unmanaged"], list):
raise ValueError("'unmanaged' must be a list")
from lib.common import deep_merge
current = _get_config()
merged = deep_merge(current, body)
_check_coverage(merged)
_save_config(merged)
logger.info("Firewall config patched: %s", sorted(body.keys()))
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_patched"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "config_patched"})
return {"config_saved": True}
@@ -663,17 +677,15 @@ def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
Raises:
ConflictError: If the config would strip both https and ssh from the
default zone, or would leave a network-managed interface without
zone coverage, and ``force`` is not set.
default zone, or would remove zone coverage from a
network-managed interface that is covered now, and ``force`` is
not set.
"""
force = bool(_body and _body.get("force"))
result = _config_apply(force=force)
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
result["synced"] = sync_result.affected_subsystems
synced = emit_and_refresh("firewall", {"action": "config_applied"})
result["synced"] = synced
return result
@@ -721,12 +733,7 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
_reload()
logger.info("Zone '%s' created (target=%s)", zone_name, target)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "zone_created", "zone": zone_name}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "zone_created", "zone": zone_name})
return {"zone": zone_name}
@@ -754,10 +761,7 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload()
logger.info("Zone '%s' deleted", zone)
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "zone_deleted", "zone": zone})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "zone_deleted", "zone": zone})
return {"zone": zone}
@@ -862,12 +866,7 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
_save_config(cfg)
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "interfaces_set", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "interfaces_set", "zone": zone})
return {"zone": zone, "interfaces": interfaces}
@@ -938,10 +937,7 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
stamp_applied(cfg)
_save_config(cfg)
logger.info("Zone '%s' services set to %s", zone, services)
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "services_set", "zone": zone})
return {"zone": zone, "services": services}
@@ -987,12 +983,7 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg["zones"][zone]["rich_rules"].append(entry)
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "rich_rule_added", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "rich_rule_added", "zone": zone})
return {"zone": zone, "id": rule_id, "rule": rule}
@@ -1044,12 +1035,7 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
]
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "rich_rule_removed", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "rich_rule_removed", "zone": zone})
return {"zone": zone, "id": rule_id}
@@ -1130,12 +1116,7 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
zone_cfg["masquerade"] = bool(enable)
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "masquerade_set", "zone": zone})
return {"zone": zone, "masquerade": bool(enable)}
@@ -1192,12 +1173,7 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
cfg["zones"][zone]["forward_ports"].append(entry)
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "forward_port_added", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "forward_port_added", "zone": zone})
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
@@ -1258,12 +1234,7 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
]
stamp_applied(cfg)
_save_config(cfg)
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "forward_port_removed", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
emit_and_refresh("firewall", {"action": "forward_port_removed", "zone": zone})
return {"zone": zone, "port": int(port), "proto": proto}
+8 -17
View File
@@ -10,6 +10,7 @@ import re
from pathlib import Path
from typing import Any
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
GET_NETWORK_INFER_DHCP_RANGES,
GET_NETWORK_INFER_ZONES,
@@ -20,7 +21,7 @@ from daemon.iface import (
POST_NETWORK_INTERFACE_RELOAD,
POST_NETWORK_SYSCTL_SET,
)
from daemon.server import NotFoundError, refresh_state, registry
from daemon.server import NotFoundError, registry
from lib.common import run, stamp_applied, validate_interface_name
from lib.dnsmasq import get_config as _get_dm_cfg
from lib.dnsmasq import save_config as _save_dm_cfg
@@ -36,7 +37,6 @@ from lib.network import (
render_network_file,
save_config,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -220,16 +220,13 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
cfg_after = get_config()
stamp_applied(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent(
"networkd", "config_saved", {"action": "interface_saved", "interface": name}
)
synced = emit_and_refresh(
"networkd", {"action": "interface_saved", "interface": name}
)
refresh_state(["networkd", *sync_result.affected_subsystems])
return {
"name": name,
"applied": deployed,
"synced": sync_result.affected_subsystems,
"synced": synced,
}
@@ -298,10 +295,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = get_config()
stamp_applied(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("networkd", "config_saved", {"action": "config_applied"})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
synced = emit_and_refresh("networkd", {"action": "config_applied"})
logger.info(
"Network config applied: %d interfaces, %d stale cleaned",
@@ -312,7 +306,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"applied": len(generated),
"files": [str(p) for p in generated],
"cleaned": [str(p) for p in cleaned],
"synced": sync_result.affected_subsystems,
"synced": synced,
}
@@ -366,8 +360,5 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
logger.info("sysctl %s set to %s", name, value)
sync_result = bus.emit(
SyncEvent("networkd", "config_saved", {"action": "sysctl_set", "name": name})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
emit_and_refresh("networkd", {"action": "sysctl_set", "name": name})
return {"name": name, "value": value}
+15 -61
View File
@@ -5,6 +5,7 @@ import os
from pathlib import Path
from typing import Any
from daemon.handlers.common import emit_and_refresh
from daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
@@ -27,9 +28,8 @@ from daemon.iface import (
POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD,
)
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from daemon.server import ConflictError, NotFoundError, registry
from lib.common import deep_merge, run, stamp_applied, strip_apply_meta
from lib.sync import SyncEvent, bus
from lib.wireguard import (
_class_interface_name,
_class_peers,
@@ -122,10 +122,7 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
body["access_classes"] = current.get("access_classes", {})
_save_wireguard_config(body)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_saved"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "config_saved"})
return {"config_saved": True}
@@ -153,10 +150,7 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_wireguard_config()
merged = deep_merge(current, body)
_save_wireguard_config(merged)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_patched"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "config_patched"})
return {"config_saved": True}
@@ -217,13 +211,10 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after = _get_wireguard_config()
stamp_applied(cfg_after)
_save_wireguard_config(cfg_after)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
synced = emit_and_refresh("wireguard", {"action": "config_applied"})
return {
"applied": True,
"synced": sync_result.affected_subsystems,
"synced": synced,
"interfaces": affected,
}
@@ -255,10 +246,7 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
except Exception:
pass
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "tunnel_down"})
return {"down": True}
@@ -296,12 +284,7 @@ def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": "class_up", "class_key": class_key}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_up", "class_key": class_key})
return {"up": True, "interface": ifname}
@@ -328,14 +311,7 @@ def class_down(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname)
except Exception:
pass
sync_result = bus.emit(
SyncEvent(
"wireguard",
"config_saved",
{"action": "class_down", "class_key": class_key},
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_down", "class_key": class_key})
return {"down": True, "interface": ifname}
@@ -377,10 +353,7 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
_save_wireguard_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "initialized"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "initialized"})
safe = dict(cfg)
safe["interface"] = dict(safe["interface"])
@@ -467,12 +440,7 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
logger.info("WireGuard peer '%s' added", name)
_peer_action = "peer_added"
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": _peer_action, "peer_name": name}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": _peer_action, "peer_name": name})
peer_out = dict(peers[name])
peer_out.pop("private_key", None)
return peer_out
@@ -498,12 +466,7 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
del peers[name]
_save_wireguard_config(cfg)
logger.info("WireGuard peer '%s' removed", name)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": "peer_removed", "peer_name": name}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "peer_removed", "peer_name": name})
return {"name": name}
@@ -629,10 +592,7 @@ def create_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"public_key": "",
}
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_created"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_created"})
out = dict(classes[key])
out.pop("private_key", None)
return out
@@ -660,10 +620,7 @@ def update_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if field in body:
class_cfg[field] = body[field]
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_updated"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_updated"})
out = dict(classes[key])
out.pop("private_key", None)
return {"key": key, **out}
@@ -699,8 +656,5 @@ def delete_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
del classes[key]
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_deleted"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
emit_and_refresh("wireguard", {"action": "class_deleted"})
return {"key": key}