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