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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user