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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user