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:
+5
-2
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user