Files
vacuum-wall/lib/acme.py
T
mteehan d9797b6dac Migrate declarative configs to config/ dir and remove hardcoded paths
Replace all hardcoded /home/wall/vacuum-wall paths in lib/ with Path(__file__).resolve()
auto-discovery. Move config files from data/ to config/<subsystem>/config.json.
ACME now uses ACME_HOME env var and data/acme/ for cert storage. Systemd units
and sudoers use {{ USER_NAME }}, {{ PROJECT_DIR }}, {{ ACME_HOME }} Jinja2
template variables for install-time substitution. Remove sys.path.insert boot
strap from test files.
2026-05-14 03:31:13 +00:00

497 lines
14 KiB
Python

"""
ACME certificate manager for Vacuum Wall.
Wraps acme.sh to issue, renew, and manage SSL/TLS certificates
from ACME providers such as ZeroSSL or Let's Encrypt. 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__)
PROJECT_DIR = Path(__file__).resolve().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"
),
}
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 = [
_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):
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()
# Check for ACME_HOME env var (set by systemd in production)
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
cmd: list[str] = [
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 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:
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 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_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
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_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",
}
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_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
domain_conf = Path(acme_home_env) / f"{domain}.conf"
return bool(domain_conf.is_file())