"""ACME certificate daemon handler.""" import logging import os import re import shutil import subprocess from datetime import UTC, datetime from pathlib import Path from typing import Any from daemon.server import NotFoundError, registry logger = logging.getLogger(__name__) PROJECT_DIR = Path(__file__).resolve().parent.parent.parent _ACME_HOME = PROJECT_DIR / "data" / "acme" _DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh") _ACME_ENVIRON = { "HOME": str(PROJECT_DIR), "PATH": os.environ.get( "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ), } _WEBROOT = PROJECT_DIR / "data" / "acme" / "www" _ACME_TAGS = {"acme"} def _find_acme() -> str: candidates = [_ACME_HOME / "acme.sh", Path("/usr/local/bin/acme.sh")] for path in candidates: if path.is_file() and os.access(path, os.X_OK): return str(path) acme = shutil.which("acme.sh") if acme: return acme raise FileNotFoundError("acme.sh not found") def _run_acme(args: list[str]) -> str: acme_bin = _find_acme() acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args] try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=120, env={**os.environ, **_ACME_ENVIRON}, ) except subprocess.TimeoutExpired as exc: raise RuntimeError(f"acme.sh timed out: {' '.join(cmd)}") from exc output = result.stdout if result.stderr: output = output + result.stderr if output else result.stderr if result.returncode != 0: raise RuntimeError(f"acme.sh failed (rc={result.returncode}): {output.strip()}") return output def _days_until(date_str: str) -> int | None: if not date_str: return None for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"): try: dt = datetime.strptime(date_str, fmt).replace(tzinfo=UTC) return (dt - datetime.now(UTC)).days except ValueError: continue try: dt = datetime.strptime(date_str, "%Y%m%d%H%M%z").astimezone(UTC) return (dt - datetime.now(UTC)).days except ValueError: pass return None def _parse_list_output(raw: str) -> list[dict]: entries: list[dict] = [] for line in raw.strip().splitlines(): line = line.strip() if not line: continue entry: dict[str, str] = {} for token in line.split(): if ":" not in token: continue key, _, value = token.partition(":") entry[key.lower()] = value if entry: entries.append(entry) return entries def _has_auto_renew(domain: str) -> bool: acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) return bool(Path(acme_home_env) / f"{domain}.conf") @registry.register("GET", "/acme/list", cache_tags=_ACME_TAGS) def list_certs(_request: Any, _body: Any) -> list[dict]: raw = _run_acme(["--list"]) certs: list[dict] = [] entries = _parse_list_output(raw) acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) acme_home = Path(acme_home_env) for entry in entries: main = entry["main_domain"] if not main: continue san_domains = [ d.strip() for d in entry.get("san_domain", "").split(",") if d.strip() ] cert_dir = acme_home / main days = _days_until(entry.get("certificate_expires", "")) certs.append( { "domain": main, "issuer": entry.get("CA", ""), "expiry": entry.get("certificate_expires", ""), "days_remaining": days, "expired": days is not None and days <= 0, "cert_path": str(cert_dir / "fullchain.cer"), "key_path": str(cert_dir / f"{main}.key"), "ca_path": str(cert_dir / "ca.cer"), "issued_at": entry.get("certificate_date", ""), "expires_at": entry.get("certificate_expires", ""), "days_until_expiry": days, "auto_renew": _has_auto_renew(main), "san_domains": san_domains, } ) return certs @registry.register("GET", "/acme/info", cache_tags=_ACME_TAGS) def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict: if not body or "domain" not in body: raise ValueError("'domain' is required") domain = body["domain"] certs = list_certs(None, None) for c in certs: if c["domain"] == domain or domain in c["san_domains"]: return c raise NotFoundError(f"No certificate found for domain: {domain}") @registry.register("POST", "/acme/issue", invalidate=_ACME_TAGS | {"nginx"}) def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") domain = body.get("domain", "").strip() if not domain: raise ValueError("'domain' is required") webroot = body.get("webroot") email = body.get("email", "").strip() or None args: list[str] = ["--issue", "-d", domain] args.extend(["--webroot", webroot or str(_WEBROOT)]) contact = email if not contact: try: acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME))) account_conf = acme_home / "account.conf" if account_conf.is_file(): text = account_conf.read_text() match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE) if match: contact = match.group(1).strip().strip("'\"") except OSError: pass if contact: args.extend(["-m", contact]) args.append("--force") output = _run_acme(args) _run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK]) logger.info("Certificate for %s issued", domain) return {"domain": domain, "output": output.strip()} @registry.register("POST", "/acme/renew", invalidate=_ACME_TAGS | {"nginx"}) def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") domain = body.get("domain", "").strip() if not domain: raise ValueError("'domain' is required") force = body.get("force", False) args: list[str] = ["--renew", "-d", domain] if force: args.append("--force") output = _run_acme(args) _run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK]) logger.info("Certificate for %s renewed", domain) return {"domain": domain, "output": output.strip()} @registry.register("DELETE", "/acme/remove", invalidate=_ACME_TAGS) def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") domain = body.get("domain", "").strip() if not domain: raise ValueError("'domain' is required") _run_acme(["--remove", "-d", domain]) logger.info("Certificate for %s removed", domain) return {"domain": domain} @registry.register("POST", "/acme/email", invalidate=_ACME_TAGS) def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: if not body: raise ValueError("Request body required") email = body.get("email", "").strip() if not email: raise ValueError("'email' is required") _run_acme(["--register-account", "-m", email]) logger.info("ACME email set to %s", email) return {"email": email} @registry.register("GET", "/acme/email", cache_tags=_ACME_TAGS) def get_email(_request: Any, _body: Any) -> dict[str, Any]: try: acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME))) account_conf = acme_home / "account.conf" if account_conf.is_file(): text = account_conf.read_text() match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE) if match: return {"email": match.group(1).strip().strip("'\"")} except OSError: pass return {"email": ""} @registry.register("GET", "/acme/paths", cache_tags=_ACME_TAGS) def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]: if not body or "domain" not in body: raise ValueError("'domain' is required") domain = body["domain"] acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME)) acme_home = str(Path(acme_home_env) / domain) return { "cert": f"{acme_home}/{domain}.cert", "key": f"{acme_home}/{domain}.key", "ca": f"{acme_home}/ca.cer", "fullchain": f"{acme_home}/fullchain.cer", }