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:
+47
-15
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import lib.acme
|
||||
import lib.common as lib_common
|
||||
from daemon.iface import (
|
||||
DELETE_ACME_ACCOUNT_DEACTIVATE,
|
||||
@@ -32,8 +33,8 @@ from daemon.iface import (
|
||||
POST_ACME_SELF_SIGNED,
|
||||
POST_ACME_VALIDATE,
|
||||
)
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.state import _run_acme
|
||||
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
|
||||
from lib.acme import _run_acme
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,6 +56,14 @@ _ISSUANCES: dict[str, "IssueRequest"] = {}
|
||||
_ISSUANCE_TTL = 300 # seconds to keep completed requests
|
||||
|
||||
|
||||
def _find_issuance(domain: str) -> "IssueRequest | None":
|
||||
"""Find an active (running) issuance request by domain."""
|
||||
for req in _ISSUANCES.values():
|
||||
if req.domain == domain and req.status == "running":
|
||||
return req
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class IssueStep:
|
||||
"""Single step in a certificate issuance workflow.
|
||||
@@ -122,7 +131,7 @@ class IssueRequest:
|
||||
|
||||
def _find_acme_bin() -> str:
|
||||
"""Return the path to the acme.sh binary."""
|
||||
from lib.state import _find_acme
|
||||
from lib.acme import _find_acme
|
||||
|
||||
return _find_acme()
|
||||
|
||||
@@ -332,13 +341,11 @@ def _check_challenge_config() -> tuple[bool, str]:
|
||||
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
|
||||
days = lib.acme.days_until_expiry(domain)
|
||||
except (RuntimeError, FileNotFoundError):
|
||||
return True, ""
|
||||
if days is not None and days > 0:
|
||||
return True, f"Valid certificate exists ({days} days remaining)"
|
||||
return True, ""
|
||||
|
||||
|
||||
@@ -660,9 +667,23 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
|
||||
raise ValueError("'domain' is required")
|
||||
domain = body["domain"]
|
||||
certs = list_certs(None, None)
|
||||
req = _find_issuance(domain)
|
||||
|
||||
for c in certs:
|
||||
if c["domain"] == domain or domain in c.get("san_domains", []):
|
||||
return c
|
||||
result = dict(c)
|
||||
if req:
|
||||
result["issuance"] = req.to_dict()
|
||||
return result
|
||||
|
||||
# No cert found — check if there's an in-progress issuance
|
||||
if req:
|
||||
return {
|
||||
"domain": domain,
|
||||
"status": "issuing",
|
||||
"issuance": req.to_dict(),
|
||||
}
|
||||
|
||||
raise NotFoundError(f"No certificate found for domain: {domain}")
|
||||
|
||||
|
||||
@@ -689,7 +710,6 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
|
||||
Raises:
|
||||
ValueError: When domain is missing.
|
||||
RuntimeError: When pre-flight checks fail.
|
||||
"""
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -707,16 +727,28 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
|
||||
_clean_expired_issuances()
|
||||
|
||||
# Dedup: if domain already has an active request, return existing ID
|
||||
# Dedup: if domain already has an active request, return it
|
||||
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",
|
||||
"status": "existing",
|
||||
}
|
||||
|
||||
# Check if cert already exists — call acme.sh directly, not via state
|
||||
try:
|
||||
certs = lib.acme.list_certs()
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(f"Cannot check existing certificates: {exc}") from exc
|
||||
for c in certs:
|
||||
if c["domain"] == domain or domain in c.get("san_domains", []):
|
||||
days = c.get("days_until_expiry")
|
||||
if days is not None and days >= 0:
|
||||
raise ConflictError(
|
||||
f"Certificate already exists for {domain} ({days} day{'s' if days != 1 else ''} remaining). Renew instead."
|
||||
)
|
||||
|
||||
# Run pre-flight checks
|
||||
_validate_checks = _validate(domain)
|
||||
if not _validate_checks["ready"]:
|
||||
|
||||
Reference in New Issue
Block a user