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)
+48 -19
View File
@@ -257,7 +257,7 @@ def list_certs() -> list[dict]:
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
@@ -265,21 +265,21 @@ def list_certs() -> list[dict]:
key_path = str(cert_dir / f"{main}.key")
ca_path = str(cert_dir / "ca.cer")
days = _days_until(entry.get("certificate_expires", ""))
days = _days_until(entry.get("renew", ""))
auto = _has_auto_renew(main)
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": cert_path,
"key_path": key_path,
"ca_path": ca_path,
"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": auto,
"san_domains": san_domains,
@@ -437,28 +437,57 @@ def deploy(domain: str) -> None:
# ------------------------------------------------------------------
def _split_line(line: str, separator: str | None) -> list[str]:
"""Split a line by *separator*, falling back to whitespace for column output."""
if separator in line:
return line.split(separator)
return line.split()
def _parse_list_output(raw: str) -> list[dict]:
"""Parse the text output from ``acme.sh --list`` into a list of dicts.
"""Parse output from ``acme.sh --list`` into a list of dicts.
Each line in the output contains ``Key:Value`` tokens separated by
whitespace, e.g.::
Handles three formats depending on system capabilities:
- Raw pipe-separated output (``|``)
- Tab-separated output (when ``column`` is unavailable)
- Column-aligned output (when ``column`` is available)
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.
All formats share the same header: Main_Domain, KeyLength, SAN_Domains,
Profile, CA, Created, Renew.
"""
lines = raw.strip().splitlines()
if len(lines) < 2:
return []
header_line = lines[0]
# Detect separator from header: pipe, tab, or whitespace
if "|" in header_line:
headers = _split_line(header_line, "|")
separator = "|"
elif "\t" in header_line:
headers = _split_line(header_line, "\t")
separator = "\t"
else:
headers = _split_line(header_line, None) # whitespace
separator = None
if "Main_Domain" not in headers:
raise ValueError(
f"acme.sh --list output is not in expected format: {header_line!r}"
)
entries: list[dict] = []
for line in raw.strip().splitlines():
for line in lines[1:]:
line = line.strip()
if not line:
continue
fields = _split_line(line, separator)
entry: dict[str, str] = {}
for token in line.split():
if ":" not in token:
continue
key, _, value = token.partition(":")
entry[key.lower()] = value
for i, h in enumerate(headers):
if i < len(fields):
entry[h.lower()] = fields[i].strip().strip('"')
if entry:
entries.append(entry)
return entries
+39 -129
View File
@@ -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()
+1
View File
@@ -14,6 +14,7 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/snippets/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
# Dnsmasq management
+1 -1
View File
@@ -19,7 +19,7 @@ Environment=HOME={{ PROJECT_DIR }}
# Security hardening
ProtectSystem=strict
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld /run/nginx
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
+40 -7
View File
@@ -61,17 +61,14 @@ class TestRunAcme:
class TestParseListOutput:
def test_parses_single_entry(self):
raw = "Main_Domain:example.com CA:LetsEncrypt Certificate_Date:2026-04-01 Certificate_Expires:2026-07-01 Certificate_Expired:No"
raw = "Main_Domain\tCA\nexample.com\tLetsEncrypt\n"
result = acme._parse_list_output(raw)
assert len(result) == 1
assert result[0]["main_domain"] == "example.com"
assert result[0]["ca"] == "LetsEncrypt"
def test_parses_multiple_entries(self):
raw = (
"Main_Domain:a.com CA:LE Certificate_Expires:2026-07-01 Certificate_Expired:No\n"
"Main_Domain:b.com CA:LE Certificate_Expires:2026-08-01 Certificate_Expired:No"
)
raw = "Main_Domain\tCA\na.com\tLE\nb.com\tLE\n"
result = acme._parse_list_output(raw)
assert len(result) == 2
@@ -79,10 +76,46 @@ class TestParseListOutput:
result = acme._parse_list_output("")
assert result == []
def test_skips_lines_without_colons(self):
raw = "some random line\nMain_Domain:a.com"
def test_skips_empty_lines(self):
raw = "Main_Domain\tCA\nexample.com\tLE\n\n \nother.com\tLE\n"
result = acme._parse_list_output(raw)
assert len(result) == 2
def test_header_skipped(self):
"""Header row is not included in results."""
raw = "Main_Domain\tKeyLength\tCA\nexample.com\tec-256\tZeroSSL.com\n"
result = acme._parse_list_output(raw)
assert len(result) == 1
assert result[0]["main_domain"] == "example.com"
def test_field_mapping(self):
"""Tab columns are mapped to lowercased header names as dict keys."""
raw = (
"Main_Domain\tKeyLength\tSAN_Domains\tProfile\tCA\tCreated\tRenew\n"
'example.com\t"ec-256"\twww.example.com\t\tZeroSSL.com\t2026-01-01\t2026-07-01\n'
)
result = acme._parse_list_output(raw)
assert len(result) == 1
entry = result[0]
assert entry["main_domain"] == "example.com"
assert entry["keylength"] == "ec-256"
assert entry["san_domains"] == "www.example.com"
assert entry["profile"] == ""
assert entry["ca"] == "ZeroSSL.com"
assert entry["created"] == "2026-01-01"
assert entry["renew"] == "2026-07-01"
def test_quoted_values_stripped(self):
"""Quoted values have quotes removed."""
raw = 'Main_Domain\tKeyLength\nexample.com\t"ec-256"\n'
result = acme._parse_list_output(raw)
assert result[0]["keylength"] == "ec-256"
def test_header_only(self):
"""Header with no data rows returns empty list."""
raw = "Main_Domain\tKeyLength\tCA\n"
result = acme._parse_list_output(raw)
assert result == []
class TestDaysUntil:
+64
View File
@@ -1,5 +1,6 @@
"""Tests for daemon/handlers/acme.py — handler endpoint logic."""
import asyncio
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -24,8 +25,10 @@ from daemon.handlers.acme import (
deactivate_account,
generate_self_signed,
get_account,
issue_cert,
register_account,
)
from daemon.server import ConflictError
class TestGenerateSelfSigned:
@@ -1088,3 +1091,64 @@ class TestDeactivateAccount:
assert not (acme_dir / ".account.conf").is_file()
assert not (acme_dir / "account.conf").is_file()
class TestIssueCertExistingCerts:
"""Phase 3: issue_cert blocks when cert expires today (days == 0) or tomorrow (days == 1)."""
def test_days_zero_blocks(self):
"""days_until_expiry returns 0 (expires today) — should block."""
with (
patch(
"daemon.handlers.acme._validate",
return_value={"ready": True, "checks": []},
),
patch(
"lib.acme.list_certs",
return_value=[{"domain": "example.com", "days_until_expiry": 0}],
),
pytest.raises(ConflictError, match="0 days remaining"),
):
asyncio.run(issue_cert(None, {"domain": "example.com"}))
def test_days_one_blocks(self):
"""days_until_expiry returns 1 (expires tomorrow) — should still block."""
with (
patch(
"daemon.handlers.acme._validate",
return_value={"ready": True, "checks": []},
),
patch(
"lib.acme.list_certs",
return_value=[{"domain": "example.com", "days_until_expiry": 1}],
),
pytest.raises(ConflictError, match="1 day remaining"),
):
asyncio.run(issue_cert(None, {"domain": "example.com"}))
def test_days_negative_one_allows(self):
"""days_until_expiry returns -1 (already expired) — should not block."""
async def _fake_run_issue(req):
pass
with (
patch(
"daemon.handlers.acme._validate",
return_value={"ready": True, "checks": []},
),
patch(
"lib.acme.list_certs",
return_value=[{"domain": "example.com", "days_until_expiry": -1}],
),
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch(
"daemon.handlers.acme._run_issue",
new=MagicMock(side_effect=_fake_run_issue),
) as mock_run_issue,
):
result = asyncio.run(issue_cert(None, {"domain": "example.com"}))
assert result["domain"] == "example.com"
assert "request_id" in result
mock_run_issue.assert_called_once()
+3 -1
View File
@@ -7,7 +7,7 @@ import logging
from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, post
from daemon.client import BadRequest, Conflict, NotFound, delete, get, post
from daemon.iface import (
DELETE_ACME_ACCOUNT_DEACTIVATE,
DELETE_ACME_REMOVE,
@@ -114,6 +114,8 @@ def issue_start():
except BadRequest as exc:
logger.info("Cert issue for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
except Conflict as exc:
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to start cert issue for '%s': %s", domain, exc)
return _error(str(exc), 500)
+6 -1
View File
@@ -236,7 +236,12 @@ function _bindIssueButtons(inner, modalIdx) {
const body = { domain: _currentIssueState.domain };
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
if (issueResp.ok) {
toast('Issuance started for ' + _currentIssueState.domain, 'success');
const status = issueResp.data?.status;
if (status === 'existing') {
toast('Issuance already in progress for ' + _currentIssueState.domain, 'warning');
} else {
toast('Issuance started for ' + _currentIssueState.domain, 'success');
}
closeModal(modalIdx);
const rid = issueResp.data?.request_id;
if (rid) pollCertIssue(rid);