refactor: overhaul daemon server, client, and handlers
This commit is contained in:
+89
-42
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user