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 -2
View File
@@ -95,11 +95,14 @@ def _run_acme(args: list[str]) -> str:
acme_home_env,
"--config-home",
acme_home_env,
*args,
# Append the full transcript to $ACME_HOME/acme.sh.log so manual
# runs (whose stdout is captured below) leave a persistent record
# of the raw CA exchange.
# of the raw CA exchange. Last on purpose: acme.sh treats the next
# token after --log as its optional file argument, so a trailing
# --log defaults the log to $LE_CONFIG_HOME/acme.sh.log and can
# never swallow a real argument.
"--log",
*args,
]
try:
+40
View File
@@ -0,0 +1,40 @@
"""Daemon-startup filesystem bootstrap.
Runs once at daemon startup, after the system-config import and before the
first state collection. Creates the runtime directories subsystems
read/write and persists the one-shot nginx legacy-format migration.
Config *files* are deliberately NOT created here: ``get_config`` reads are
pure and return in-memory defaults, and the system-config import must see
absent files in order to adopt live system state on first start. Files are
materialized on the first ``save_config`` (or by the import itself).
"""
from lib import dnsmasq, firewall, network, nginx, wireguard
from lib.common import ensure_dirs
__all__ = ["bootstrap"]
def bootstrap() -> None:
"""Create runtime directories and persist the one-shot nginx migration.
Idempotent — existing directories are left untouched and the nginx
migration only rewrites the on-disk file when it actually changes.
"""
ensure_dirs(
dnsmasq.CONFIG_DIR,
dnsmasq.DATA_DIR,
dnsmasq.FRAGMENTS_DIR,
firewall.CONFIG_DIR,
firewall.DATA_DIR,
network.CONFIG_DIR,
network.DATA_DIR,
nginx.CONFIG_DIR,
nginx.SITES_DIR,
wireguard.CONFIG_PATH.parent,
)
# One-shot legacy-format migration for the nginx config (see
# ``lib.nginx.get_config``). Runs here, at startup, so read paths stay
# side-effect free.
nginx.migrate_config_file()
+19
View File
@@ -120,6 +120,24 @@ def _diff_nodes(old: Any, new: Any, path: str, out: list[dict[str, Any]]) -> Non
out.append({"path": path, "action": "changed", "old": old, "new": new})
def compute_pending(cfg: dict[str, Any]) -> tuple[bool, list[dict[str, Any]]]:
"""Return ``(pending_changes, pending_diff)`` from apply bookkeeping keys.
``pending_changes`` is ``True`` when the config was never applied or its
content no longer matches the recorded ``_last_applied_hash``. When
pending and a ``_last_applied_config`` snapshot is recorded, the diff is a
field-level comparison of the snapshot against the current (meta-stripped)
config; otherwise it is empty.
"""
pending = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(cfg)
diff: list[dict[str, Any]] = []
if pending:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
diff = deep_diff(snap, strip_apply_meta(cfg))
return pending, diff
def validate_interface_name(name: str) -> str:
"""Validate a Linux network interface name.
@@ -301,6 +319,7 @@ __all__ = [
"_APPLY_HASH_KEY",
"_LAST_APPLIED_CONFIG_KEY",
"_hash_password",
"compute_pending",
"config_hash",
"deep_diff",
"deep_merge",
+11 -2
View File
@@ -37,8 +37,12 @@ DEFAULT_CFG: dict[str, Any] = {
def get_config() -> dict[str, Any]:
"""Load current dnsmasq config from JSON state file."""
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
"""Load current dnsmasq config from JSON state file.
Pure read — never writes or creates directories. Returns the in-memory
default when the file is missing; directories and the file are
materialized on the first ``save_config``.
"""
raw = load_json(CONFIG_PATH)
if not raw:
return deepcopy(DEFAULT_CFG)
@@ -73,6 +77,11 @@ def set_domain(domain: str | None) -> None:
__all__ = [
"CONFIG_DIR",
"CONFIG_PATH",
"DATA_DIR",
"DEFAULT_CFG",
"FRAGMENTS_DIR",
"get_config",
"save_config",
"set_domain",
+72 -20
View File
@@ -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",
]
+8 -4
View File
@@ -8,6 +8,7 @@ import contextlib
import ipaddress
import json
import logging
from copy import deepcopy
from pathlib import Path
from typing import Any
@@ -78,13 +79,16 @@ __all__ = [
def get_config() -> dict[str, Any]:
"""Read network config from config/network/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``.
Returns:
Dict with ``interfaces`` mapping interface names to config entries.
"""
if not CONFIG_FILE.exists():
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
return load_json(CONFIG_FILE)
raw = load_json(CONFIG_FILE)
if not raw:
return deepcopy(DEFAULT_CONFIG)
return raw
def save_config(cfg: dict[str, Any]) -> None:
+24 -9
View File
@@ -169,31 +169,45 @@ def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
def get_config() -> dict[str, Any]:
"""Load the current nginx config, initializing with defaults if needed.
"""Load the current nginx config (pure read, in-memory migration).
Ensures config and sites directories exist, applies migrations for
legacy formats, then returns the config dict.
Never writes or creates directories. Returns the in-memory default when
the file is missing and applies legacy-format migration in memory, so
read paths (state collectors, apply-time checks) stay side-effect free.
The one-shot on-disk migration runs at daemon startup via
``migrate_config_file``.
Returns:
The complete config dict with ``backends``, ``domains``, and ``ssl`` keys.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
if not raw:
raw = deepcopy(DEFAULT_CONFIG)
save_config(raw)
return raw
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
if "backends" not in raw:
raw["backends"] = {}
return _migrate_config(raw)
def migrate_config_file() -> bool:
"""Persist the one-shot legacy-format migration, if the file needs it.
Runs at daemon startup so ``get_config`` reads stay pure. Rewrites the
on-disk file only when migration actually changes it.
Returns:
True when the on-disk file was rewritten, False otherwise.
"""
raw = load_json(CONFIG_FILE)
if not raw:
return False
pre = deepcopy(raw)
raw = _migrate_config(raw)
# Read-only unless normalization/migration actually changed the config;
# re-saving on every read rewrites the file (owner/mtime churn).
if raw != pre:
save_config(raw)
return raw
return True
return False
def save_config(cfg: dict[str, Any]) -> None:
@@ -616,6 +630,7 @@ __all__ = [
"get_config",
"get_domains",
"get_management_domains",
"migrate_config_file",
"remove_domain",
"save_config",
"test_config",
+12 -997
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -370,7 +370,7 @@ def status() -> dict[str, Any]:
if res.returncode != 0:
result["classes"][class_key] = {"up": False, "peers": []}
continue
class_status = _parse_wg_show_output(res.stdout.strip())
class_status = parse_wg_show_output(res.stdout.strip())
result["classes"][class_key] = class_status
if class_status["up"]:
result["up"] = True
@@ -382,7 +382,7 @@ def status() -> dict[str, Any]:
ifname = cfg["interface"].get("name", "wg0")
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
if res.returncode == 0:
parsed = _parse_wg_show_output(res.stdout.strip())
parsed = parse_wg_show_output(res.stdout.strip())
result["up"] = parsed["up"]
result["interface"] = parsed.get("interface", {})
result["peers"] = parsed.get("peers", [])
@@ -392,8 +392,12 @@ def status() -> dict[str, Any]:
return result
def _parse_wg_show_output(raw: str) -> dict[str, Any]:
"""Parse ``wg show`` output into structured dict."""
def parse_wg_show_output(raw: str) -> dict[str, Any]:
"""Parse ``wg show`` output into structured dict.
Returns ``{"up", "interface", "peers"}`` where *interface* carries
``public_key``, ``listen_port`` and (when present) ``fwmark``.
"""
result: dict[str, Any] = {
"up": False,
"interface": {},
@@ -742,6 +746,7 @@ __all__ = [
"get_peer_status",
"get_peers",
"initialize",
"parse_wg_show_output",
"remove_peer",
"save_config",
"set_listen_port",