fix: ACME cert list self-heals when account.conf is left owner-only
The startup normalize and _run_acme_preflight covered daemon startup and issue/renew, but the recurring collector poll called lib.acme.list_certs() without normalizing ACME_HOME. A non-daemon run (e.g. a manual run as the WebUI user) re-creating account.conf owner-only made every acme.sh --list exit 2, so the collector returned certs=[] and the UI showed no certs until the next issue/renew or daemon restart. - collector: normalize_acme_home() before list_certs() so the poll self-heals - issue pre-check: normalize before the direct lib.acme.list_certs() - _parse_account_conf: read acme.sh v3 account.conf (not just .account.conf) - _collect_acme: actionable status.error for the account.conf perm case - install.sh: chown ACME_HOME conf files to the daemon user
This commit is contained in:
@@ -267,6 +267,7 @@ class TestAcmeCollectNonFatal:
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||
),
|
||||
patch("daemon.handlers.acme.normalize_acme_home"),
|
||||
patch(
|
||||
"lib.acme.list_certs",
|
||||
side_effect=RuntimeError("acme.sh failed with exit code 2"),
|
||||
@@ -289,6 +290,7 @@ class TestAcmeCollectNonFatal:
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||
),
|
||||
patch("daemon.handlers.acme.normalize_acme_home"),
|
||||
patch("lib.acme.list_certs", return_value=[]),
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
||||
@@ -298,6 +300,94 @@ class TestAcmeCollectNonFatal:
|
||||
|
||||
assert result["status"] == {"error": None}
|
||||
|
||||
def test_self_heal_normalizes_before_list(self):
|
||||
from daemon.collectors.acme import _collect_acme
|
||||
|
||||
order: list[str] = []
|
||||
|
||||
def _norm():
|
||||
order.append("normalize")
|
||||
|
||||
def _list():
|
||||
order.append("list")
|
||||
return [{"domain": "example.com"}]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||
),
|
||||
patch("daemon.handlers.acme.normalize_acme_home", side_effect=_norm),
|
||||
patch("lib.acme.list_certs", side_effect=_list),
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
||||
),
|
||||
):
|
||||
result = _collect_acme()
|
||||
|
||||
# The poll must normalize ACME_HOME perms before listing, so a
|
||||
# mid-lifetime ownership flip self-heals without a restart.
|
||||
assert order == ["normalize", "list"]
|
||||
assert result["certs"] == [{"domain": "example.com"}]
|
||||
assert result["status"] == {"error": None}
|
||||
|
||||
def test_permission_error_is_actionable(self):
|
||||
from daemon.collectors.acme import _collect_acme
|
||||
|
||||
msg = "acme.sh failed with exit code 2: .../account.conf: Permission denied"
|
||||
with (
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_get_acme_email", return_value="a@b.c"
|
||||
),
|
||||
patch("daemon.handlers.acme.normalize_acme_home"),
|
||||
patch("lib.acme.list_certs", side_effect=RuntimeError(msg)),
|
||||
patch.object(
|
||||
daemon.collectors.acme, "_parse_account_conf", return_value=_ACCOUNT
|
||||
),
|
||||
):
|
||||
result = _collect_acme()
|
||||
|
||||
assert result["status"]["error"] is not None
|
||||
assert "sudo chown" in result["status"]["error"]
|
||||
|
||||
|
||||
class TestParseAccountConf:
|
||||
"""_parse_account_conf reads acme.sh v3's account.conf (no leading dot)."""
|
||||
|
||||
def test_reads_no_dot_account_conf(self, tmp_path):
|
||||
from daemon.collectors.acme import _parse_account_conf
|
||||
|
||||
(tmp_path / "account.conf").write_text(
|
||||
"ACME_LEEMAIL='me@example.com'\nACME_MCA='zerossl'\nACME_CERTKEYSIZE=256\n"
|
||||
)
|
||||
acct = _parse_account_conf(acme_home=tmp_path)
|
||||
assert acct["registered"] is True
|
||||
assert acct["email"] == "me@example.com"
|
||||
assert acct["ca"] == "ZeroSSL"
|
||||
assert acct["key_length"] == 256
|
||||
|
||||
def test_prefers_no_dot_over_legacy_dot(self, tmp_path):
|
||||
from daemon.collectors.acme import _parse_account_conf
|
||||
|
||||
(tmp_path / "account.conf").write_text(
|
||||
"ACME_LEEMAIL='new@example.com'\nACME_MCA='letsencrypt'\n"
|
||||
)
|
||||
(tmp_path / ".account.conf").write_text(
|
||||
"ACME_LEEMAIL='old@example.com'\nACME_MCA='zerossl'\n"
|
||||
)
|
||||
acct = _parse_account_conf(acme_home=tmp_path)
|
||||
assert acct["email"] == "new@example.com"
|
||||
|
||||
def test_falls_back_to_legacy_dot(self, tmp_path):
|
||||
from daemon.collectors.acme import _parse_account_conf
|
||||
|
||||
(tmp_path / ".account.conf").write_text(
|
||||
"ACME_LEEMAIL='legacy@example.com'\nACME_MCA='zerossl'\n"
|
||||
)
|
||||
acct = _parse_account_conf(acme_home=tmp_path)
|
||||
assert acct["registered"] is True
|
||||
assert acct["email"] == "legacy@example.com"
|
||||
assert acct["ca"] == "ZeroSSL"
|
||||
|
||||
|
||||
class TestStateVersions:
|
||||
def test_version_starts_at_zero(self):
|
||||
|
||||
Reference in New Issue
Block a user