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
+14
View File
@@ -58,6 +58,20 @@ class TestRunAcme:
assert cmd[0] == "/usr/local/bin/acme.sh"
assert "sudo" not in cmd
@patch("lib.acme._find_acme")
@patch("lib.acme.subprocess.run")
def test_log_flag_is_last(self, mock_run, mock_find):
# --log must trail the subcommand args: acme.sh would otherwise
# consume the first subcommand arg as its (optional) file argument.
mock_find.return_value = "/usr/local/bin/acme.sh"
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
acme._run_acme(["--issue", "-d", "example.com"])
cmd = mock_run.call_args[0][0]
assert cmd.count("--log") == 1
assert cmd[-1] == "--log"
assert cmd.index("--issue") < cmd.index("--log")
assert "example.com" in cmd
class TestParseListOutput:
def test_parses_single_entry(self):
+62
View File
@@ -0,0 +1,62 @@
"""Tests for the daemon-startup filesystem bootstrap (lib.bootstrap)."""
import pytest
from lib import bootstrap, dnsmasq, firewall, network, nginx, wireguard
@pytest.fixture()
def sandbox(tmp_path, monkeypatch):
"""Point every bootstrap-referenced path into a throwaway tree."""
cfg = tmp_path / "config"
data = tmp_path / "data"
monkeypatch.setattr(dnsmasq, "CONFIG_DIR", cfg / "dnsmasq")
monkeypatch.setattr(dnsmasq, "DATA_DIR", data / "dnsmasq")
monkeypatch.setattr(dnsmasq, "FRAGMENTS_DIR", data / "dnsmasq" / "fragments")
monkeypatch.setattr(firewall, "CONFIG_DIR", cfg / "firewall")
monkeypatch.setattr(firewall, "DATA_DIR", data / "firewall")
monkeypatch.setattr(network, "CONFIG_DIR", cfg / "network")
monkeypatch.setattr(network, "DATA_DIR", data / "networkd")
monkeypatch.setattr(nginx, "CONFIG_DIR", cfg / "nginx")
monkeypatch.setattr(nginx, "DATA_DIR", data / "nginx")
monkeypatch.setattr(nginx, "SITES_DIR", data / "nginx" / "sites-enabled")
monkeypatch.setattr(nginx, "CONFIG_FILE", cfg / "nginx" / "config.json")
monkeypatch.setattr(wireguard, "CONFIG_PATH", cfg / "wireguard" / "config.json")
return tmp_path
def test_creates_runtime_dirs(sandbox):
bootstrap.bootstrap()
assert dnsmasq.FRAGMENTS_DIR.is_dir()
assert firewall.DATA_DIR.is_dir()
assert network.DATA_DIR.is_dir()
assert nginx.SITES_DIR.is_dir()
assert wireguard.CONFIG_PATH.parent.is_dir()
def test_does_not_create_config_files(sandbox):
# Config files are left for system-import (first start) or the first
# save_config — bootstrap must not pre-empt either.
bootstrap.bootstrap()
assert not nginx.CONFIG_FILE.exists()
assert not (dnsmasq.CONFIG_DIR / "config.json").exists()
assert not (network.CONFIG_DIR / "config.json").exists()
assert not (firewall.CONFIG_DIR / "config.json").exists()
assert not wireguard.CONFIG_PATH.exists()
def test_persists_nginx_migration(sandbox):
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
bootstrap.bootstrap()
on_disk = nginx.get_config()
assert on_disk["backends"]["webui"]["_migrated"] is True
raw = nginx.CONFIG_FILE.read_text()
assert '"_migrated": true' in raw or '"_migrated":True' in raw
def test_idempotent(sandbox):
nginx.save_config({"domains": {}})
bootstrap.bootstrap()
mtime = nginx.CONFIG_FILE.stat().st_mtime_ns
bootstrap.bootstrap()
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime
+51
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
compute_pending,
config_hash,
deep_diff,
load_json,
@@ -78,6 +79,56 @@ class TestDeepDiff:
assert not any(p.startswith("z.ranges[0].n") for p in paths)
class TestComputePending:
def test_hash_match_no_pending(self):
cfg = {"a": 1}
stamp_applied(cfg)
pending, diff = compute_pending(cfg)
assert pending is False
assert diff == []
def test_never_applied_pending_no_snapshot(self):
pending, diff = compute_pending({"a": 1})
assert pending is True
assert diff == []
def test_never_applied_pending_with_foreign_snapshot(self):
# A recorded snapshot that does not match the current hash is still
# used for the diff.
cfg = {"a": 2, _LAST_APPLIED_CONFIG_KEY: {"a": 1}}
pending, diff = compute_pending(cfg)
assert pending is True
assert diff == [{"path": "a", "action": "changed", "old": 1, "new": 2}]
def test_hash_mismatch_with_snapshot_diffs(self):
applied = {"zones": {"lan": {"services": ["http"]}}}
stamped = dict(applied)
stamp_applied(stamped)
drifted = {"zones": {"lan": {"services": ["http", "ssh"]}}}
drifted[_LAST_APPLIED_CONFIG_KEY] = applied
drifted[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY]
pending, diff = compute_pending(drifted)
assert pending is True
paths = {d["path"] for d in diff}
assert "zones.lan.services" in paths
def test_hash_mismatch_snapshot_not_dict(self):
cfg = {"a": 1, _LAST_APPLIED_CONFIG_KEY: "not-a-dict"}
pending, diff = compute_pending(cfg)
assert pending is True
assert diff == []
def test_meta_keys_excluded_from_diff(self):
cfg = {"a": 1}
stamp_applied(cfg)
cfg["a"] = 2 # drift
pending, diff = compute_pending(cfg)
assert pending is True
assert not any(
p.startswith(("_last_applied",)) for d in diff for p in [d["path"]]
)
class TestDashboardFallback:
def test_hash_subsystem_unchanged_generic(self):
# Guards that a pending status without a snapshot still yields a
+227 -91
View File
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, call, patch
import pytest
from daemon.handlers import common as daemoncommon
from daemon.handlers import firewall as daemonfirewall
from daemon.server import ConflictError, NotFoundError
from lib import firewall
@@ -377,25 +378,26 @@ _PENDING_LIVE_PUBLIC = {
class TestComputePendingChangesAbsentInterfaces:
"""Zones whose config lacks the 'interfaces' key are hands-off on apply,
so their interfaces diff must not be reported; other field drift is."""
"""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 (no hands-off zones)."""
def test_services_drift_reported_without_interfaces_key(self):
def test_services_and_interfaces_drift_reported_without_interfaces_key(self):
cfg = {"zones": {"public": {"services": ["http", "ssh"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
types = {c["type"] for c in result["pending"]}
assert "services" in types
assert "interfaces" not in types
# Absent key counts as an empty list: live eth0 is a pending removal.
assert "interfaces" in types
def test_no_spurious_interfaces_entry_for_absent_key_zone(self):
# Config in sync on everything except a missing interfaces key: the
# zone's live interfaces are intentionally left alone by apply.
def test_absent_key_zone_in_sync_live_reports_nothing(self):
# Config lacks the interfaces key and the live zone has no
# interfaces either — absent key equals the empty live set.
live = {**_PENDING_LIVE_PUBLIC, "interfaces": []}
cfg = {"zones": {"public": {"services": ["http"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
result = firewall._compute_pending_changes(cfg, {"public": live})
assert result["pending"] == []
assert result["needs_apply"] is False
@@ -457,6 +459,51 @@ class TestTargetDriftSemantics:
# ---------------------------------------------------------------------------
class TestValidateCoverage:
def test_all_covered(self):
fw = {"zones": {"public": {"interfaces": ["eth0"]}}}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == []
def test_uncovered_reported_sorted(self):
fw = {"zones": {"public": {"interfaces": ["eth1"]}}}
net = {"interfaces": {"eth5": {}, "eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0", "eth5"]
def test_unmanaged_exempts(self):
fw = {
"zones": {"public": {"interfaces": ["eth1"]}},
"unmanaged": ["eth0"],
}
net = {"interfaces": {"eth0": {}, "eth1": {}}}
assert firewall.validate_coverage(fw, net) == []
def test_lo_and_wg_exempt(self):
fw = {"zones": {}}
net = {"interfaces": {"lo": {}, "wg0": {}, "wg-full": {}}}
assert firewall.validate_coverage(fw, net) == []
def test_absent_key_counts_as_empty(self):
# A zone without an 'interfaces' key covers nothing.
fw = {"zones": {"public": {"services": ["http"]}}}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0"]
def test_empty_network_config(self):
assert firewall.validate_coverage({"zones": {}}, {"interfaces": {}}) == []
assert firewall.validate_coverage({"zones": {}}, {}) == []
def test_non_dict_zone_and_non_list_unmanaged_ignored(self):
fw = {"zones": {"public": "oops"}, "unmanaged": "eth0"}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0"]
def test_non_string_entries_ignored(self):
fw = {"zones": {"public": {"interfaces": [None, 7]}}, "unmanaged": [None]}
net = {"interfaces": {"eth0": {}}}
assert firewall.validate_coverage(fw, net) == ["eth0"]
class TestGetZoneInfo:
def test_parses_zone_info(self):
result = firewall._parse_zone_output(
@@ -656,7 +703,7 @@ class TestDaemonConfigApply:
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}},
@@ -702,8 +749,8 @@ class TestDaemonMgmtLockoutGuard:
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.set_zone_services(
@@ -723,8 +770,8 @@ class TestDaemonMgmtLockoutGuard:
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config"),
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.set_zone_services(
@@ -791,7 +838,7 @@ class TestDaemonMgmtLockoutGuard:
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}},
@@ -803,8 +850,10 @@ class TestDaemonMgmtLockoutGuard:
# ---------------------------------------------------------------------------
# Interface-coverage guard: apply must not leave a network-managed interface
# in no zone (clients lose connectivity/DHCP) unless forced.
# Coverage invariant: the config must cover every network-managed
# interface (or declare it unmanaged). Pure config check — the config is
# the source of truth for zone interfaces (absent key = empty), so there
# is no live-state comparison and no hands-off zones.
# ---------------------------------------------------------------------------
@@ -849,7 +898,7 @@ def _apply_with(
) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup", return_value=backup),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value=deepcopy(cfg),
@@ -860,21 +909,88 @@ def _apply_with(
return result, mock_run
class TestDaemonInterfaceCoverageGuard:
def test_absent_key_zone_keeps_live_interfaces_on_apply(self):
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
class TestDaemonCoverageInvariant:
def test_conflict_when_network_iface_uncovered(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch("daemon.handlers.firewall.run") as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup") as mock_backup,
pytest.raises(ConflictError) as exc,
):
daemonfirewall._config_apply()
msg = str(exc.value)
assert "eth0" in msg
assert "unmanaged" in msg
assert "force" in msg
mock_backup.assert_not_called()
# Pure config check: the guard performs no live-state reads at all.
mock_run.assert_not_called()
def test_unmanaged_exempts_iface(self):
cfg = {
"zones": {"public": {"interfaces": ["eth1"], "services": []}},
"unmanaged": ["eth0"],
}
result, mock_run = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
# The guard reads live zones once, up front.
assert mock_run.call_args_list[0].args[0] == [
cmds = [c.args[0] for c in mock_run.call_args_list]
# Live eth0 is removed, config eth1 added exactly once (permanent).
assert [
"firewall-cmd",
"--get-active-zones",
]
# Hands off: no interface mutation commands for the absent-key zone.
for c in mock_run.call_args_list:
for arg in c.args[0]:
assert not arg.startswith("--remove-interface=")
assert not arg.startswith("--add-interface=")
"--zone=public",
"--remove-interface=eth0",
"--permanent",
] in cmds
assert (
cmds.count(
["firewall-cmd", "--zone=public", "--add-interface=eth1", "--permanent"]
)
== 1
)
def test_absent_key_zone_counts_as_empty(self):
# No hands-off zones: a zone without an 'interfaces' key covers
# nothing, so a managed interface left out of every zone blocks.
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch("daemon.handlers.firewall.run"),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError) as exc,
):
daemonfirewall._config_apply()
assert "eth0" in str(exc.value)
def test_live_only_zone_does_not_count_as_covered(self):
# eth1 is held by 'guest' live but the config (the source of
# truth) does not cover it — apply is blocked regardless of live.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth1": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\nguest\n eth1\n"),
),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError),
):
daemonfirewall._config_apply()
def test_force_bypasses_coverage_guard(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
assert result["applied_zones"] == ["public"]
def test_guard_ignores_lo_and_wg(self):
# lo/wg* are never guarded even though the network config carries them.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"lo": {}, "wg0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
def test_explicit_empty_list_unassigns(self):
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
@@ -891,60 +1007,80 @@ class TestDaemonInterfaceCoverageGuard:
any(a.startswith("--add-interface=") for a in cmd) for cmd in cmds
)
def test_conflict_when_network_iface_goes_uncovered(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
class TestDaemonSaveCoverageValidation:
"""The coverage invariant is enforced at save time too (POST/PATCH
/firewall/config), so bad configs are rejected before they are written."""
def test_save_blocks_uncovered(self):
body = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError) as exc,
patch("daemon.handlers.firewall._save_config") as mock_save,
pytest.raises(ValueError) as exc,
):
daemonfirewall._config_apply()
daemonfirewall.save_config_handler(None, body)
assert "eth0" in str(exc.value)
assert "force" in str(exc.value)
assert "unmanaged" in str(exc.value)
mock_save.assert_not_called()
def test_force_bypasses_coverage_guard(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
assert result["applied_zones"] == ["public"]
def test_guard_ignores_lo_and_wg(self):
# lo/wg* are never guarded even though the network config carries them.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"lo": {}, "wg0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
def test_live_only_zone_interfaces_count_as_covered(self):
# eth1 is held by 'guest', which is live but absent from the config —
# apply never touches it, so eth1 counts as covered.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"eth1": {}}, "public\n eth0\nguest\n eth1\n")
assert result["applied_zones"] == ["public"]
def test_coverage_guard_conflict_writes_no_backup(self):
# Guard conflict must be side-effect free, like the lockout conflict.
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
def test_save_allows_unmanaged(self):
body = {
"zones": {"public": {"interfaces": ["eth1"], "services": []}},
"unmanaged": ["eth0"],
}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup") as mock_backup,
pytest.raises(ConflictError),
patch("daemon.handlers.firewall._save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
daemonfirewall._config_apply()
mock_backup.assert_not_called()
# Only the guard's live-zone read ran — no mutation commands at all.
assert [c.args[0] for c in mock_run.call_args_list] == [
["firewall-cmd", "--get-active-zones"]
]
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.save_config_handler(None, body)
assert result == {"config_saved": True}
mock_save.assert_called_once()
def test_save_rejects_non_list_unmanaged(self):
body = {"zones": {"public": {"interfaces": ["eth0"]}}, "unmanaged": "eth0"}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
pytest.raises(ValueError) as exc,
):
daemonfirewall.save_config_handler(None, body)
assert "unmanaged" in str(exc.value)
def test_patch_blocks_merge_that_uncovers(self):
current = {"zones": {"public": {"interfaces": ["eth0"], "services": []}}}
body = {"zones": {"public": {"interfaces": ["eth1"]}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch(
"daemon.handlers.firewall._get_config", return_value=deepcopy(current)
),
patch("daemon.handlers.firewall._save_config") as mock_save,
pytest.raises(ValueError) as exc,
):
daemonfirewall.patch_config(None, body)
assert "eth0" in str(exc.value)
mock_save.assert_not_called()
def test_patch_allows_merge_that_covers(self):
current = {"zones": {"public": {"interfaces": ["eth0"], "services": []}}}
body = {"zones": {"internal": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch(
"daemon.handlers.firewall._get_config", return_value=deepcopy(current)
),
patch("daemon.handlers.firewall._save_config") as mock_save,
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.patch_config(None, body)
assert result == {"config_saved": True}
saved = mock_save.call_args[0][0]
assert set(saved["zones"]) == {"public", "internal"}
# ---------------------------------------------------------------------------
@@ -957,8 +1093,8 @@ class TestDaemonCreateZone:
with (
patch("daemon.handlers.firewall.run", return_value=run_return) as mock_run,
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.create_zone(None, body)
@@ -1023,7 +1159,7 @@ class TestDaemonConfigApplyBackup:
"daemon.handlers.firewall._save_backup",
return_value="/tmp/rules.json",
) as mock_backup,
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch("daemon.handlers.firewall._get_config", return_value=deepcopy(cfg)),
patch("daemon.handlers.firewall._save_config"),
):
@@ -1082,7 +1218,7 @@ class TestDaemonConfigApplyStamp:
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.common.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value=deepcopy(_STAMP_TEST_CFG),
@@ -1114,8 +1250,8 @@ class TestDaemonMutatorBaselineStamp:
def test_set_zone_interfaces_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
with (
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_zone_interfaces(
@@ -1138,8 +1274,8 @@ class TestDaemonMutatorBaselineStamp:
daemonfirewall, "_parse_zone_output", return_value={"services": []}
),
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_zone_services(
@@ -1162,8 +1298,8 @@ class TestDaemonMutatorBaselineStamp:
):
with (
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True})
@@ -1182,8 +1318,8 @@ class TestDaemonMutatorBaselineStamp:
would manufacture spurious service diffs on the next poll."""
with (
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
patch.object(daemoncommon, "bus") as mock_bus,
patch("daemon.handlers.common.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False})
@@ -1208,10 +1344,10 @@ class TestDaemonGetConfigEndpoint:
"daemon.handlers.firewall._config_apply",
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
)
@patch("daemon.handlers.firewall.bus")
@patch("daemon.handlers.common.bus")
def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
with patch("daemon.handlers.firewall.refresh_state"):
with patch("daemon.handlers.common.refresh_state"):
daemonfirewall.config_apply(None, None)
mock_apply.assert_called_once_with(force=False)
mock_apply.reset_mock()
+3 -2
View File
@@ -29,10 +29,11 @@ class TestGetConfig:
assert isinstance(cfg, dict)
assert "interfaces" in cfg
def test_creates_config_file(self, tmp_network):
def test_missing_file_returns_default_without_writing(self, tmp_network):
# Pure read: get_config never materializes the file.
cfg = _net.get_config()
assert _net.CONFIG_FILE.exists()
assert cfg["interfaces"] == {}
assert not _net.CONFIG_FILE.exists()
class TestSaveConfig:
+12 -10
View File
@@ -279,18 +279,18 @@ class TestStateParserDedup:
"""Verify lib/state.py uses lib.network.parse_networkctl_status()."""
def test_state_uses_network_parser(self):
"""The networkd collector in state.py should import from lib.network."""
import lib.state as _state
"""The networkd collector should import from lib.network."""
import daemon.collectors.networkd as _collector
source = Path(_state.__file__).read_text()
assert "from lib.network import parse_networkctl_status" in source
source = Path(_collector.__file__).read_text()
assert "from lib.network import" in source
assert "parse_networkctl_status" in source
def test_networkd_collector_returns_correct_format(self):
"""_collect_networkd should return interfaces dict + timestamp."""
import lib.state as _state
import daemon.collectors.networkd as _collector
with patch("lib.state.run") as mock_run:
with patch("daemon.collectors.networkd.run") as mock_run:
mock_run.return_value = json.dumps(
{
"Interfaces": [
@@ -320,7 +320,7 @@ class TestStateParserDedup:
]
}
)
result = _state._collect_networkd()
result = _collector._collect_networkd()
assert "interfaces" in result
assert "timestamp" in result
@@ -329,10 +329,12 @@ class TestStateParserDedup:
def test_networkd_collector_handles_failure(self):
"""_collect_networkd returns empty interfaces on error."""
import lib.state as _state
import daemon.collectors.networkd as _collector
with patch("lib.state.run", side_effect=RuntimeError("no networkctl")):
result = _state._collect_networkd()
with patch(
"daemon.collectors.networkd.run", side_effect=RuntimeError("no networkctl")
):
result = _collector._collect_networkd()
assert result["interfaces"] == {}
assert "timestamp" in result
+17 -3
View File
@@ -70,14 +70,28 @@ class TestGetConfig:
# No churn: reading a current-format config leaves the file alone.
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
def test_read_saves_when_migration_applied(self, temp_data_dir):
"""get_config() persists the file when migration actually changes it."""
def test_read_migrates_in_memory_without_writing(self, temp_data_dir):
"""get_config() is pure: migration is applied in memory, file untouched."""
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
cfg = nginx.get_config()
# Migration added the builtin webui backend.
# Migration added the builtin webui backend (in memory only).
assert cfg["backends"]["webui"]["_migrated"] is True
assert nginx.CONFIG_FILE.stat().st_mtime_ns == mtime_before
def test_migrate_config_file_persists_legacy(self, temp_data_dir):
"""migrate_config_file() rewrites the file when migration changes it."""
nginx.save_config({"domains": {"app.example.com": {"backend": "myapp"}}})
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
assert nginx.migrate_config_file() is True
assert nginx.CONFIG_FILE.stat().st_mtime_ns != mtime_before
# Idempotent: a second run is a no-op.
assert nginx.migrate_config_file() is False
def test_migrate_config_file_noop_when_missing(self, temp_data_dir):
assert not nginx.CONFIG_FILE.exists()
assert nginx.migrate_config_file() is False
assert not nginx.CONFIG_FILE.exists()
class TestSaveConfig:
+25 -15
View File
@@ -9,7 +9,13 @@ these tests catch drift between the schemas and the collectors.
import json
from unittest.mock import Mock, patch
import lib.state
import daemon.collectors.acme
import daemon.collectors.dnsmasq
import daemon.collectors.firewall
import daemon.collectors.networkd
import daemon.collectors.nginx
import daemon.collectors.system
import daemon.collectors.wireguard
from lib import schema
@@ -20,9 +26,9 @@ def _missing(required_keys: frozenset, data: dict) -> set[str]:
class TestCollectorShapesMatchSchema:
def test_firewall_state(self):
with (
patch.object(lib.state, "run") as mock_run,
patch.object(daemon.collectors.firewall, "run") as mock_run,
patch.object(
lib.state,
daemon.collectors.firewall,
"_network_get_config",
return_value={
"interfaces": {
@@ -63,7 +69,7 @@ class TestCollectorShapesMatchSchema:
return ""
mock_run.side_effect = run_side
result = lib.state._collect_firewall()
result = daemon.collectors.firewall._collect_firewall()
assert not _missing(schema.FirewallState.__required_keys__, result)
for iface in result["interfaces"]:
@@ -74,38 +80,40 @@ class TestCollectorShapesMatchSchema:
assert result["uncovered_interfaces"] == ["eth1"]
def test_dnsmasq_state(self):
with patch.object(lib.state, "run_proc") as mock_proc:
with patch.object(daemon.collectors.dnsmasq, "run_proc") as mock_proc:
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = lib.state._collect_dnsmasq()
result = daemon.collectors.dnsmasq._collect_dnsmasq()
assert not _missing(schema.DnsmasqState.__required_keys__, result)
for k in schema.DnsmasqStatus.__required_keys__:
assert k in result["status"], f"DnsmasqStatus missing {k}"
def test_nginx_state(self):
result = lib.state._collect_nginx()
result = daemon.collectors.nginx._collect_nginx()
assert not _missing(schema.NginxState.__required_keys__, result)
assert "pending_changes" in result["status"]
def test_acme_state(self):
with (
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch("lib.acme.list_certs", return_value=[]),
patch.object(
lib.state,
daemon.collectors.acme,
"_parse_account_conf",
return_value={"registered": False, "email": "", "ca": ""},
),
):
result = lib.state._collect_acme()
result = daemon.collectors.acme._collect_acme()
assert not _missing(schema.AcmeState.__required_keys__, result)
assert result["status"]["error"] is None
def test_wireguard_state(self):
with patch.object(lib.state, "run_proc") as mock_proc:
with patch.object(daemon.collectors.wireguard, "run_proc") as mock_proc:
mock_proc.return_value = Mock(stdout="", returncode=1)
result = lib.state._collect_wireguard()
result = daemon.collectors.wireguard._collect_wireguard()
assert not _missing(schema.WgState.__required_keys__, result)
for k in schema.WgStatus.__required_keys__:
@@ -135,8 +143,10 @@ class TestCollectorShapesMatchSchema:
}
]
}
with patch.object(lib.state, "run", return_value=json.dumps(networkctl)):
result = lib.state._collect_networkd()
with patch.object(
daemon.collectors.networkd, "run", return_value=json.dumps(networkctl)
):
result = daemon.collectors.networkd._collect_networkd()
assert not _missing(schema.NetworkdState.__required_keys__, result)
assert "eth0" in result["interfaces"]
@@ -148,7 +158,7 @@ class TestCollectorShapesMatchSchema:
def test_system_state(self):
"""Reads /proc and /sys directly — no mocking needed on Linux."""
result = lib.state._collect_system()
result = daemon.collectors.system._collect_system()
assert not _missing(schema.SystemState.__required_keys__, result)
for k in schema.CpuLoad.__required_keys__:
assert k in result["load"], f"CpuLoad missing {k}"
+31 -19
View File
@@ -3,7 +3,9 @@
import json
from unittest.mock import patch
import lib
import daemon.collectors.acme
import daemon.collectors.dnsmasq
import daemon.collectors.firewall
from lib.state import State, state
@@ -51,9 +53,9 @@ class TestState:
class TestCollectAll:
@patch("lib.state.run")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_returns_dict(self, mock_run):
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -88,10 +90,10 @@ class TestCollectAll:
assert "interfaces" in result
assert "timestamp" in result
@patch("lib.state.run")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_vlan_ips_populated(self, mock_run):
"""VLAN interfaces with @suffix in ip addr output get their IPs collected."""
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -144,10 +146,10 @@ class TestCollectAll:
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
assert "10.0.0.1/24" in vlan_iface["ips"]
@patch("lib.state.get_service_descriptions")
@patch("lib.state.run")
@patch("daemon.collectors.firewall.get_service_descriptions")
@patch("daemon.collectors.firewall.run")
def test_collect_firewall_includes_service_descriptions(self, mock_run, mock_desc):
from lib.state import _collect_firewall
from daemon.collectors.firewall import _collect_firewall
def run_side(args, **kwargs):
if "--get-active-zones" in args:
@@ -168,11 +170,11 @@ class TestCollectAll:
mock_desc.assert_called_once_with()
assert result["service_descriptions"] == descs
@patch("lib.state.run_proc")
@patch("daemon.collectors.dnsmasq.run_proc")
def test_collect_dnsmasq_returns_dict(self, mock_proc):
from unittest.mock import Mock
from lib.state import _collect_dnsmasq
from daemon.collectors.dnsmasq import _collect_dnsmasq
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = _collect_dnsmasq()
@@ -181,12 +183,12 @@ class TestCollectAll:
assert "config" in result
assert "leases" in result
@patch("lib.state.run_proc")
@patch("daemon.collectors.dnsmasq.run_proc")
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
from unittest.mock import Mock
from daemon.collectors.dnsmasq import _collect_dnsmasq
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
from lib.state import _collect_dnsmasq
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
applied = {
@@ -226,7 +228,9 @@ class TestCollectAll:
_APPLY_HASH_KEY: "stale-hash",
}
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
monkeypatch.setattr(
"lib.dnsmasq.CONFIG_PATH", tmp_path / "config" / "dnsmasq" / "config.json"
)
# service check -> active; lease file read -> no lines
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
@@ -257,15 +261,19 @@ class TestAcmeCollectNonFatal:
"""A broken acme.sh must not clear the acme subsystem (dashboard guard)."""
def test_list_failure_yields_empty_certs_and_error(self):
from lib.state import _collect_acme
from daemon.collectors.acme import _collect_acme
with (
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch(
"lib.acme.list_certs",
side_effect=RuntimeError("acme.sh failed with exit code 2"),
),
patch.object(lib.state, "_parse_account_conf", return_value=_ACCOUNT),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
@@ -275,12 +283,16 @@ class TestAcmeCollectNonFatal:
assert "exit code 2" in result["status"]["error"]
def test_success_reports_no_error(self):
from lib.state import _collect_acme
from daemon.collectors.acme import _collect_acme
with (
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
patch.object(
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
),
patch("lib.acme.list_certs", return_value=[]),
patch.object(lib.state, "_parse_account_conf", return_value=_ACCOUNT),
patch.object(
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
),
):
result = _collect_acme()
+38 -2
View File
@@ -336,7 +336,7 @@ class TestGenerateWgShowParser:
" listening port: 51820\n"
" peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
)
result = wireguard._parse_wg_show_output(output)
result = wireguard.parse_wg_show_output(output)
assert result["up"] is True
assert result["interface"]["public_key"] == "IFACE-PUB"
assert result["interface"]["listen_port"] == 51820
@@ -346,10 +346,46 @@ class TestGenerateWgShowParser:
assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"]
def test_empty_output(self):
result = wireguard._parse_wg_show_output("")
result = wireguard.parse_wg_show_output("")
assert result["up"] is False
assert result["peers"] == []
def test_parses_fwmark(self):
output = (
"interface: wg0\n"
" public key: IFACE-PUB\n"
" listening port: 51820\n"
" fwmark: 0x0\n"
)
result = wireguard.parse_wg_show_output(output)
assert result["up"] is True
assert result["interface"]["fwmark"] == "0x0"
def test_peer_transfer_and_keepalive(self):
output = (
"interface: wg0\n"
" public key: IFACE-PUB\n"
" listening port: 51820\n"
" peer: PUBKEY1\n"
" endpoint: 203.0.113.1:51820\n"
" allowed ips: 10.0.0.0/24, 10.0.1.0/24\n"
" latest handshake: 2 minutes ago\n"
" transfer: 1.23 GiB received, 4.56 GiB sent\n"
" persistent-keepalive: 25\n"
)
result = wireguard.parse_wg_show_output(output)
peer = result["peers"][0]
assert peer["allowed_ips"] == ["10.0.0.0/24", "10.0.1.0/24"]
assert peer["latest_handshake"] == "2 minutes ago"
assert peer["transfer_received"] == "1.23 GiB received"
assert peer["transfer_sent"] == "4.56 GiB sent"
assert peer["persistent_keepalive"] == 25
def test_bad_keepalive_value(self):
output = "interface: wg0\n peer: PUBKEY1\n persistent-keepalive: bogus\n"
result = wireguard.parse_wg_show_output(output)
assert result["peers"][0]["persistent_keepalive"] is None
class TestAccessClasses:
def test_default_config_has_access_classes(self):