diff --git a/daemon/client.py b/daemon/client.py index e0e2b04..679349b 100644 --- a/daemon/client.py +++ b/daemon/client.py @@ -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")) diff --git a/daemon/handlers/acme.py b/daemon/handlers/acme.py index d411458..8f751b2 100644 --- a/daemon/handlers/acme.py +++ b/daemon/handlers/acme.py @@ -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"]: diff --git a/daemon/server.py b/daemon/server.py index 7107dfb..57688a4 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -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) diff --git a/lib/acme.py b/lib/acme.py index 1851509..51116f8 100644 --- a/lib/acme.py +++ b/lib/acme.py @@ -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 diff --git a/lib/state.py b/lib/state.py index 5b2248b..31df1a7 100644 --- a/lib/state.py +++ b/lib/state.py @@ -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() diff --git a/system/sudoers.d/vacuum-walld b/system/sudoers.d/vacuum-walld index 10b8a11..2c10a76 100644 --- a/system/sudoers.d/vacuum-walld +++ b/system/sudoers.d/vacuum-walld @@ -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 diff --git a/system/systemd/vacuum-walld.service b/system/systemd/vacuum-walld.service index 4758f5b..a9674b5 100644 --- a/system/systemd/vacuum-walld.service +++ b/system/systemd/vacuum-walld.service @@ -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 diff --git a/tests/test_acme.py b/tests/test_acme.py index 35c7a97..07fa812 100644 --- a/tests/test_acme.py +++ b/tests/test_acme.py @@ -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: diff --git a/tests/test_handler_acme.py b/tests/test_handler_acme.py index 006d1cb..5aa8731 100644 --- a/tests/test_handler_acme.py +++ b/tests/test_handler_acme.py @@ -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() diff --git a/webui/api/certs.py b/webui/api/certs.py index 550ee67..b02ba16 100644 --- a/webui/api/certs.py +++ b/webui/api/certs.py @@ -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) diff --git a/webui/static/pages/certs.js b/webui/static/pages/certs.js index aa46c28..49b962c 100644 --- a/webui/static/pages/certs.js +++ b/webui/static/pages/certs.js @@ -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);