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:
2026-06-27 00:38:49 +00:00
parent feaf253403
commit 398831b6e2
11 changed files with 268 additions and 176 deletions
+8
View File
@@ -35,6 +35,12 @@ class BadRequest(Exception):
pass
class Conflict(Exception):
"""Raised when the daemon returns HTTP 409."""
pass
_DEFAULT_SOCKET = None
@@ -175,6 +181,8 @@ def request(
raise NotFound(data.get("error", str(exc))) from exc
if resp.status_code == 400:
raise BadRequest(data.get("error", str(exc))) from exc
if resp.status_code == 409:
raise Conflict(data.get("error", str(exc))) from exc
raise RuntimeError(data.get("error", str(exc))) from exc
if not data.get("ok"):
raise RuntimeError(data.get("error", "Unknown error"))
+47 -15
View File
@@ -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"]:
+11 -3
View File
@@ -159,6 +159,12 @@ class NotFoundError(Exception):
pass
class ConflictError(Exception):
"""Raised when a request conflicts with an existing resource."""
pass
def ok(data: Any = None) -> web.Response:
"""Create a success JSON response.
@@ -247,6 +253,8 @@ async def _handle_request(request: web.Request) -> web.Response:
result = await result
except NotFoundError as exc:
return error(str(exc), 404)
except ConflictError as exc:
return error(str(exc), 409)
except ValueError as exc:
return error(str(exc), 400)
except RuntimeError as exc:
@@ -420,10 +428,10 @@ async def _poll_loop(subsystem: str, interval: int) -> None:
await asyncio.sleep(interval)
def start_polling() -> None:
def start_polling(loop: asyncio.AbstractEventLoop) -> None:
"""Start one poll loop task per subsystem."""
for subsystem, interval in _POLL_INTERVALS.items():
task = asyncio.create_task(_poll_loop(subsystem, interval))
task = loop.create_task(_poll_loop(subsystem, interval))
task.add_done_callback(_poll_tasks.discard)
_poll_tasks.add(task)
@@ -547,7 +555,7 @@ def main() -> None:
for subsystem in state_store.SUBSYSTEMS:
if state_store.get(subsystem) is not None:
state_store.bump(subsystem)
loop.run_until_complete(start_polling())
start_polling(loop)
logger.info("vacuum-walld listening on %s", socket_path)
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT)