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:
+105
-134
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user