"""ACME certificate daemon handler.""" import asyncio import logging import os import re import socket import subprocess from contextlib import suppress from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path from typing import Any from uuid import uuid4 from daemon.server import NotFoundError, refresh_state, 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" # In-memory store for active issuance requests. _ISSUANCES: dict[str, "IssueRequest"] = {} _ISSUANCE_TTL = 300 # seconds to keep completed requests @dataclass class IssueStep: name: str label: str status: str = "pending" message: str | None = None @dataclass class IssueRequest: request_id: str domain: str email: str | None = None webroot: str | None = None steps: list[IssueStep] = field(default_factory=list) status: str = "running" created_at: float = field(default_factory=lambda: datetime.now(UTC).timestamp()) expires_at: float | None = None def to_dict(self) -> dict[str, Any]: return { "request_id": self.request_id, "domain": self.domain, "status": self.status, "steps": [ { "name": s.name, "label": s.label, "status": s.status, "message": s.message, } for s in self.steps ], "created_at": self.created_at, "expires_at": self.expires_at, } # --------------------------------------------------------------------------- # Internal helpers def _run_acme(args: list[str]) -> str: from lib.state import _find_acme 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 _find_acme_bin() -> str: from lib.state import _find_acme return _find_acme() def _get_acme_email() -> str: 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 match.group(1).strip().strip("'\"") except OSError: pass return "" def _get_state() -> dict[str, Any] | None: from lib.state import state as state_store return state_store.get("acme") def _get_acme_state() -> dict[str, Any]: ac = _get_state() if ac is None: return {} return ac def _clean_expired_issuances() -> None: """Remove completed requests older than TTL.""" now = datetime.now(UTC).timestamp() expired = [ rid for rid, req in _ISSUANCES.items() if req.expires_at and now > req.expires_at ] for rid in expired: del _ISSUANCES[rid] # --------------------------------------------------------------------------- # Validation helpers def _check_domain_format(domain: str) -> tuple[bool, str]: """Validate basic domain name format.""" import re as _re pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$" if not _re.match(pattern, domain): return False, "Invalid domain name format" return True, "" def _check_dns_resolves(domain: str) -> tuple[bool, str]: """Check that domain resolves to this machine's IP via A record.""" try: results = socket.getaddrinfo(domain, 80, socket.AF_UNSPEC, socket.SOCK_STREAM) if not results: return False, "Domain does not resolve to any address" local_ips = set() hostname = socket.gethostname() with suppress(OSError): local_ips.add(socket.gethostbyname(hostname)) # Also collect all interface IPs try: import ipaddress from fcntl import ioctl def get_interfaces(): import struct s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) names = b"\x00" * 4096 raw = ioctl(s.fileno(), 0x8912, names) s.close() ifaces = [] for i in range(0, 4096, 32): name = raw[i : i + 16].split(b"\x00")[0].decode() if name == "lo": continue addr = struct.unpack(" tuple[bool, str]: try: _find_acme_bin() return True, "acme.sh found" except FileNotFoundError: return False, "acme.sh not installed" def _check_email_configured() -> tuple[bool, str]: email = _get_acme_email() or "" if email: return True, f"Contact email configured: {email}" return False, "No ACME contact email configured" def _check_webroot() -> tuple[bool, str]: if _WEBROOT.is_dir() and os.access(str(_WEBROOT), os.W_OK): return True, "ACME webroot ready" return False, "ACME webroot not ready or not writable" def _check_challenge_config() -> tuple[bool, str]: from lib.nginx import SITES_DIR site_conf = SITES_DIR / "_acme-challenge.conf" if SITES_DIR else None if site_conf and site_conf.is_file(): return True, "ACME challenge nginx config present" return False, "ACME challenge nginx config missing" def _check_existing_cert(domain: str) -> tuple[bool, str]: """Warn if a valid cert already exists (not blocking).""" try: from lib.acme import days_until_expiry days = days_until_expiry(domain) if days is not None and days > 0: return True, f"Valid certificate exists ({days} days remaining)" except (ValueError, RuntimeError, FileNotFoundError): pass return True, "" def _validate(domain: str) -> dict[str, Any]: """Run all pre-checks for a domain. Returns structured results.""" checks: list[dict[str, Any]] = [] ready = True check_fns = [ ("acme_installed", _check_acme_installed, True), ("email_configured", _check_email_configured, True), ("webroot_ready", _check_webroot, True), ("challenge_configured", _check_challenge_config, True), ("domain_format", lambda: _check_domain_format(domain), True), ("dns_resolves", lambda: _check_dns_resolves(domain), True), ("existing_cert", lambda: _check_existing_cert(domain), False), ] for name, fn, blocking in check_fns: try: passed, msg = fn() checks.append( {"name": name, "passed": passed, "message": msg, "blocking": blocking} ) if not passed and blocking: ready = False except Exception as exc: checks.append( { "name": name, "passed": False, "message": str(exc), "blocking": blocking, } ) ready = False return {"domain": domain, "checks": checks, "ready": ready} # --------------------------------------------------------------------------- # Routes — status reads from state, mutations call refresh_state @registry.register("GET", "/acme/list") def list_certs(_request: Any, _body: Any) -> list[dict]: ac = _get_acme_state() return ac.get("certs", []) @registry.register("GET", "/acme/info") 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.get("san_domains", []): return c raise NotFoundError(f"No certificate found for domain: {domain}") @registry.register("POST", "/acme/validate") def validate_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") return _validate(domain) @registry.register("POST", "/acme/issue") async 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") email = body.get("email", "").strip() or None webroot = body.get("webroot") _clean_expired_issuances() # Dedup: if domain already has an active request, return existing ID for existing in _ISSUANCES.values(): if existing.domain == domain and existing.status == "running": return { "request_id": existing.request_id, "status": "existing", "domain": domain, "message": "Issuance already in progress for this domain", } # Run pre-flight checks _validate_checks = _validate(domain) if not _validate_checks["ready"]: failed = [ c["name"] for c in _validate_checks["checks"] if not c["passed"] and c["blocking"] ] raise RuntimeError(f"Pre-flight checks failed: {', '.join(failed)}") # Create tracked request request_id = uuid4().hex[:12] steps = [ IssueStep(name="issue", label="Issuing certificate"), IssueStep(name="deploy", label="Registering deploy hook"), IssueStep(name="refresh", label="Refreshing certificate state"), ] req = IssueRequest( request_id=request_id, domain=domain, email=email, webroot=webroot, steps=steps, ) _ISSUANCES[request_id] = req # Spawn background task _task = asyncio.create_task(_run_issue(req)) # noqa: RUF006 — task runs to completion on its own return {"request_id": request_id, "domain": domain} @registry.register("GET", "/acme/issue/status") def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: request_id = (body or {}).get("id", "").strip() if not request_id: raise ValueError("'id' is required") req = _ISSUANCES.get(request_id) if not req: raise NotFoundError(f"Issuance request {request_id} not found") return req.to_dict() async def _run_issue(req: IssueRequest) -> None: """Background task: run acme.sh steps, update step status.""" try: # Step 1: issue req.steps[0].status = "running" args: list[str] = ["--issue", "-d", req.domain] args.extend(["--webroot", req.webroot or str(_WEBROOT)]) contact = req.email if not contact: contact = _get_acme_email() if contact: args.extend(["-m", contact]) args.append("--force") output = _run_acme(args) req.steps[0].status = "done" req.steps[0].message = output.strip()[:200] # Step 2: deploy req.steps[1].status = "running" _run_acme(["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]) req.steps[1].status = "done" req.steps[1].message = "Deploy hook registered" # Step 3: refresh state req.steps[2].status = "running" refresh_state(["acme"]) req.steps[2].status = "done" req.steps[2].message = "State refreshed" req.status = "completed" req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL logger.info( "Certificate for %s issued (request %s)", req.domain, req.request_id ) except Exception as exc: # Mark current running step as error, overall as failed for step in req.steps: if step.status == "running": step.status = "error" step.message = str(exc) break else: req.steps.append( IssueStep(name="error", label="Error", status="error", message=str(exc)) ) req.status = "failed" req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL logger.error("Cert issuance for %s failed: %s", req.domain, exc) @registry.register("POST", "/acme/renew") 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) refresh_state(["acme"]) return {"domain": domain, "output": output.strip()} @registry.register("DELETE", "/acme/remove") 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) refresh_state(["acme"]) return {"domain": domain} @registry.register("POST", "/acme/email") 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) refresh_state(["acme"]) return {"email": email} @registry.register("GET", "/acme/email") def get_email(_request: Any, _body: Any) -> dict[str, Any]: ac = _get_acme_state() if ac: return {"email": ac.get("email", "")} 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") 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", }