200e078bc5
- Add daemon/ module with aiohttp server, sync client, and handler registry - Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard) - Add system/acme-deploy.py, vacuum-walld sudoers and systemd service - Update API routes to use daemon client instead of lib/ directly - Update lib/, tests/, and webui/ for new architecture - Update docs and deployment scripts
253 lines
8.6 KiB
Python
253 lines
8.6 KiB
Python
"""ACME certificate daemon handler."""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from daemon.server import NotFoundError, registry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
|
_ACME_HOME = PROJECT_DIR / "data" / "acme"
|
|
_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh")
|
|
|
|
_ACME_ENVIRON = {
|
|
"HOME": str(PROJECT_DIR),
|
|
"PATH": os.environ.get(
|
|
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
),
|
|
}
|
|
|
|
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
|
|
|
|
_ACME_TAGS = {"acme"}
|
|
|
|
|
|
def _find_acme() -> str:
|
|
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:
|
|
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 _days_until(date_str: str) -> int | None:
|
|
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_list_output(raw: str) -> list[dict]:
|
|
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 _has_auto_renew(domain: str) -> bool:
|
|
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
|
return bool(Path(acme_home_env) / f"{domain}.conf")
|
|
|
|
|
|
@registry.register("GET", "/acme/list", cache_tags=_ACME_TAGS)
|
|
def list_certs(_request: Any, _body: Any) -> list[dict]:
|
|
raw = _run_acme(["--list"])
|
|
certs: list[dict] = []
|
|
entries = _parse_list_output(raw)
|
|
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
|
acme_home = Path(acme_home_env)
|
|
for entry in entries:
|
|
main = entry["main_domain"]
|
|
if not main:
|
|
continue
|
|
san_domains = [
|
|
d.strip() for d in entry.get("san_domain", "").split(",") if d.strip()
|
|
]
|
|
cert_dir = acme_home / main
|
|
days = _days_until(entry.get("certificate_expires", ""))
|
|
certs.append(
|
|
{
|
|
"domain": main,
|
|
"issuer": entry.get("CA", ""),
|
|
"expiry": entry.get("certificate_expires", ""),
|
|
"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", ""),
|
|
"days_until_expiry": days,
|
|
"auto_renew": _has_auto_renew(main),
|
|
"san_domains": san_domains,
|
|
}
|
|
)
|
|
return certs
|
|
|
|
|
|
@registry.register("GET", "/acme/info", cache_tags=_ACME_TAGS)
|
|
def get_cert_info(_request: Any, body: dict[str, Any] | None) -> dict:
|
|
if not body or "domain" not in body:
|
|
raise ValueError("'domain' is required")
|
|
domain = body["domain"]
|
|
certs = list_certs(None, None)
|
|
for c in certs:
|
|
if c["domain"] == domain or domain in c["san_domains"]:
|
|
return c
|
|
raise NotFoundError(f"No certificate found for domain: {domain}")
|
|
|
|
|
|
@registry.register("POST", "/acme/issue", invalidate=_ACME_TAGS | {"nginx"})
|
|
def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
domain = body.get("domain", "").strip()
|
|
if not domain:
|
|
raise ValueError("'domain' is required")
|
|
webroot = body.get("webroot")
|
|
email = body.get("email", "").strip() or None
|
|
args: list[str] = ["--issue", "-d", domain]
|
|
args.extend(["--webroot", webroot or str(_WEBROOT)])
|
|
contact = email
|
|
if not contact:
|
|
try:
|
|
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
|
account_conf = acme_home / "account.conf"
|
|
if account_conf.is_file():
|
|
text = account_conf.read_text()
|
|
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
|
if match:
|
|
contact = match.group(1).strip().strip("'\"")
|
|
except OSError:
|
|
pass
|
|
if contact:
|
|
args.extend(["-m", contact])
|
|
args.append("--force")
|
|
output = _run_acme(args)
|
|
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
|
|
logger.info("Certificate for %s issued", domain)
|
|
return {"domain": domain, "output": output.strip()}
|
|
|
|
|
|
@registry.register("POST", "/acme/renew", invalidate=_ACME_TAGS | {"nginx"})
|
|
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
domain = body.get("domain", "").strip()
|
|
if not domain:
|
|
raise ValueError("'domain' is required")
|
|
force = body.get("force", False)
|
|
args: list[str] = ["--renew", "-d", domain]
|
|
if force:
|
|
args.append("--force")
|
|
output = _run_acme(args)
|
|
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
|
|
logger.info("Certificate for %s renewed", domain)
|
|
return {"domain": domain, "output": output.strip()}
|
|
|
|
|
|
@registry.register("DELETE", "/acme/remove", invalidate=_ACME_TAGS)
|
|
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
domain = body.get("domain", "").strip()
|
|
if not domain:
|
|
raise ValueError("'domain' is required")
|
|
_run_acme(["--remove", "-d", domain])
|
|
logger.info("Certificate for %s removed", domain)
|
|
return {"domain": domain}
|
|
|
|
|
|
@registry.register("POST", "/acme/email", invalidate=_ACME_TAGS)
|
|
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
|
if not body:
|
|
raise ValueError("Request body required")
|
|
email = body.get("email", "").strip()
|
|
if not email:
|
|
raise ValueError("'email' is required")
|
|
_run_acme(["--register-account", "-m", email])
|
|
logger.info("ACME email set to %s", email)
|
|
return {"email": email}
|
|
|
|
|
|
@registry.register("GET", "/acme/email", cache_tags=_ACME_TAGS)
|
|
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
|
try:
|
|
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
|
account_conf = acme_home / "account.conf"
|
|
if account_conf.is_file():
|
|
text = account_conf.read_text()
|
|
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
|
if match:
|
|
return {"email": match.group(1).strip().strip("'\"")}
|
|
except OSError:
|
|
pass
|
|
return {"email": ""}
|
|
|
|
|
|
@registry.register("GET", "/acme/paths", cache_tags=_ACME_TAGS)
|
|
def get_cert_paths(_request: Any, body: dict[str, Any] | None) -> dict[str, str]:
|
|
if not body or "domain" not in body:
|
|
raise ValueError("'domain' is required")
|
|
domain = body["domain"]
|
|
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
|
acme_home = str(Path(acme_home_env) / domain)
|
|
return {
|
|
"cert": f"{acme_home}/{domain}.cert",
|
|
"key": f"{acme_home}/{domain}.key",
|
|
"ca": f"{acme_home}/ca.cer",
|
|
"fullchain": f"{acme_home}/fullchain.cer",
|
|
}
|