"""ACME state collector.""" import logging import os from pathlib import Path from typing import Any from lib import schema from lib.common import load_json from lib.state import PROJECT_DIR, _now_iso, register_collector logger = logging.getLogger(__name__) _CA_NAME_MAP: dict[str, str] = { "letsencrypt": "Let's Encrypt", "zerossl": "ZeroSSL", } def _resolve_ca_name(ca_server: str) -> str: """Map a CA server identifier to its human-readable name. Uses prefix matching sorted by longest prefix first to avoid shorter prefixes winning (e.g. "letsencrypt" matching before "letsencrypt.org"). Args: ca_server: Raw CA server string from acme.sh config. Returns: Human-readable name, or unchanged string if no match. """ for prefix, name in sorted( _CA_NAME_MAP.items(), key=lambda x: len(x[0]), reverse=True ): if ca_server.startswith(prefix): return name return ca_server def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]: """Parse acme.sh account information and return account status dict. Checks three sources in order: 1. Legacy ``.account.conf`` file (acme.sh v2.x format) 2. Declarative ``config/acme/config.json`` (saved by the registration handler with ``email`` and ``ca`` fields) Args: acme_home: Optional override for ACME home directory. Falls back to ``ACME_HOME`` env var or ``PROJECT_DIR/data/acme``. Returns: Dict with ``registered``, ``email``, ``ca``, and ``key_length`` keys. If no account is found, ``registered`` is ``False`` with empty / ``None`` values. """ if acme_home is None: acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme")) acme_home = Path(acme_home_env) default = { "registered": False, "email": "", "ca": "", "key_length": None, } # 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: pass else: email = "" ca_raw = "" key_length = None for line in text.splitlines(): if line.startswith("ACME_LEEMAIL="): email = line.split("=", 1)[1].strip().strip("'\"") elif line.startswith("ACME_MCA="): ca_raw = line.split("=", 1)[1].strip().strip("'\"") elif line.startswith("ACME_CERTKEYSIZE="): raw_val = line.split("=", 1)[1].strip().strip("'\"") key_length = int(raw_val) if raw_val.isdigit() else None if email and ca_raw: return { "registered": True, "email": email, "ca": _resolve_ca_name(ca_raw), "key_length": key_length, } # 2. Declarative config (saved by register_account / set_email handlers) # Modern acme.sh (v3.x) stores account data in per-CA JSON files # (ca//account.json) — we can't reliably parse those without # walking the directory, so fall back to the declarative config # which the handlers keep in sync. # Derive project root from acme_home (acme_home is at /data/acme). try: project_root = acme_home.parent.parent # data/acme → data → project root acme_cfg = project_root / "config" / "acme" / "config.json" data = load_json(acme_cfg) email = (data.get("email") or "").strip() ca_raw = (data.get("ca") or "").strip() if email and ca_raw: return { "registered": True, "email": email, "ca": _resolve_ca_name(ca_raw), "key_length": None, } except (OSError, ValueError): pass return default def _get_acme_email() -> str: """Read the ACME ``acme.sh`` email from the account config file. Falls back to the declarative ACME config (config/acme/config.json) if acme.sh account has not been registered yet. """ from lib.acme import _read_acme_email 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 : /account.conf " "&& sudo chmod 0640 /account.conf, then restart " "vacuum-walld" ) return text def _collect_acme() -> schema.AcmeState: """Collect ACME certificate list and email. Returns: Dict containing certificate details and registered email. """ email = _get_acme_email() # Non-fatal: a broken acme.sh (e.g. unreadable account.conf after an # ownership flip) must not blank the whole dashboard via a cleared # state store. Collect what we can and surface the failure in # `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 = _friendly_acme_error(exc) account = _parse_account_conf() return { "certs": certs, "email": email, "account": account, "status": {"error": cert_error}, "timestamp": _now_iso(), } register_collector("acme", _collect_acme)