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