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:
2026-09-04 21:38:55 +00:00
parent 2b7fe1f485
commit 6476695d29
4 changed files with 145 additions and 5 deletions
+41 -4
View File
@@ -66,9 +66,17 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
"key_length": None,
}
# 1. Legacy .account.conf (acme.sh v2.x)
account_path = acme_home / ".account.conf"
if account_path.is_file():
# 1. acme.sh account file. Modern acme.sh (v3.x) writes ``account.conf``;
# older v2.x wrote ``.account.conf``. Check both so the account card
# reflects the real acme.sh account rather than only the declarative
# fallback below.
account_path = None
for name in ("account.conf", ".account.conf"):
candidate = acme_home / name
if candidate.is_file():
account_path = candidate
break
if account_path is not None:
try:
text = account_path.read_text()
except OSError:
@@ -129,6 +137,26 @@ def _get_acme_email() -> str:
return _read_acme_email()
def _friendly_acme_error(exc: Exception) -> str:
"""Turn a collection exception into an actionable message.
The collector already self-heals by normalizing ACME_HOME permissions
first, so the one remaining permission case is when that normalize could
not run (e.g. the sudo step was denied). For that case surface a concrete
remediation instead of the raw acme.sh exit-2 text; otherwise return the
original message unchanged.
"""
text = str(exc)
if "account.conf" in text and "Permission denied" in text:
return (
f"{text} — account.conf is not readable by the daemon; repair it "
"with: sudo chown <daemon-user>:<group> <ACME_HOME>/account.conf "
"&& sudo chmod 0640 <ACME_HOME>/account.conf, then restart "
"vacuum-walld"
)
return text
def _collect_acme() -> schema.AcmeState:
"""Collect ACME certificate list and email.
@@ -143,13 +171,22 @@ def _collect_acme() -> schema.AcmeState:
# `status.error` so the poll diff still detects recovery.
cert_error: str | None = None
try:
# Self-heal ACME_HOME permissions before listing, exactly like the
# handler preflight (_run_acme_preflight). acme.sh dot-sources
# account.conf on startup; a prior run by another user (e.g. a manual
# run as the WebUI user) can leave it owner-only and make `--list`
# exit 2. The startup normalize only covers the first collection, so
# the poll must normalize too or a mid-lifetime ownership flip would
# blank the cert list until the next issue/renew or daemon restart.
from daemon.handlers.acme import normalize_acme_home
from lib.acme import list_certs
normalize_acme_home()
certs = list_certs()
except Exception as exc:
logger.warning("ACME state collection failed", exc_info=True)
certs = []
cert_error = str(exc)
cert_error = _friendly_acme_error(exc)
account = _parse_account_conf()
+4 -1
View File
@@ -807,8 +807,11 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
"status": "existing",
}
# Check if cert already exists — call acme.sh directly, not via state
# Check if cert already exists — call acme.sh directly, not via state.
# Normalize ACME_HOME first (same reason as the preflight): a prior run by
# another user can leave account.conf owner-only and make `--list` exit 2.
try:
normalize_acme_home()
certs = lib.acme.list_certs()
except RuntimeError as exc:
raise RuntimeError(f"Cannot check existing certificates: {exc}") from exc