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"))
|
||||
|
||||
+45
-13
@@ -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)
|
||||
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)"
|
||||
except (ValueError, RuntimeError, FileNotFoundError):
|
||||
pass
|
||||
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)
|
||||
|
||||
|
||||
+48
-19
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user