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