fix: ACME ownership self-heal + daily timer, apply-all force, firewall baseline re-stamp
acme:
- acme.sh chmods its tree to owner-only (700/600) every run, which
broke the two-user model: a tree left owner-only by one user made
every acme.sh call of the other exit 2
- normalize_acme_home() reopens group access (sudo chmod g+rwX,
files only — setgid dirs trip RestrictSUIDSGID); _run_acme_preflight
is the choke point before every daemon acme.sh call + startup
- acme service now runs as the daemon user; --log persists the raw CA
transcript; SYS_LOG=6 journals manual issue/renew runs
- timer daily-only: two runs/day landed inside ZeroSSL's 24h
validation backoff (Retry-After: 86400) — a permanent renewal lockout
- _collect_acme no longer raises on cert-list failure; reports
status.error (AcmeState.status) so the certs page can surface it
firewall: re-stamp the applied baseline on live zone mutations
(interfaces/services/rich-rules/masquerade/forward-ports) so cancel-all
reverts to post-mutation state, not a stale install-era snapshot;
set_masquerade syncs the declarative config for existing zones;
add_forward_port records toaddr only with toport
status: apply-all accepts {"force": true} (forwarded to the firewall
apply only); ApplyConfirm force checkbox; applyResultToasts() — the
errors map wins over the 200; ActionButton checks errors before the
success toast; dashboard uses ApplyConfirm
system_import: drift re-imports carry the existing apply-meta; first
import stamps the adopted content as applied (it is the running state)
— no phantom pending changes
nginx: get_config only re-saves when migration actually changed the
config (no more owner/mtime churn on every read)
install: repair mis-owned top-level system dirs (tmpfiles
unsafe-path-transition), warn with a full-repair command for deeper
mis-ownership
daemon/server: loop.get_exception_handler() (aiohttp API fix)
tests: 888 pytest + 24 node passing; ruff clean
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
|
||||
*/
|
||||
|
||||
import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
|
||||
import { buildRows, isPending, SUBSYSTEM_LIST, applyResultToasts } from '../webui/static/hoover/components/applyconfirm.js';
|
||||
|
||||
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
|
||||
|
||||
@@ -235,5 +235,38 @@ test('buildRows row VNodes have correct tag', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// === applyResultToasts ===
|
||||
// apply-all returns 200 with { applied, errors } even when subsystems
|
||||
// failed — resp.ok alone is not a success signal; errors must win.
|
||||
test('applyResultToasts: errors suppress the success toast', () => {
|
||||
const t = applyResultToasts({ applied: ['Network'], errors: { Firewall: 'refused' } }, 'All changes applied');
|
||||
assertEq(t.success, null, 'no success toast when errors exist');
|
||||
assertIncludes(t.error, 'Firewall — refused');
|
||||
});
|
||||
|
||||
test('applyResultToasts: success toast when applied and no errors', () => {
|
||||
const t = applyResultToasts({ applied: ['Firewall', 'Nginx'], errors: {} }, 'All changes applied');
|
||||
assertEq(t.error, null);
|
||||
assertEq(t.success, 'All changes applied');
|
||||
});
|
||||
|
||||
test('applyResultToasts: no toast when nothing applied and no errors', () => {
|
||||
const t = applyResultToasts({ applied: [], errors: {} }, 'All changes applied');
|
||||
assertEq(t.error, null);
|
||||
assertEq(t.success, null);
|
||||
});
|
||||
|
||||
test('applyResultToasts: multiple errors are joined', () => {
|
||||
const t = applyResultToasts({ applied: [], errors: { Firewall: 'a', Nginx: 'b' } }, 'ok');
|
||||
assertIncludes(t.error, 'Firewall — a');
|
||||
assertIncludes(t.error, 'Nginx — b');
|
||||
});
|
||||
|
||||
test('applyResultToasts: null payload is safe', () => {
|
||||
const t = applyResultToasts(null, 'ok');
|
||||
assertEq(t.error, null);
|
||||
assertEq(t.success, null);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -1099,6 +1099,97 @@ class TestDaemonConfigApplyStamp:
|
||||
assert saved[_LAST_APPLIED_CONFIG_KEY] == _STAMP_TEST_CFG
|
||||
|
||||
|
||||
class TestDaemonMutatorBaselineStamp:
|
||||
"""Per-zone mutations apply to live firewalld immediately and must
|
||||
re-stamp the applied baseline, so cancel-all reverts to the post-mutation
|
||||
state instead of an older snapshot (regression: stale install-era
|
||||
snapshot resurrected a phantom 'remove interface' pending change).
|
||||
"""
|
||||
|
||||
ZONES_OUT = "public\ninternal"
|
||||
|
||||
@patch("daemon.handlers.firewall._reload")
|
||||
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
|
||||
@patch("daemon.handlers.firewall.run", return_value=ZONES_OUT)
|
||||
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"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
daemonfirewall.set_zone_interfaces(
|
||||
None, {"zone": "internal", "interfaces": ["eth1"]}
|
||||
)
|
||||
saved = mock_save.call_args[0][0]
|
||||
assert saved["zones"]["internal"]["interfaces"] == ["eth1"]
|
||||
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
|
||||
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
|
||||
assert saved[_LAST_APPLIED_CONFIG_KEY]["zones"]["internal"]["interfaces"] == [
|
||||
"eth1"
|
||||
]
|
||||
|
||||
@patch("daemon.handlers.firewall._reload")
|
||||
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
|
||||
@patch("daemon.handlers.firewall.run", return_value=ZONES_OUT)
|
||||
def test_set_zone_services_stamps_baseline(self, mock_run, mock_cfg, mock_reload):
|
||||
with (
|
||||
patch.object(
|
||||
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"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
daemonfirewall.set_zone_services(
|
||||
None, {"zone": "internal", "services": ["ssh"]}
|
||||
)
|
||||
saved = mock_save.call_args[0][0]
|
||||
assert saved["zones"]["internal"]["services"] == ["ssh"]
|
||||
assert saved[_LAST_APPLIED_CONFIG_KEY]["zones"]["internal"]["services"] == [
|
||||
"ssh"
|
||||
]
|
||||
|
||||
@patch("daemon.handlers.firewall._reload")
|
||||
@patch(
|
||||
"daemon.handlers.firewall._get_config",
|
||||
return_value={"zones": {"internal": {}}},
|
||||
)
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_set_masquerade_syncs_config_and_stamps(
|
||||
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"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
daemonfirewall.set_masquerade(None, {"zone": "internal", "enable": True})
|
||||
saved = mock_save.call_args[0][0]
|
||||
assert saved["zones"]["internal"]["masquerade"] is True
|
||||
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
|
||||
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
|
||||
|
||||
@patch("daemon.handlers.firewall._reload")
|
||||
@patch("daemon.handlers.firewall._get_config", return_value={"zones": {}})
|
||||
@patch("daemon.handlers.firewall.run")
|
||||
def test_set_masquerade_no_config_entry_skips_write(
|
||||
self, mock_run, mock_cfg, mock_reload
|
||||
):
|
||||
"""A zone absent from the config must not gain a bare entry — that
|
||||
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"),
|
||||
):
|
||||
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
|
||||
daemonfirewall.set_masquerade(None, {"zone": "public", "enable": False})
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
class TestDaemonGetConfigEndpoint:
|
||||
def test_strips_apply_meta(self):
|
||||
with patch.object(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -1303,3 +1304,74 @@ class TestGetRenewStatus:
|
||||
assert status["domain"] == "example.com"
|
||||
assert status["status"] == "completed"
|
||||
assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"]
|
||||
|
||||
|
||||
class TestNormalizeAcmeHome:
|
||||
def test_normalize_invokes_sudo_chmod_on_files(self, tmp_path):
|
||||
f1 = tmp_path / "account.conf"
|
||||
f1.write_text("x")
|
||||
(tmp_path / "sub").mkdir()
|
||||
f2 = tmp_path / "sub" / "dom.key"
|
||||
f2.write_text("x")
|
||||
with (
|
||||
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
|
||||
patch(
|
||||
"lib.common.run_proc",
|
||||
return_value=MagicMock(returncode=0, stderr=""),
|
||||
) as mock_proc,
|
||||
):
|
||||
acme_mod.normalize_acme_home()
|
||||
args = mock_proc.call_args.args[0]
|
||||
assert args[:2] == ["chmod", "g+rwX"]
|
||||
assert set(args[2:]) == {str(f1), str(f2)}
|
||||
mock_proc.assert_called_once()
|
||||
|
||||
def test_normalize_empty_tree_skips_sudo(self, tmp_path):
|
||||
with (
|
||||
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
|
||||
patch("lib.common.run_proc") as mock_proc,
|
||||
):
|
||||
acme_mod.normalize_acme_home()
|
||||
mock_proc.assert_not_called()
|
||||
|
||||
def test_normalize_failure_does_not_raise(self, tmp_path):
|
||||
(tmp_path / "a.conf").write_text("x")
|
||||
with (
|
||||
patch("daemon.handlers.acme._ACME_HOME", tmp_path),
|
||||
patch(
|
||||
"lib.common.run_proc",
|
||||
return_value=MagicMock(returncode=1, stderr="denied"),
|
||||
),
|
||||
patch.object(acme_mod, "logger"),
|
||||
):
|
||||
acme_mod.normalize_acme_home()
|
||||
|
||||
def test_preflight_normalizes_before_run(self):
|
||||
calls = []
|
||||
with (
|
||||
patch.object(
|
||||
acme_mod,
|
||||
"normalize_acme_home",
|
||||
side_effect=lambda: calls.append("normalize"),
|
||||
),
|
||||
patch.object(
|
||||
acme_mod,
|
||||
"_run_acme",
|
||||
side_effect=lambda args: calls.append("run:" + " ".join(args)) or "ok",
|
||||
),
|
||||
):
|
||||
out = acme_mod._run_acme_preflight(["--list", "--listraw"])
|
||||
assert calls == ["normalize", "run:--list --listraw"]
|
||||
assert out == "ok"
|
||||
|
||||
|
||||
class TestPreflightWiring:
|
||||
def test_issue_uses_preflight(self):
|
||||
source = inspect.getsource(acme_mod._run_issue)
|
||||
assert "_run_acme_preflight" in source
|
||||
assert "normalize_acme_home" in source
|
||||
|
||||
def test_renew_uses_preflight(self):
|
||||
source = inspect.getsource(acme_mod._run_renew)
|
||||
assert "_run_acme_preflight" in source
|
||||
assert "normalize_acme_home" in source
|
||||
|
||||
@@ -15,18 +15,36 @@ from daemon.handlers.network import (
|
||||
save_interface,
|
||||
set_sysctl,
|
||||
)
|
||||
from lib import dnsmasq as _dm
|
||||
from lib import firewall as _fw
|
||||
from lib import network as _net
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_network(tmp_path):
|
||||
orig_config = _net.CONFIG_FILE
|
||||
orig_data = _net.DATA_DIR
|
||||
# Handler endpoints emit "networkd" sync events; the subscribers
|
||||
# (lib.sync.NetworkToAllSync) read/write the firewall and dnsmasq
|
||||
# configs, and apply_all re-stamps the dnsmasq config. Point all of
|
||||
# those paths at tmp so tests never touch the real config files.
|
||||
orig_net = (_net.CONFIG_FILE, _net.DATA_DIR)
|
||||
orig_dm = (_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR)
|
||||
orig_fw = (_fw.CONFIG_DIR, _fw.CONFIG_FILE)
|
||||
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
|
||||
_net.DATA_DIR = tmp_path / "data" / "networkd"
|
||||
_dm.CONFIG_DIR = tmp_path / "config" / "dnsmasq"
|
||||
_dm.DATA_DIR = tmp_path / "data" / "dnsmasq"
|
||||
_dm.CONFIG_PATH = _dm.CONFIG_DIR / "config.json"
|
||||
_dm.FRAGMENTS_DIR = _dm.DATA_DIR / "fragments"
|
||||
_dm.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_dm.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_dm.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_fw.CONFIG_DIR = tmp_path / "config" / "firewall"
|
||||
_fw.CONFIG_FILE = _fw.CONFIG_DIR / "config.json"
|
||||
_fw.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
yield tmp_path
|
||||
_net.CONFIG_FILE = orig_config
|
||||
_net.DATA_DIR = orig_data
|
||||
_net.CONFIG_FILE, _net.DATA_DIR = orig_net
|
||||
_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR = orig_dm
|
||||
_fw.CONFIG_DIR, _fw.CONFIG_FILE = orig_fw
|
||||
|
||||
|
||||
# =================================================================
|
||||
@@ -363,7 +381,7 @@ class TestInferEndpoints:
|
||||
|
||||
|
||||
class TestSetSysctl:
|
||||
def test_set_sysctl_success(self):
|
||||
def test_set_sysctl_success(self, tmp_network):
|
||||
with (
|
||||
patch("daemon.handlers.network.run") as mock_run,
|
||||
patch.object(Path, "read_text", return_value="1"),
|
||||
|
||||
@@ -55,6 +55,30 @@ class TestGetConfig:
|
||||
assert "ssl" in cfg
|
||||
assert cfg["domains"] == {}
|
||||
|
||||
def test_read_does_not_rewrite_unchanged_file(self, temp_data_dir):
|
||||
"""get_config() must not re-save a file that needs no migration."""
|
||||
nginx.save_config(
|
||||
{
|
||||
"backends": {"webui": {"_migrated": True, "paths": {}}},
|
||||
"domains": {"app.example.com": {"backend": "webui"}},
|
||||
"ssl": {"protocols": "TLSv1.3"},
|
||||
}
|
||||
)
|
||||
mtime_before = nginx.CONFIG_FILE.stat().st_mtime_ns
|
||||
cfg = nginx.get_config()
|
||||
assert cfg["domains"] == {"app.example.com": {"backend": "webui"}}
|
||||
# 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."""
|
||||
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.
|
||||
assert cfg["backends"]["webui"]["_migrated"] is True
|
||||
assert nginx.CONFIG_FILE.stat().st_mtime_ns != mtime_before
|
||||
|
||||
|
||||
class TestSaveConfig:
|
||||
def test_saves_and_reloads(self, temp_data_dir):
|
||||
|
||||
@@ -100,6 +100,7 @@ class TestCollectorShapesMatchSchema:
|
||||
result = lib.state._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:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import lib
|
||||
from lib.state import State, state
|
||||
|
||||
|
||||
@@ -249,6 +250,43 @@ class TestCollectFailure:
|
||||
assert s.is_populated() is False
|
||||
|
||||
|
||||
_ACCOUNT = {"registered": False, "email": "", "ca": ""}
|
||||
|
||||
|
||||
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
|
||||
|
||||
with (
|
||||
patch.object(lib.state, "_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),
|
||||
):
|
||||
result = _collect_acme()
|
||||
|
||||
assert result["certs"] == []
|
||||
assert result["email"] == "a@b.c"
|
||||
assert result["status"]["error"] is not None
|
||||
assert "exit code 2" in result["status"]["error"]
|
||||
|
||||
def test_success_reports_no_error(self):
|
||||
from lib.state import _collect_acme
|
||||
|
||||
with (
|
||||
patch.object(lib.state, "_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),
|
||||
):
|
||||
result = _collect_acme()
|
||||
|
||||
assert result["status"] == {"error": None}
|
||||
|
||||
|
||||
class TestStateVersions:
|
||||
def test_version_starts_at_zero(self):
|
||||
s = State()
|
||||
|
||||
@@ -377,6 +377,45 @@ class TestStatusApplyAll:
|
||||
assert "Firewall" in result["errors"]
|
||||
mock_nginx.assert_called_once()
|
||||
|
||||
def test_force_body_forwarded_to_firewall_only(self):
|
||||
mock_fw = MagicMock()
|
||||
mock_nginx = MagicMock()
|
||||
|
||||
pending_data = {**self._fake_pending_all}
|
||||
pending_data["firewall"]["needs_apply"] = True
|
||||
pending_data["firewall"]["change_count"] = 1
|
||||
pending_data["nginx"]["pending_changes"] = True
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch.dict(
|
||||
"daemon.handlers.status.SYS_APPLY",
|
||||
{
|
||||
"firewall": mock_fw,
|
||||
"nginx": mock_nginx,
|
||||
},
|
||||
),
|
||||
):
|
||||
status.status_apply_all(None, {"force": True})
|
||||
mock_fw.assert_called_once_with(None, {"force": True})
|
||||
mock_nginx.assert_called_once_with(None, None)
|
||||
|
||||
def test_no_body_passed_without_force(self):
|
||||
mock_fw = MagicMock()
|
||||
|
||||
pending_data = {**self._fake_pending_all}
|
||||
pending_data["firewall"]["needs_apply"] = True
|
||||
pending_data["firewall"]["change_count"] = 1
|
||||
|
||||
with (
|
||||
patch("daemon.handlers.status.status_pending", return_value=pending_data),
|
||||
patch("daemon.handlers.status.refresh_state"),
|
||||
patch.dict("daemon.handlers.status.SYS_APPLY", {"firewall": mock_fw}),
|
||||
):
|
||||
status.status_apply_all(None, None)
|
||||
mock_fw.assert_called_once_with(None, None)
|
||||
|
||||
|
||||
class TestSysOrder:
|
||||
"""Verify SYS_ORDER and SYS_LABELS constants."""
|
||||
|
||||
+10
-2
@@ -226,10 +226,14 @@ class TestGetAffected:
|
||||
|
||||
|
||||
class TestDnsToFirewallSync:
|
||||
@patch("lib.sync.get_interface_ip", return_value="10.0.0.1")
|
||||
@patch("lib.dnsmasq.save_config")
|
||||
@patch("lib.firewall.save_config")
|
||||
@patch("lib.firewall.get_config")
|
||||
@patch("lib.dnsmasq.get_config")
|
||||
def test_adds_dhcp_dns(self, mock_dm_get, mock_fw_get, mock_fw_save):
|
||||
def test_adds_dhcp_dns(
|
||||
self, mock_dm_get, mock_fw_get, mock_fw_save, mock_dm_save, mock_ip
|
||||
):
|
||||
mock_dm_get.return_value = {
|
||||
"dhcp": {
|
||||
"ranges": [
|
||||
@@ -288,10 +292,14 @@ class TestDnsToFirewallSync:
|
||||
assert "dhcp" not in saved_cfg["zones"]["internal"]["services"]
|
||||
assert "dns" not in saved_cfg["zones"]["internal"]["services"]
|
||||
|
||||
@patch("lib.sync.get_interface_ip", return_value="10.0.0.1")
|
||||
@patch("lib.dnsmasq.save_config")
|
||||
@patch("lib.firewall.save_config")
|
||||
@patch("lib.firewall.get_config")
|
||||
@patch("lib.dnsmasq.get_config")
|
||||
def test_idempotent(self, mock_dm_get, mock_fw_get, mock_fw_save):
|
||||
def test_idempotent(
|
||||
self, mock_dm_get, mock_fw_get, mock_fw_save, mock_dm_save, mock_ip
|
||||
):
|
||||
mock_dm_get.return_value = {
|
||||
"dhcp": {
|
||||
"ranges": [
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from lib import system_import
|
||||
from lib.common import save_json
|
||||
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY, config_hash, save_json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -137,6 +137,39 @@ class TestImportDnsmasq:
|
||||
):
|
||||
assert not system_import.import_dnsmasq()
|
||||
|
||||
def test_preserves_apply_meta_on_drift(self, temp_project, tmp_path):
|
||||
# Existing config differs from the live conf and carries apply
|
||||
# bookkeeping — the rewrite must keep the baseline so pending
|
||||
# detection and cancel-all survive daemon restarts.
|
||||
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
|
||||
self._write_conf(tmp_path, conf)
|
||||
cfg_path = tmp_path / "config" / "dnsmasq"
|
||||
cfg_path.mkdir(parents=True, exist_ok=True)
|
||||
baseline = {"dns": {"upstreams": ["1.1.1.1"]}}
|
||||
save_json(
|
||||
cfg_path / "config.json",
|
||||
{
|
||||
**baseline,
|
||||
_APPLY_HASH_KEY: "old-hash",
|
||||
_LAST_APPLIED_CONFIG_KEY: baseline,
|
||||
},
|
||||
)
|
||||
assert system_import.import_dnsmasq()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg[_APPLY_HASH_KEY] == "old-hash"
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY] == baseline
|
||||
assert cfg["dns"]["upstreams"] == ["8.8.8.8"]
|
||||
|
||||
def test_stamps_applied_on_first_import(self, temp_project, tmp_path):
|
||||
# No config file yet: the imported content is the running state,
|
||||
# so it must be stamped as applied (no phantom pending changes).
|
||||
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_dnsmasq()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY]["dns"]["upstreams"] == ["8.8.8.8"]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# WireGuard
|
||||
@@ -232,6 +265,44 @@ class TestImportWireguard:
|
||||
assert system_import.import_wireguard()
|
||||
assert not system_import.import_wireguard()
|
||||
|
||||
def test_preserves_apply_meta_on_drift(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Interface]\n"
|
||||
" PrivateKey = abc123\n"
|
||||
" Address = 10.137.0.1/24\n"
|
||||
" ListenPort = 51820\n"
|
||||
)
|
||||
self._write_conf(tmp_path, conf)
|
||||
cfg_path = tmp_path / "config" / "wireguard"
|
||||
cfg_path.mkdir(parents=True, exist_ok=True)
|
||||
baseline = {"interface": {"listen_port": 51821}, "peers": {}}
|
||||
save_json(
|
||||
cfg_path / "config.json",
|
||||
{
|
||||
**baseline,
|
||||
_APPLY_HASH_KEY: "old-hash",
|
||||
_LAST_APPLIED_CONFIG_KEY: baseline,
|
||||
},
|
||||
)
|
||||
assert system_import.import_wireguard()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg[_APPLY_HASH_KEY] == "old-hash"
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY] == baseline
|
||||
assert cfg["interface"]["listen_port"] == 51820
|
||||
|
||||
def test_stamps_applied_on_first_import(self, temp_project, tmp_path):
|
||||
conf = (
|
||||
"[Interface]\n"
|
||||
" PrivateKey = abc123\n"
|
||||
" Address = 10.137.0.1/24\n"
|
||||
" ListenPort = 51820\n"
|
||||
)
|
||||
self._write_conf(tmp_path, conf)
|
||||
assert system_import.import_wireguard()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY]["interface"]["private_key"] == "abc123"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Networkd
|
||||
@@ -593,6 +664,18 @@ class TestImportFirewall:
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert "dmz" not in cfg["zones"]
|
||||
|
||||
def test_import_stamps_applied(self, temp_project, tmp_path):
|
||||
# Fresh import adopts the live firewalld state, which is by
|
||||
# definition the applied state — the file must carry a baseline.
|
||||
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
|
||||
assert system_import.import_firewall()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
|
||||
assert cfg[_LAST_APPLIED_CONFIG_KEY]["zones"]["public"]["interfaces"] == [
|
||||
"eth0",
|
||||
"eth1",
|
||||
]
|
||||
|
||||
def test_parse_error_returns_false(self, temp_project, tmp_path):
|
||||
with patch("lib.system_import.run", return_value="garbage with no valid zones"):
|
||||
assert not system_import.import_firewall()
|
||||
|
||||
Reference in New Issue
Block a user