refactor: introduce two-user daemon architecture with socket-based communication
- 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
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
"""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",
|
||||
}
|
||||
Reference in New Issue
Block a user