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:
+72
-20
@@ -7,6 +7,7 @@ All privileged commands are handled by daemon/handlers/firewall.py.
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -327,9 +328,16 @@ def _ensure_config_file() -> None:
|
||||
|
||||
|
||||
def get_config() -> dict[str, Any]:
|
||||
"""Return the declarative config from ``config/firewall/config.json``."""
|
||||
_ensure_config_file()
|
||||
return load_json(CONFIG_FILE)
|
||||
"""Return the declarative config from ``config/firewall/config.json``.
|
||||
|
||||
Pure read — never writes. Returns the in-memory default when the file
|
||||
is missing; the file is materialized on the first ``save_config`` (or
|
||||
by the system-config import on first start).
|
||||
"""
|
||||
raw = load_json(CONFIG_FILE)
|
||||
if not raw:
|
||||
return deepcopy(DEFAULT_CONFIG)
|
||||
return raw
|
||||
|
||||
|
||||
def save_config(cfg: dict[str, Any]) -> None:
|
||||
@@ -370,11 +378,10 @@ def _compute_pending_changes(
|
||||
Pure function — no subprocess calls. Caller is responsible for providing
|
||||
live state (typically from the daemon).
|
||||
|
||||
The interfaces diff is only reported for zones whose config explicitly
|
||||
carries an ``interfaces`` key; zones with the key absent are hands-off
|
||||
(apply keeps their live interfaces), so diffing them would advertise
|
||||
changes that never happen. Likewise the target diff is only reported when
|
||||
the config carries an explicit target that normalizes to something other
|
||||
The config is the source of truth for zone interfaces: an absent
|
||||
``interfaces`` key counts as an empty list, so every config zone is
|
||||
diffed on interfaces. Likewise the target diff is only reported when the
|
||||
config carries an explicit target that normalizes to something other
|
||||
than ``default`` — an absent key or a ``default``-normalizing value is
|
||||
unmanaged (apply never re-sets it). Services, masquerade, rich rules and
|
||||
forward ports are reported for all config zones.
|
||||
@@ -387,18 +394,19 @@ def _compute_pending_changes(
|
||||
for zone_name, zone_cfg in cfg_zones.items():
|
||||
live_zone = live_zones.get(zone_name, {})
|
||||
|
||||
if "interfaces" in zone_cfg:
|
||||
cfg_ifaces = set(zone_cfg.get("interfaces", []))
|
||||
live_ifaces = set(live_zone.get("interfaces", []))
|
||||
if cfg_ifaces != live_ifaces:
|
||||
changes.append(
|
||||
{
|
||||
"zone": zone_name,
|
||||
"type": "interfaces",
|
||||
"config": sorted(cfg_ifaces),
|
||||
"live": sorted(live_ifaces),
|
||||
}
|
||||
)
|
||||
# The config is the source of truth for zone interfaces: an absent
|
||||
# key counts as an empty list, so every config zone is diffed.
|
||||
cfg_ifaces = set(zone_cfg.get("interfaces", []))
|
||||
live_ifaces = set(live_zone.get("interfaces", []))
|
||||
if cfg_ifaces != live_ifaces:
|
||||
changes.append(
|
||||
{
|
||||
"zone": zone_name,
|
||||
"type": "interfaces",
|
||||
"config": sorted(cfg_ifaces),
|
||||
"live": sorted(live_ifaces),
|
||||
}
|
||||
)
|
||||
|
||||
cfg_services = set(zone_cfg.get("services", []))
|
||||
live_services = set(live_zone.get("services", []))
|
||||
@@ -488,6 +496,49 @@ def _compute_pending_changes(
|
||||
}
|
||||
|
||||
|
||||
def validate_coverage(fw_cfg: dict[str, Any], net_cfg: dict[str, Any]) -> list[str]:
|
||||
"""Return network-managed interfaces with no firewall zone coverage.
|
||||
|
||||
Pure — compares the declarative firewall config against the network
|
||||
config; no live state. A managed interface is covered when it appears in
|
||||
some zone's ``interfaces`` list (an absent key counts as empty), or is
|
||||
explicitly declared in the top-level ``unmanaged`` list. ``lo`` and
|
||||
``wg*`` interfaces are never guarded (VPN zones are managed by the
|
||||
WireGuard sync; loopback is normally zoneless).
|
||||
|
||||
Args:
|
||||
fw_cfg: Firewall declarative config (``zones`` plus optional
|
||||
top-level ``unmanaged`` list).
|
||||
net_cfg: Network config (``interfaces`` mapping).
|
||||
|
||||
Returns:
|
||||
Sorted list of uncovered interface names; empty when the config is
|
||||
valid.
|
||||
"""
|
||||
managed = [
|
||||
name
|
||||
for name in net_cfg.get("interfaces", {})
|
||||
if name != "lo" and not name.startswith("wg")
|
||||
]
|
||||
if not managed:
|
||||
return []
|
||||
covered: set[str] = set()
|
||||
for zone_cfg in fw_cfg.get("zones", {}).values():
|
||||
if isinstance(zone_cfg, dict):
|
||||
covered.update(
|
||||
i for i in zone_cfg.get("interfaces", []) if isinstance(i, str)
|
||||
)
|
||||
unmanaged_raw = fw_cfg.get("unmanaged", [])
|
||||
unmanaged = (
|
||||
{i for i in unmanaged_raw if isinstance(i, str)}
|
||||
if isinstance(unmanaged_raw, list)
|
||||
else set()
|
||||
)
|
||||
return sorted(
|
||||
name for name in managed if name not in covered and name not in unmanaged
|
||||
)
|
||||
|
||||
|
||||
def config_pending(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compare declarative config against firewalld live state, return diff.
|
||||
|
||||
@@ -554,4 +605,5 @@ __all__ = [
|
||||
"load_backup",
|
||||
"save_backup",
|
||||
"save_config",
|
||||
"validate_coverage",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user