refactor: overhaul daemon server, client, and handlers

This commit is contained in:
2026-06-16 03:35:58 +00:00
parent c5813d68b3
commit 4fc0fb3f72
10 changed files with 683 additions and 195 deletions
+89 -42
View File
@@ -12,7 +12,21 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from daemon.iface import (
DELETE_ACME_REMOVE,
GET_ACME_EMAIL,
GET_ACME_INFO,
GET_ACME_ISSUE_STATUS,
GET_ACME_LIST,
GET_ACME_PATHS,
POST_ACME_EMAIL,
POST_ACME_ISSUE,
POST_ACME_RENEW,
POST_ACME_SELF_SIGNED,
POST_ACME_VALIDATE,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.state import _run_acme
logger = logging.getLogger(__name__)
@@ -99,38 +113,6 @@ class IssueRequest:
# Internal helpers
def _run_acme(args: list[str]) -> str:
"""Execute an acme.sh command and return combined output.
Returns:
Standard output (plus stderr).
Raises:
RuntimeError: On timeout or non-zero exit.
"""
from lib.state import _find_acme
acme_bin = _find_acme()
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
cmd = [acme_bin, "--home", acme_home_env, "--config-home", acme_home_env, *args]
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 _find_acme_bin() -> str:
"""Return the path to the acme.sh binary."""
from lib.state import _find_acme
@@ -326,14 +308,14 @@ def _validate(domain: str) -> dict[str, Any]:
# Routes — status reads from state, mutations call refresh_state
@registry.register("GET", "/acme/list")
@registry.register(GET_ACME_LIST)
def list_certs(_request: Any, _body: Any) -> list[dict]:
"""GET /acme/list — return managed certificates."""
ac = _get_acme_state()
return ac.get("certs", [])
@registry.register("GET", "/acme/info")
@registry.register(GET_ACME_INFO)
def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
"""GET /acme/info — return details for a single domain certificate.
@@ -351,7 +333,7 @@ def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
raise NotFoundError(f"No certificate found for domain: {domain}")
@registry.register("POST", "/acme/validate")
@registry.register(POST_ACME_VALIDATE)
def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/validate — run pre-flight checks for a domain.
@@ -366,7 +348,7 @@ def validate_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return _validate(domain)
@registry.register("POST", "/acme/issue")
@registry.register(POST_ACME_ISSUE)
async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/issue — create a new certificate issuance request.
@@ -429,7 +411,7 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"request_id": request_id, "domain": domain}
@registry.register("GET", "/acme/issue/status")
@registry.register(GET_ACME_ISSUE_STATUS)
def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /acme/issue/status — poll status of an issuance request.
@@ -498,7 +480,7 @@ async def _run_issue(req: IssueRequest) -> None:
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
@registry.register("POST", "/acme/renew")
@registry.register(POST_ACME_RENEW)
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/renew — renew a certificate for the given domain.
@@ -524,7 +506,7 @@ def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain, "output": output.strip()}
@registry.register("DELETE", "/acme/remove")
@registry.register(DELETE_ACME_REMOVE)
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /acme/remove — remove a certificate from ACME management.
@@ -542,7 +524,7 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"domain": domain}
@registry.register("POST", "/acme/email")
@registry.register(POST_ACME_EMAIL)
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/email — set the ACME contact email via account registration.
@@ -570,7 +552,7 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
return {"email": email}
@registry.register("GET", "/acme/email")
@registry.register(GET_ACME_EMAIL)
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /acme/email — return the currently configured ACME contact email."""
ac = _get_acme_state()
@@ -582,7 +564,7 @@ def get_email(_request: Any, _body: Any) -> dict[str, Any]:
return {"email": email}
@registry.register("GET", "/acme/paths")
@registry.register(GET_ACME_PATHS)
def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]:
"""GET /acme/paths — return filesystem paths for a domain's certificate files.
@@ -600,3 +582,68 @@ def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]
"ca": f"{acme_home}/ca.cer",
"fullchain": f"{acme_home}/fullchain.cer",
}
@registry.register(POST_ACME_SELF_SIGNED)
def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/self-signed — generate a self-signed certificate for a domain.
Idempotent: skips generation if cert and key already exist.
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
if not domain:
raise ValueError("'domain' is required")
days = body.get("days", 365)
cert_dir = _ACME_HOME / domain
cert_dir.mkdir(parents=True, exist_ok=True)
cert_file = cert_dir / "fullchain.cer"
key_file = cert_dir / f"{domain}.key"
if cert_file.is_file() and key_file.is_file():
logger.info("Self-signed cert for %s already exists, skipping", domain)
return {
"domain": domain,
"cert": str(cert_file),
"key": str(key_file),
"generated": False,
}
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
str(key_file),
"-out",
str(cert_file),
"-days",
str(days),
"-nodes",
"-subj",
f"/CN={domain}",
],
capture_output=True,
text=True,
check=True,
timeout=30,
)
cert_file.chmod(0o644)
key_file.chmod(0o600)
logger.info("Self-signed cert for %s generated (%d days)", domain, days)
return {
"domain": domain,
"cert": str(cert_file),
"key": str(key_file),
"generated": True,
}