""" ACME certificate manager for Vacuum Wall. Wraps acme.sh to issue, renew, and manage SSL/TLS certificates from Let's Encrypt (or other ACME providers). acme.sh runs as the vacuum-wall system user; nginx is reloaded via a deploy hook script. """ import logging import os import re import shutil import subprocess from datetime import UTC, datetime from pathlib import Path logger = logging.getLogger(__name__) _ACME_ENVIRON = { "HOME": str(Path.home()), "PATH": os.environ.get( "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ), } PROJECT_DIR = Path("/home/wall/vacuum-wall") _DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh") def _find_acme() -> str: """Locate the acme.sh binary on the system. Checks: 1. ~/.acme.sh/acme.sh 2. /usr/local/bin/acme.sh Returns: Absolute path to the acme.sh binary. Raises: FileNotFoundError: If acme.sh cannot be found. """ candidates = [ Path.home() / ".acme.sh" / "acme.sh", Path("/usr/local/bin/acme.sh"), ] for path in candidates: if path.is_file() and os.access(path, os.X_OK): logger.info("Found acme.sh at %s", path) return str(path) acme = shutil.which("acme.sh") if acme: logger.info("Found acme.sh via PATH at %s", acme) return acme raise FileNotFoundError( "acme.sh not found in any standard location. " "Install it with: curl -sSL https://get.acme.sh | sh" ) def _run_acme(args: list[str]) -> str: """Execute acme.sh with the given arguments (as the current user). acme.sh does not need root for most operations. Only standalone and TLS-ALPN validation modes require binding to privileged ports, which are not used by Vacuum Wall (webroot validation is used instead). Args: args: List of arguments to pass to acme.sh. Returns: Combined stdout + stderr from the command, since acme.sh writes meaningful output to both streams. Raises: RuntimeError: If the acme.sh command exits with a non-zero code. """ acme_bin = _find_acme() cmd: list[str] = [ acme_bin, "--home", str(Path.home() / ".acme.sh"), "--config-home", str(Path.home() / ".acme.sh"), *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 command timed out after 120s: {' '.join(cmd)}" ) from exc output = result.stdout if result.stderr: output = output + result.stderr if output else result.stderr if result.returncode != 0: logger.error("acme.sh failed (rc=%d): %s", result.returncode, output.strip()) raise RuntimeError( f"acme.sh failed with exit code {result.returncode}: {output.strip()}" ) return output def set_email(email: str) -> None: """Configure the default ACME contact email. Registers or updates the ACME account with the given email address. Args: email: The contact email for the ACME account. """ _run_acme(["--register-account", "-m", email]) logger.info("ACME contact email set to %s", email) def get_email() -> str: """Return the ACME contact email, or '' if none is configured.""" try: account_conf = Path.home() / ".acme.sh" / "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 as exc: logger.warning("Could not read account.conf: %s", exc) return "" def issue(domain: str, webroot: str | None = None) -> dict: """Issue a new SSL certificate for a domain. Args: domain: The primary domain name. webroot: Path to the web root directory for HTTP-01 validation. Returns: A dict with 'success', 'domain', 'message', 'output', and 'error'. """ args: list[str] = ["--issue", "-d", domain] if webroot: args.extend(["--webroot", webroot]) email = get_email() if email: args.extend(["-m", email]) args.append("--force") try: output = _run_acme(args) deploy(domain) return { "success": True, "domain": domain, "message": f"Certificate for {domain} issued successfully", "output": output.strip(), "error": None, } except RuntimeError as exc: return { "success": False, "domain": domain, "message": f"Failed to issue certificate for {domain}", "output": "", "error": str(exc), } def renew(domain: str, force: bool = False) -> dict: """Renew an existing SSL certificate. Args: domain: The domain whose certificate should be renewed. force: If True, renew even if the certificate isn't close to expiry. Returns: A dict with 'success', 'domain', 'message', 'output', and 'error'. """ args: list[str] = ["--renew", "-d", domain] if force: args.append("--force") try: output = _run_acme(args) return { "success": True, "domain": domain, "message": f"Certificate for {domain} renewed successfully", "output": output.strip(), "error": None, } except RuntimeError as exc: return { "success": False, "domain": domain, "message": f"Failed to renew certificate for {domain}", "output": "", "error": str(exc), } def remove(domain: str) -> str: """Stop auto-renewal for a domain. Runs ``acme.sh --remove`` which stops the cron job from renewing the certificate. Per the acme.sh README the cert/key files are **not** deleted from disk after ``--remove``; remove them with the ``--ecc`` flag if needed, or delete the ``~/.acme.sh/{domain}`` directory manually. Args: domain: The domain to remove from the renewal list. Returns: The combined stdout from the acme.sh command. """ output = _run_acme(["--remove", "-d", domain]) logger.info("Certificate for %s removed", domain) return output def list_certs() -> list[dict]: """List all managed certificates with expiry information. Returns: A list of dicts, one per certificate, with keys matching the cert-info schema (domain, ca, cert_path, etc.). """ raw = _run_acme(["--list"]) certs: list[dict] = [] entries = _parse_list_output(raw) acme_home = Path.home() / ".acme.sh" 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 cert_path = str(cert_dir / "fullchain.cer") key_path = str(cert_dir / f"{main}.key") ca_path = str(cert_dir / "ca.cer") days = _days_until(entry.get("certificate_expires", "")) auto = _has_auto_renew(main) certs.append( { "domain": main, "ca": entry.get("CA", ""), "cert_path": cert_path, "key_path": key_path, "ca_path": ca_path, "issued_at": entry.get("certificate_date", ""), "expires_at": entry.get("certificate_expires", ""), "days_until_expiry": days, "auto_renew": auto, "san_domains": san_domains, } ) return certs def get_cert_info(domain: str) -> dict: """Return detailed information about a certificate. Args: domain: The domain name. Returns: A dict matching the cert-info schema. Raises: ValueError: If no certificate is found for the domain. """ certs = list_certs() for c in certs: if c["domain"] == domain or domain in c["san_domains"]: return c raise ValueError(f"No certificate found for domain: {domain}") def get_expiry(domain: str) -> str | None: """Return the certificate expiry date as an ISO string, or None. Args: domain: The domain name. Returns: Expiry date string (e.g. '2026-04-15') or None. """ try: info = get_cert_info(domain) return info.get("expires_at") except ValueError: return None def is_expired(domain: str) -> bool: """Check whether a certificate has expired. Args: domain: The domain name. Returns: True if the certificate is expired or not found, False otherwise. """ days = days_until_expiry(domain) if days is None: return True return days < 0 def days_until_expiry(domain: str) -> int | None: """Calculate the number of days until a certificate expires. Args: domain: The domain name. Returns: Integer days remaining (negative if expired), or None if cert not found. """ try: info = get_cert_info(domain) return _days_until(info.get("expires_at", "")) except ValueError: return None def copy_cert(domain: str, dest_dir: str) -> dict: """Copy certificate files to a target directory. Copies the fullchain, key, and CA certificate files. Args: domain: The domain name. dest_dir: Destination directory path. Returns: A dict with paths to the copied files. """ paths = get_cert_paths(domain) target = Path(dest_dir) target.mkdir(parents=True, exist_ok=True) copied = {} for label, src in paths.items(): src_path = Path(src) if src_path.is_file(): dst = target / src_path.name shutil.copy2(str(src_path), str(dst)) copied[label] = str(dst) else: logger.warning("Source %s (%s) not found, skipping", label, src) return { "domain": domain, "dest_dir": str(target), "copied": copied, "failed": [k for k in paths if k not in copied], } def get_cert_paths(domain: str) -> dict: """Return the file paths for all certificate components. Args: domain: The domain name. Returns: Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths. """ acme_home = str(Path.home() / ".acme.sh" / 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", } def deploy(domain: str) -> None: """Register the deploy hook for a domain. Tells acme.sh to run the Vacuum Wall deploy script after every successful issue or renewal. The hook fires automatically on future renewals as well, so this only needs to be called once per domain. Args: domain: The domain name. """ _run_acme( [ "--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK, ] ) logger.info("Deploy hook registered for %s", domain) # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _parse_list_output(raw: str) -> list[dict]: """Parse the text output from ``acme.sh --list`` into a list of dicts. Each line in the output contains ``Key:Value`` tokens separated by whitespace, e.g.:: Main_Domain:example.com SAN_Domain:www.example.com CA:Let's Encrypt Certificate_Date:2026-04-01 Certificate_Expired:No Keys are converted to lowercase in the returned dicts. """ 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 _days_until(date_str: str) -> int | None: """Parse an ISO date string and return days until that date from now.""" 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) delta = dt - datetime.now(UTC) return delta.days except ValueError: continue try: dt = datetime.strptime(date_str, "%Y%m%d%H%M%z").astimezone(UTC) delta = dt - datetime.now(UTC) return delta.days except ValueError: pass return None def _has_auto_renew(domain: str) -> bool: """Check whether a domain has automatic renewal configured. acme.sh tracks certificates in per-domain ``{domain}.conf`` files under ``~/.acme.sh/``; existence of this file means the systemd timer's ``--cron`` run will pick it up. """ acme_home = Path.home() / ".acme.sh" domain_conf = acme_home / f"{domain}.conf" return bool(domain_conf.is_file())