Refactor ACME module and add cert issuance conflict handling
- Move acme.sh utilities (_run_acme, _find_acme, etc.) from lib/state to lib/acme - Rewrite _parse_list_output to support pipe, tab, and column-separated formats - Add ConflictError (409) to block issuing when cert already exists - Move _find_issuance helper to detect in-progress issuance per domain - Update issue_cert to check existing certs and return issuance status - Fix start_polling to accept event loop explicitly - Add sudoers entry for chown on vacuum-wall.conf - Extend systemd ReadWritePaths for /run/nginx.pid and /var/log/nginx - Update frontend to handle 'existing' issuance status
This commit is contained in:
+39
-129
@@ -7,8 +7,6 @@ state instead of invoking subprocesses on every request.
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -28,6 +26,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
_CA_NAME_MAP: dict[str, str] = {
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"zerossl": "ZeroSSL",
|
||||
}
|
||||
|
||||
_DEFAULT_POLL_INTERVALS: dict[str, int] = {
|
||||
"firewall": 30,
|
||||
"wireguard": 10,
|
||||
@@ -677,113 +680,25 @@ register_collector("nginx", _collect_nginx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_acme() -> str:
|
||||
"""Locate the ``acme.sh`` binary on the filesystem.
|
||||
def _resolve_ca_name(ca_server: str) -> str:
|
||||
"""Map a CA server identifier to its human-readable name.
|
||||
|
||||
Returns:
|
||||
Absolute path to the ``acme.sh`` executable.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If acme.sh cannot be found.
|
||||
"""
|
||||
acme_home = PROJECT_DIR / "data" / "acme"
|
||||
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:
|
||||
"""Run ``acme.sh`` with *args* and return combined output.
|
||||
Uses prefix matching sorted by longest prefix first to avoid
|
||||
shorter prefixes winning (e.g. "letsencrypt" matching before
|
||||
"letsencrypt.org").
|
||||
|
||||
Args:
|
||||
args: Command-line arguments to pass after the home/config flags.
|
||||
ca_server: Raw CA server string from acme.sh config.
|
||||
|
||||
Returns:
|
||||
Combined stdout/stderr output.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If acme.sh exits non-zero or times out.
|
||||
Human-readable name, or unchanged string if no match.
|
||||
"""
|
||||
acme_bin = _find_acme()
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
|
||||
_ACME_ENVIRON = {
|
||||
"HOME": str(PROJECT_DIR),
|
||||
"PATH": os.environ.get(
|
||||
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
),
|
||||
}
|
||||
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:
|
||||
"""Parse a date string and return days until *date_str* from now.
|
||||
|
||||
Args:
|
||||
date_str: Date string in common ACME formats.
|
||||
|
||||
Returns:
|
||||
Number of days remaining, or ``None`` if empty or unparseable.
|
||||
"""
|
||||
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_acme_list_output(raw: str) -> list[dict]:
|
||||
"""Parse ``acme.sh --list`` output into a list of certificate dicts.
|
||||
|
||||
Args:
|
||||
raw: Raw output string from ``acme.sh --list``.
|
||||
|
||||
Returns:
|
||||
List of dicts with certificate entry fields.
|
||||
"""
|
||||
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
|
||||
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]:
|
||||
@@ -834,11 +749,7 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
||||
if not email or not ca_raw:
|
||||
return default
|
||||
|
||||
ca_map = {
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"zerossl": "ZeroSSL",
|
||||
}
|
||||
ca = ca_map.get(ca_raw, ca_raw)
|
||||
ca = _resolve_ca_name(ca_raw)
|
||||
|
||||
return {
|
||||
"registered": True,
|
||||
@@ -848,19 +759,6 @@ def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _has_auto_renew(domain: str) -> bool:
|
||||
"""Check whether *domain* has an auto-renew configuration file.
|
||||
|
||||
Args:
|
||||
domain: Domain name to check.
|
||||
|
||||
Returns:
|
||||
``True`` if a corresponding ``acme.sh`` config file exists.
|
||||
"""
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||
return bool(Path(acme_home_env) / f"{domain}.conf")
|
||||
|
||||
|
||||
def _get_acme_email() -> str:
|
||||
"""Read the ACME ``acme.sh`` email from the account config file.
|
||||
|
||||
@@ -882,8 +780,16 @@ def _collect_acme() -> dict[str, Any]:
|
||||
|
||||
certs: list[dict[str, Any]] = []
|
||||
try:
|
||||
from lib.acme import (
|
||||
_days_until,
|
||||
_has_auto_renew,
|
||||
_parse_list_output,
|
||||
_run_acme,
|
||||
)
|
||||
|
||||
raw = _run_acme(["--list"])
|
||||
entries = _parse_acme_list_output(raw)
|
||||
|
||||
entries = _parse_list_output(raw)
|
||||
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
|
||||
acme_home = Path(acme_home_env)
|
||||
for entry in entries:
|
||||
@@ -891,29 +797,33 @@ def _collect_acme() -> dict[str, Any]:
|
||||
if not main:
|
||||
continue
|
||||
san_domains = [
|
||||
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
|
||||
d.strip() for d in entry.get("san_domains", "").split(",") if d.strip()
|
||||
]
|
||||
cert_dir = acme_home / main
|
||||
days = _days_until(entry.get("certificate_expires", ""))
|
||||
days = _days_until(entry.get("renew", ""))
|
||||
certs.append(
|
||||
{
|
||||
"domain": main,
|
||||
"issuer": entry.get("CA", ""),
|
||||
"expiry": entry.get("certificate_expires", ""),
|
||||
"issuer": entry.get("ca", ""),
|
||||
"expiry": entry.get("renew", ""),
|
||||
"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", ""),
|
||||
"issued_at": entry.get("created", ""),
|
||||
"expires_at": entry.get("renew", ""),
|
||||
"days_until_expiry": days,
|
||||
"auto_renew": _has_auto_renew(main),
|
||||
"san_domains": san_domains,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(
|
||||
"ACME state collection failed, returning empty cert list",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
account = _parse_account_conf()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user