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 pass
class Conflict(Exception):
"""Raised when the daemon returns HTTP 409."""
pass
_DEFAULT_SOCKET = None _DEFAULT_SOCKET = None
@@ -175,6 +181,8 @@ def request(
raise NotFound(data.get("error", str(exc))) from exc raise NotFound(data.get("error", str(exc))) from exc
if resp.status_code == 400: if resp.status_code == 400:
raise BadRequest(data.get("error", str(exc))) from exc 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 raise RuntimeError(data.get("error", str(exc))) from exc
if not data.get("ok"): if not data.get("ok"):
raise RuntimeError(data.get("error", "Unknown error")) raise RuntimeError(data.get("error", "Unknown error"))
+47 -15
View File
@@ -15,6 +15,7 @@ from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
import lib.acme
import lib.common as lib_common import lib.common as lib_common
from daemon.iface import ( from daemon.iface import (
DELETE_ACME_ACCOUNT_DEACTIVATE, DELETE_ACME_ACCOUNT_DEACTIVATE,
@@ -32,8 +33,8 @@ from daemon.iface import (
POST_ACME_SELF_SIGNED, POST_ACME_SELF_SIGNED,
POST_ACME_VALIDATE, POST_ACME_VALIDATE,
) )
from daemon.server import NotFoundError, refresh_state, registry from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from lib.state import _run_acme from lib.acme import _run_acme
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -55,6 +56,14 @@ _ISSUANCES: dict[str, "IssueRequest"] = {}
_ISSUANCE_TTL = 300 # seconds to keep completed requests _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 @dataclass
class IssueStep: class IssueStep:
"""Single step in a certificate issuance workflow. """Single step in a certificate issuance workflow.
@@ -122,7 +131,7 @@ class IssueRequest:
def _find_acme_bin() -> str: def _find_acme_bin() -> str:
"""Return the path to the acme.sh binary.""" """Return the path to the acme.sh binary."""
from lib.state import _find_acme from lib.acme import _find_acme
return _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]: def _check_existing_cert(domain: str) -> tuple[bool, str]:
"""Warn if a valid cert already exists (not blocking).""" """Warn if a valid cert already exists (not blocking)."""
try: try:
from lib.acme import days_until_expiry days = lib.acme.days_until_expiry(domain)
except (RuntimeError, FileNotFoundError):
days = days_until_expiry(domain) return True, ""
if days is not None and days > 0: if days is not None and days > 0:
return True, f"Valid certificate exists ({days} days remaining)" return True, f"Valid certificate exists ({days} days remaining)"
except (ValueError, RuntimeError, FileNotFoundError):
pass
return True, "" return True, ""
@@ -660,9 +667,23 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
raise ValueError("'domain' is required") raise ValueError("'domain' is required")
domain = body["domain"] domain = body["domain"]
certs = list_certs(None, None) certs = list_certs(None, None)
req = _find_issuance(domain)
for c in certs: for c in certs:
if c["domain"] == domain or domain in c.get("san_domains", []): 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}") 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: Raises:
ValueError: When domain is missing. ValueError: When domain is missing.
RuntimeError: When pre-flight checks fail.
""" """
if not body: if not body:
raise ValueError("Request body required") 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() _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(): for existing in _ISSUANCES.values():
if existing.domain == domain and existing.status == "running": if existing.domain == domain and existing.status == "running":
return { return {
"request_id": existing.request_id, "request_id": existing.request_id,
"status": "existing",
"domain": domain, "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 # Run pre-flight checks
_validate_checks = _validate(domain) _validate_checks = _validate(domain)
if not _validate_checks["ready"]: if not _validate_checks["ready"]:
+11 -3
View File
@@ -159,6 +159,12 @@ class NotFoundError(Exception):
pass pass
class ConflictError(Exception):
"""Raised when a request conflicts with an existing resource."""
pass
def ok(data: Any = None) -> web.Response: def ok(data: Any = None) -> web.Response:
"""Create a success JSON response. """Create a success JSON response.
@@ -247,6 +253,8 @@ async def _handle_request(request: web.Request) -> web.Response:
result = await result result = await result
except NotFoundError as exc: except NotFoundError as exc:
return error(str(exc), 404) return error(str(exc), 404)
except ConflictError as exc:
return error(str(exc), 409)
except ValueError as exc: except ValueError as exc:
return error(str(exc), 400) return error(str(exc), 400)
except RuntimeError as exc: except RuntimeError as exc:
@@ -420,10 +428,10 @@ async def _poll_loop(subsystem: str, interval: int) -> None:
await asyncio.sleep(interval) await asyncio.sleep(interval)
def start_polling() -> None: def start_polling(loop: asyncio.AbstractEventLoop) -> None:
"""Start one poll loop task per subsystem.""" """Start one poll loop task per subsystem."""
for subsystem, interval in _POLL_INTERVALS.items(): 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) task.add_done_callback(_poll_tasks.discard)
_poll_tasks.add(task) _poll_tasks.add(task)
@@ -547,7 +555,7 @@ def main() -> None:
for subsystem in state_store.SUBSYSTEMS: for subsystem in state_store.SUBSYSTEMS:
if state_store.get(subsystem) is not None: if state_store.get(subsystem) is not None:
state_store.bump(subsystem) state_store.bump(subsystem)
loop.run_until_complete(start_polling()) start_polling(loop)
logger.info("vacuum-walld listening on %s", socket_path) logger.info("vacuum-walld listening on %s", socket_path)
logger.info("WebSocket on 127.0.0.1:%d", _WS_PORT) 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 continue
san_domains = [ 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 cert_dir = acme_home / main
@@ -265,21 +265,21 @@ def list_certs() -> list[dict]:
key_path = str(cert_dir / f"{main}.key") key_path = str(cert_dir / f"{main}.key")
ca_path = str(cert_dir / "ca.cer") 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) auto = _has_auto_renew(main)
certs.append( certs.append(
{ {
"domain": main, "domain": main,
"issuer": entry.get("CA", ""), "issuer": entry.get("ca", ""),
"expiry": entry.get("certificate_expires", ""), "expiry": entry.get("renew", ""),
"days_remaining": days, "days_remaining": days,
"expired": days is not None and days <= 0, "expired": days is not None and days <= 0,
"cert_path": cert_path, "cert_path": cert_path,
"key_path": key_path, "key_path": key_path,
"ca_path": ca_path, "ca_path": ca_path,
"issued_at": entry.get("certificate_date", ""), "issued_at": entry.get("created", ""),
"expires_at": entry.get("certificate_expires", ""), "expires_at": entry.get("renew", ""),
"days_until_expiry": days, "days_until_expiry": days,
"auto_renew": auto, "auto_renew": auto,
"san_domains": san_domains, "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]: 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 Handles three formats depending on system capabilities:
whitespace, e.g.:: - 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 All formats share the same header: Main_Domain, KeyLength, SAN_Domains,
Encrypt Certificate_Date:2026-04-01 Certificate_Expired:No Profile, CA, Created, Renew.
Keys are converted to lowercase in the returned dicts.
""" """
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] = [] entries: list[dict] = []
for line in raw.strip().splitlines(): for line in lines[1:]:
line = line.strip() line = line.strip()
if not line: if not line:
continue continue
fields = _split_line(line, separator)
entry: dict[str, str] = {} entry: dict[str, str] = {}
for token in line.split(): for i, h in enumerate(headers):
if ":" not in token: if i < len(fields):
continue entry[h.lower()] = fields[i].strip().strip('"')
key, _, value = token.partition(":")
entry[key.lower()] = value
if entry: if entry:
entries.append(entry) entries.append(entry)
return entries return entries
+39 -129
View File
@@ -7,8 +7,6 @@ state instead of invoking subprocesses on every request.
import contextlib import contextlib
import logging import logging
import os import os
import shutil
import subprocess
from copy import deepcopy from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -28,6 +26,11 @@ logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent 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] = { _DEFAULT_POLL_INTERVALS: dict[str, int] = {
"firewall": 30, "firewall": 30,
"wireguard": 10, "wireguard": 10,
@@ -677,113 +680,25 @@ register_collector("nginx", _collect_nginx)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _find_acme() -> str: def _resolve_ca_name(ca_server: str) -> str:
"""Locate the ``acme.sh`` binary on the filesystem. """Map a CA server identifier to its human-readable name.
Returns: Uses prefix matching sorted by longest prefix first to avoid
Absolute path to the ``acme.sh`` executable. shorter prefixes winning (e.g. "letsencrypt" matching before
"letsencrypt.org").
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.
Args: Args:
args: Command-line arguments to pass after the home/config flags. ca_server: Raw CA server string from acme.sh config.
Returns: Returns:
Combined stdout/stderr output. Human-readable name, or unchanged string if no match.
Raises:
RuntimeError: If acme.sh exits non-zero or times out.
""" """
acme_bin = _find_acme() for prefix, name in sorted(
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme")) _CA_NAME_MAP.items(), key=lambda x: len(x[0]), reverse=True
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args] ):
_ACME_ENVIRON = { if ca_server.startswith(prefix):
"HOME": str(PROJECT_DIR), return name
"PATH": os.environ.get( return ca_server
"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
def _parse_account_conf(acme_home: Path | None = None) -> dict[str, Any]: 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: if not email or not ca_raw:
return default return default
ca_map = { ca = _resolve_ca_name(ca_raw)
"letsencrypt": "Let's Encrypt",
"zerossl": "ZeroSSL",
}
ca = ca_map.get(ca_raw, ca_raw)
return { return {
"registered": True, "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: def _get_acme_email() -> str:
"""Read the ACME ``acme.sh`` email from the account config file. """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]] = [] certs: list[dict[str, Any]] = []
try: try:
from lib.acme import (
_days_until,
_has_auto_renew,
_parse_list_output,
_run_acme,
)
raw = _run_acme(["--list"]) 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_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env) acme_home = Path(acme_home_env)
for entry in entries: for entry in entries:
@@ -891,29 +797,33 @@ def _collect_acme() -> dict[str, Any]:
if not main: if not main:
continue continue
san_domains = [ 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 cert_dir = acme_home / main
days = _days_until(entry.get("certificate_expires", "")) days = _days_until(entry.get("renew", ""))
certs.append( certs.append(
{ {
"domain": main, "domain": main,
"issuer": entry.get("CA", ""), "issuer": entry.get("ca", ""),
"expiry": entry.get("certificate_expires", ""), "expiry": entry.get("renew", ""),
"days_remaining": days, "days_remaining": days,
"expired": days is not None and days <= 0, "expired": days is not None and days <= 0,
"cert_path": str(cert_dir / "fullchain.cer"), "cert_path": str(cert_dir / "fullchain.cer"),
"key_path": str(cert_dir / f"{main}.key"), "key_path": str(cert_dir / f"{main}.key"),
"ca_path": str(cert_dir / "ca.cer"), "ca_path": str(cert_dir / "ca.cer"),
"issued_at": entry.get("certificate_date", ""), "issued_at": entry.get("created", ""),
"expires_at": entry.get("certificate_expires", ""), "expires_at": entry.get("renew", ""),
"days_until_expiry": days, "days_until_expiry": days,
"auto_renew": _has_auto_renew(main), "auto_renew": _has_auto_renew(main),
"san_domains": san_domains, "san_domains": san_domains,
} }
) )
except Exception: except Exception:
pass logger.warning(
"ACME state collection failed, returning empty cert list",
exc_info=True,
)
raise
account = _parse_account_conf() 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/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/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/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 {{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
# Dnsmasq management # Dnsmasq management
+1 -1
View File
@@ -19,7 +19,7 @@ Environment=HOME={{ PROJECT_DIR }}
# Security hardening # Security hardening
ProtectSystem=strict 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 PrivateTmp=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
+40 -7
View File
@@ -61,17 +61,14 @@ class TestRunAcme:
class TestParseListOutput: class TestParseListOutput:
def test_parses_single_entry(self): 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) result = acme._parse_list_output(raw)
assert len(result) == 1 assert len(result) == 1
assert result[0]["main_domain"] == "example.com" assert result[0]["main_domain"] == "example.com"
assert result[0]["ca"] == "LetsEncrypt" assert result[0]["ca"] == "LetsEncrypt"
def test_parses_multiple_entries(self): def test_parses_multiple_entries(self):
raw = ( raw = "Main_Domain\tCA\na.com\tLE\nb.com\tLE\n"
"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"
)
result = acme._parse_list_output(raw) result = acme._parse_list_output(raw)
assert len(result) == 2 assert len(result) == 2
@@ -79,10 +76,46 @@ class TestParseListOutput:
result = acme._parse_list_output("") result = acme._parse_list_output("")
assert result == [] assert result == []
def test_skips_lines_without_colons(self): def test_skips_empty_lines(self):
raw = "some random line\nMain_Domain:a.com" 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) result = acme._parse_list_output(raw)
assert len(result) == 1 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: class TestDaysUntil:
+64
View File
@@ -1,5 +1,6 @@
"""Tests for daemon/handlers/acme.py — handler endpoint logic.""" """Tests for daemon/handlers/acme.py — handler endpoint logic."""
import asyncio
import urllib.error import urllib.error
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -24,8 +25,10 @@ from daemon.handlers.acme import (
deactivate_account, deactivate_account,
generate_self_signed, generate_self_signed,
get_account, get_account,
issue_cert,
register_account, register_account,
) )
from daemon.server import ConflictError
class TestGenerateSelfSigned: class TestGenerateSelfSigned:
@@ -1088,3 +1091,64 @@ class TestDeactivateAccount:
assert not (acme_dir / ".account.conf").is_file() assert not (acme_dir / ".account.conf").is_file()
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 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 ( from daemon.iface import (
DELETE_ACME_ACCOUNT_DEACTIVATE, DELETE_ACME_ACCOUNT_DEACTIVATE,
DELETE_ACME_REMOVE, DELETE_ACME_REMOVE,
@@ -114,6 +114,8 @@ def issue_start():
except BadRequest as exc: except BadRequest as exc:
logger.info("Cert issue for '%s' rejected: %s", domain, exc) logger.info("Cert issue for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400) return _error(str(exc), 400)
except Conflict as exc:
return _error(str(exc), 409)
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to start cert issue for '%s': %s", domain, exc) logger.error("Failed to start cert issue for '%s': %s", domain, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
+6 -1
View File
@@ -236,7 +236,12 @@ function _bindIssueButtons(inner, modalIdx) {
const body = { domain: _currentIssueState.domain }; const body = { domain: _currentIssueState.domain };
const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body }); const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body });
if (issueResp.ok) { 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); closeModal(modalIdx);
const rid = issueResp.data?.request_id; const rid = issueResp.data?.request_id;
if (rid) pollCertIssue(rid); if (rid) pollCertIssue(rid);