feat: pre-computed state store and async ACME issuance (fixes timeout mismatch)
- Add lib/state.py: in-memory state store with subsystem collectors (firewall, dnsmasq, nginx, acme, wireguard) - Refactor all handlers: read from state on GET, call refresh_state() after mutations instead of invoking subprocesses per request - daemon/server.py: add refresh_state(), /status/all, /status/refresh; populate state at startup - webui/api/certs.py: async step-by-step ACME issuance (validate, issue with request_id, poll status) replacing blocking endpoint - webui/server.py: render pages from state instead of direct lib calls - Update templates, JS for async cert issuance with polling UI - Update tests for state-based mocking; add test_state.py - Fix SIM105 lint issue (contextlib.suppress) - Add TODO.md with certificate issuance issue tracking Resolves: WebUI 30s timeout freeze during cert issuance (Problem 1)
This commit is contained in:
+374
-107
@@ -1,15 +1,19 @@
|
||||
"""ACME certificate daemon handler."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from daemon.server import NotFoundError, registry
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,21 +30,56 @@ _ACME_ENVIRON = {
|
||||
|
||||
_WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
|
||||
|
||||
_ACME_TAGS = {"acme"}
|
||||
# In-memory store for active issuance requests.
|
||||
_ISSUANCES: dict[str, "IssueRequest"] = {}
|
||||
_ISSUANCE_TTL = 300 # seconds to keep completed requests
|
||||
|
||||
|
||||
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")
|
||||
@dataclass
|
||||
class IssueStep:
|
||||
name: str
|
||||
label: str
|
||||
status: str = "pending"
|
||||
message: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class IssueRequest:
|
||||
request_id: str
|
||||
domain: str
|
||||
email: str | None = None
|
||||
webroot: str | None = None
|
||||
steps: list[IssueStep] = field(default_factory=list)
|
||||
status: str = "running"
|
||||
created_at: float = field(default_factory=lambda: datetime.now(UTC).timestamp())
|
||||
expires_at: float | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"request_id": self.request_id,
|
||||
"domain": self.domain,
|
||||
"status": self.status,
|
||||
"steps": [
|
||||
{
|
||||
"name": s.name,
|
||||
"label": s.label,
|
||||
"status": s.status,
|
||||
"message": s.message,
|
||||
}
|
||||
for s in self.steps
|
||||
],
|
||||
"created_at": self.created_at,
|
||||
"expires_at": self.expires_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
|
||||
|
||||
def _run_acme(args: list[str]) -> str:
|
||||
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]
|
||||
@@ -62,126 +101,348 @@ def _run_acme(args: list[str]) -> str:
|
||||
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
|
||||
def _find_acme_bin() -> str:
|
||||
from lib.state import _find_acme
|
||||
|
||||
return _find_acme()
|
||||
|
||||
|
||||
def _get_acme_email() -> str:
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y%m%d%H%M%z").astimezone(UTC)
|
||||
return (dt - datetime.now(UTC)).days
|
||||
except ValueError:
|
||||
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 match.group(1).strip().strip("'\"")
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
return ""
|
||||
|
||||
|
||||
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 _get_state() -> dict[str, Any] | None:
|
||||
from lib.state import state as state_store
|
||||
|
||||
return state_store.get("acme")
|
||||
|
||||
|
||||
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")
|
||||
def _get_acme_state() -> dict[str, Any]:
|
||||
ac = _get_state()
|
||||
if ac is None:
|
||||
return {}
|
||||
return ac
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
def _clean_expired_issuances() -> None:
|
||||
"""Remove completed requests older than TTL."""
|
||||
now = datetime.now(UTC).timestamp()
|
||||
expired = [
|
||||
rid
|
||||
for rid, req in _ISSUANCES.items()
|
||||
if req.expires_at and now > req.expires_at
|
||||
]
|
||||
for rid in expired:
|
||||
del _ISSUANCES[rid]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation helpers
|
||||
|
||||
|
||||
def _check_domain_format(domain: str) -> tuple[bool, str]:
|
||||
"""Validate basic domain name format."""
|
||||
import re as _re
|
||||
|
||||
pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$"
|
||||
if not _re.match(pattern, domain):
|
||||
return False, "Invalid domain name format"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _check_dns_resolves(domain: str) -> tuple[bool, str]:
|
||||
"""Check that domain resolves to this machine's IP via A record."""
|
||||
try:
|
||||
results = socket.getaddrinfo(domain, 80, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
if not results:
|
||||
return False, "Domain does not resolve to any address"
|
||||
|
||||
local_ips = set()
|
||||
hostname = socket.gethostname()
|
||||
with suppress(OSError):
|
||||
local_ips.add(socket.gethostbyname(hostname))
|
||||
# Also collect all interface IPs
|
||||
try:
|
||||
import ipaddress
|
||||
from fcntl import ioctl
|
||||
|
||||
def get_interfaces():
|
||||
import struct
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
names = b"\x00" * 4096
|
||||
raw = ioctl(s.fileno(), 0x8912, names)
|
||||
s.close()
|
||||
ifaces = []
|
||||
for i in range(0, 4096, 32):
|
||||
name = raw[i : i + 16].split(b"\x00")[0].decode()
|
||||
if name == "lo":
|
||||
continue
|
||||
addr = struct.unpack("<I", raw[i + 16 : i + 20])[0]
|
||||
ifaces.append(str(ipaddress.IPv4Address(addr)))
|
||||
return ifaces
|
||||
|
||||
local_ips.update(get_interfaces())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved = False
|
||||
for _, _, _, _, addr in results:
|
||||
if addr in local_ips:
|
||||
resolved = True
|
||||
break
|
||||
|
||||
if resolved:
|
||||
return True, "DNS resolves correctly"
|
||||
return (
|
||||
False,
|
||||
f"Domain resolves to {results[0][4][0]}, not this server",
|
||||
)
|
||||
return certs
|
||||
except socket.gaierror:
|
||||
return False, "Domain does not resolve (NXDOMAIN or timeout)"
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/info", cache_tags=_ACME_TAGS)
|
||||
def _check_acme_installed() -> tuple[bool, str]:
|
||||
try:
|
||||
_find_acme_bin()
|
||||
return True, "acme.sh found"
|
||||
except FileNotFoundError:
|
||||
return False, "acme.sh not installed"
|
||||
|
||||
|
||||
def _check_email_configured() -> tuple[bool, str]:
|
||||
email = _get_acme_email() or ""
|
||||
if email:
|
||||
return True, f"Contact email configured: {email}"
|
||||
return False, "No ACME contact email configured"
|
||||
|
||||
|
||||
def _check_webroot() -> tuple[bool, str]:
|
||||
if _WEBROOT.is_dir() and os.access(str(_WEBROOT), os.W_OK):
|
||||
return True, "ACME webroot ready"
|
||||
return False, "ACME webroot not ready or not writable"
|
||||
|
||||
|
||||
def _check_challenge_config() -> tuple[bool, str]:
|
||||
from lib.nginx import SITES_DIR
|
||||
|
||||
site_conf = SITES_DIR / "_acme-challenge.conf" if SITES_DIR else None
|
||||
if site_conf and site_conf.is_file():
|
||||
return True, "ACME challenge nginx config present"
|
||||
return False, "ACME challenge nginx config missing"
|
||||
|
||||
|
||||
def _check_existing_cert(domain: str) -> tuple[bool, str]:
|
||||
"""Warn if a valid cert already exists (not blocking)."""
|
||||
try:
|
||||
from lib.acme import days_until_expiry
|
||||
|
||||
days = days_until_expiry(domain)
|
||||
if days is not None and days > 0:
|
||||
return True, f"Valid certificate exists ({days} days remaining)"
|
||||
except (ValueError, RuntimeError, FileNotFoundError):
|
||||
pass
|
||||
return True, ""
|
||||
|
||||
|
||||
def _validate(domain: str) -> dict[str, Any]:
|
||||
"""Run all pre-checks for a domain. Returns structured results."""
|
||||
checks: list[dict[str, Any]] = []
|
||||
ready = True
|
||||
|
||||
check_fns = [
|
||||
("acme_installed", _check_acme_installed, True),
|
||||
("email_configured", _check_email_configured, True),
|
||||
("webroot_ready", _check_webroot, True),
|
||||
("challenge_configured", _check_challenge_config, True),
|
||||
("domain_format", lambda: _check_domain_format(domain), True),
|
||||
("dns_resolves", lambda: _check_dns_resolves(domain), True),
|
||||
("existing_cert", lambda: _check_existing_cert(domain), False),
|
||||
]
|
||||
|
||||
for name, fn, blocking in check_fns:
|
||||
try:
|
||||
passed, msg = fn()
|
||||
checks.append(
|
||||
{"name": name, "passed": passed, "message": msg, "blocking": blocking}
|
||||
)
|
||||
if not passed and blocking:
|
||||
ready = False
|
||||
except Exception as exc:
|
||||
checks.append(
|
||||
{
|
||||
"name": name,
|
||||
"passed": False,
|
||||
"message": str(exc),
|
||||
"blocking": blocking,
|
||||
}
|
||||
)
|
||||
ready = False
|
||||
|
||||
return {"domain": domain, "checks": checks, "ready": ready}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes — status reads from state, mutations call refresh_state
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/list")
|
||||
def list_certs(_request: Any, _body: Any) -> list[dict]:
|
||||
ac = _get_acme_state()
|
||||
return ac.get("certs", [])
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/info")
|
||||
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"]:
|
||||
if c["domain"] == domain or domain in c.get("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]:
|
||||
@registry.register("POST", "/acme/validate")
|
||||
def validate_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")
|
||||
return _validate(domain)
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/issue")
|
||||
async 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()}
|
||||
webroot = body.get("webroot")
|
||||
|
||||
_clean_expired_issuances()
|
||||
|
||||
# Dedup: if domain already has an active request, return existing ID
|
||||
for existing in _ISSUANCES.values():
|
||||
if existing.domain == domain and existing.status == "running":
|
||||
return {
|
||||
"request_id": existing.request_id,
|
||||
"status": "existing",
|
||||
"domain": domain,
|
||||
"message": "Issuance already in progress for this domain",
|
||||
}
|
||||
|
||||
# Run pre-flight checks
|
||||
_validate_checks = _validate(domain)
|
||||
if not _validate_checks["ready"]:
|
||||
failed = [
|
||||
c["name"]
|
||||
for c in _validate_checks["checks"]
|
||||
if not c["passed"] and c["blocking"]
|
||||
]
|
||||
raise RuntimeError(f"Pre-flight checks failed: {', '.join(failed)}")
|
||||
|
||||
# Create tracked request
|
||||
request_id = uuid4().hex[:12]
|
||||
steps = [
|
||||
IssueStep(name="issue", label="Issuing certificate"),
|
||||
IssueStep(name="deploy", label="Registering deploy hook"),
|
||||
IssueStep(name="refresh", label="Refreshing certificate state"),
|
||||
]
|
||||
|
||||
req = IssueRequest(
|
||||
request_id=request_id,
|
||||
domain=domain,
|
||||
email=email,
|
||||
webroot=webroot,
|
||||
steps=steps,
|
||||
)
|
||||
_ISSUANCES[request_id] = req
|
||||
|
||||
# Spawn background task
|
||||
_task = asyncio.create_task(_run_issue(req)) # noqa: RUF006 — task runs to completion on its own
|
||||
|
||||
return {"request_id": request_id, "domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/renew", invalidate=_ACME_TAGS | {"nginx"})
|
||||
@registry.register("GET", "/acme/issue/status")
|
||||
def get_issuance_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
request_id = (body or {}).get("id", "").strip()
|
||||
if not request_id:
|
||||
raise ValueError("'id' is required")
|
||||
|
||||
req = _ISSUANCES.get(request_id)
|
||||
if not req:
|
||||
raise NotFoundError(f"Issuance request {request_id} not found")
|
||||
|
||||
return req.to_dict()
|
||||
|
||||
|
||||
async def _run_issue(req: IssueRequest) -> None:
|
||||
"""Background task: run acme.sh steps, update step status."""
|
||||
try:
|
||||
# Step 1: issue
|
||||
req.steps[0].status = "running"
|
||||
args: list[str] = ["--issue", "-d", req.domain]
|
||||
args.extend(["--webroot", req.webroot or str(_WEBROOT)])
|
||||
contact = req.email
|
||||
if not contact:
|
||||
contact = _get_acme_email()
|
||||
if contact:
|
||||
args.extend(["-m", contact])
|
||||
args.append("--force")
|
||||
output = _run_acme(args)
|
||||
req.steps[0].status = "done"
|
||||
req.steps[0].message = output.strip()[:200]
|
||||
|
||||
# Step 2: deploy
|
||||
req.steps[1].status = "running"
|
||||
_run_acme(["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK])
|
||||
req.steps[1].status = "done"
|
||||
req.steps[1].message = "Deploy hook registered"
|
||||
|
||||
# Step 3: refresh state
|
||||
req.steps[2].status = "running"
|
||||
refresh_state(["acme"])
|
||||
req.steps[2].status = "done"
|
||||
req.steps[2].message = "State refreshed"
|
||||
|
||||
req.status = "completed"
|
||||
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
|
||||
logger.info(
|
||||
"Certificate for %s issued (request %s)", req.domain, req.request_id
|
||||
)
|
||||
except Exception as exc:
|
||||
# Mark current running step as error, overall as failed
|
||||
for step in req.steps:
|
||||
if step.status == "running":
|
||||
step.status = "error"
|
||||
step.message = str(exc)
|
||||
break
|
||||
else:
|
||||
req.steps.append(
|
||||
IssueStep(name="error", label="Error", status="error", message=str(exc))
|
||||
)
|
||||
req.status = "failed"
|
||||
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
|
||||
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/renew")
|
||||
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -195,10 +456,11 @@ def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
output = _run_acme(args)
|
||||
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
|
||||
logger.info("Certificate for %s renewed", domain)
|
||||
refresh_state(["acme"])
|
||||
return {"domain": domain, "output": output.strip()}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/acme/remove", invalidate=_ACME_TAGS)
|
||||
@registry.register("DELETE", "/acme/remove")
|
||||
def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -207,10 +469,11 @@ def remove_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raise ValueError("'domain' is required")
|
||||
_run_acme(["--remove", "-d", domain])
|
||||
logger.info("Certificate for %s removed", domain)
|
||||
refresh_state(["acme"])
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/acme/email", invalidate=_ACME_TAGS)
|
||||
@registry.register("POST", "/acme/email")
|
||||
def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -219,11 +482,15 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raise ValueError("'email' is required")
|
||||
_run_acme(["--register-account", "-m", email])
|
||||
logger.info("ACME email set to %s", email)
|
||||
refresh_state(["acme"])
|
||||
return {"email": email}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/email", cache_tags=_ACME_TAGS)
|
||||
@registry.register("GET", "/acme/email")
|
||||
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
ac = _get_acme_state()
|
||||
if ac:
|
||||
return {"email": ac.get("email", "")}
|
||||
try:
|
||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
||||
account_conf = acme_home / "account.conf"
|
||||
@@ -237,7 +504,7 @@ def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return {"email": ""}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/paths", cache_tags=_ACME_TAGS)
|
||||
@registry.register("GET", "/acme/paths")
|
||||
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")
|
||||
|
||||
+52
-82
@@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.server import NotFoundError, registry
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,7 +33,11 @@ DEFAULT_CFG: dict[str, Any] = {
|
||||
"dns": {"upstreams": ["8.8.8.8", "1.1.1.1"], "domain": None, "custom_records": []},
|
||||
}
|
||||
|
||||
_DNSMASQ_TAGS = {"dnsmasq"}
|
||||
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
from lib.state import state as state_store
|
||||
|
||||
return state_store.get("dnsmasq")
|
||||
|
||||
|
||||
def _get_config() -> dict[str, Any]:
|
||||
@@ -66,66 +70,46 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _parse_lease_line(line: str) -> dict[str, Any] | None:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
try:
|
||||
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
|
||||
except (ValueError, OSError):
|
||||
ts = None
|
||||
return {
|
||||
"expires_at": ts,
|
||||
"mac": parts[1],
|
||||
"ip": parts[2],
|
||||
"hostname": parts[3] if len(parts) > 3 else "",
|
||||
"interface": parts[4] if len(parts) > 4 else "",
|
||||
}
|
||||
def _get_dnsmasq_state() -> dict[str, Any]:
|
||||
dm = _get_state()
|
||||
if dm is None:
|
||||
return {}
|
||||
return dm
|
||||
|
||||
|
||||
def _get_lease_table() -> list[dict[str, Any]]:
|
||||
leases: list[dict[str, Any]] = []
|
||||
try:
|
||||
result = run_proc(
|
||||
["cat", LEASE_FILE],
|
||||
sudo=True,
|
||||
check=True,
|
||||
)
|
||||
for entry in map(_parse_lease_line, result.stdout.splitlines()):
|
||||
if entry is not None:
|
||||
leases.append(entry)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return leases
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/config", cache_tags=_DNSMASQ_TAGS)
|
||||
@registry.register("GET", "/dnsmasq/config")
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
dm = _get_dnsmasq_state()
|
||||
if dm:
|
||||
return dm.get("config", {})
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/config", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("POST", "/dnsmasq/config")
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
_save_config(body)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/dnsmasq/config", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("PATCH", "/dnsmasq/config")
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/apply", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("POST", "/dnsmasq/apply")
|
||||
def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
conf_text = _generate_conf(cfg)
|
||||
@@ -139,44 +123,19 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
)
|
||||
run(["systemctl", "reload", "dnsmasq"], sudo=True)
|
||||
logger.info("dnsmasq config written and reloaded")
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/status", cache_tags=_DNSMASQ_TAGS)
|
||||
@registry.register("GET", "/dnsmasq/status")
|
||||
def get_status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
try:
|
||||
proc = run_proc(
|
||||
["systemctl", "is-active", "dnsmasq"], sudo=True
|
||||
)
|
||||
active = proc.stdout.strip() == "active"
|
||||
except Exception:
|
||||
active = False
|
||||
conf_exists = Path(DNSMASQ_CONF).is_file()
|
||||
conf_on_disk = ""
|
||||
if conf_exists:
|
||||
try:
|
||||
with open(DNSMASQ_CONF) as f:
|
||||
conf_on_disk = f.read()
|
||||
except PermissionError:
|
||||
pass
|
||||
expected = _generate_conf(cfg)
|
||||
leases = _get_lease_table()
|
||||
return {
|
||||
"service_active": active,
|
||||
"config_file_exists": conf_exists,
|
||||
"config_in_sync": conf_on_disk == expected,
|
||||
"dhcp_ranges": len(cfg["dhcp"]["ranges"]),
|
||||
"static_leases": len(cfg["dhcp"]["static_leases"]),
|
||||
"custom_dns_records": len(cfg["dns"]["custom_records"]),
|
||||
"upstreams": cfg["dns"]["upstreams"],
|
||||
"domain": cfg["dns"].get("domain"),
|
||||
"active_leases": len(leases),
|
||||
"leases": leases,
|
||||
}
|
||||
dm = _get_dnsmasq_state()
|
||||
if dm and "status" in dm:
|
||||
return dm["status"]
|
||||
return {}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/ranges/add", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("POST", "/dnsmasq/ranges/add")
|
||||
def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -216,10 +175,11 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
entry["dns"] = body["dns"]
|
||||
ranges.append(entry)
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/ranges/remove", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("DELETE", "/dnsmasq/ranges/remove")
|
||||
def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -245,15 +205,19 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
|
||||
)
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"interface": iface, "start": start, "end": end}
|
||||
|
||||
|
||||
@registry.register("GET", "/dnsmasq/leases", cache_tags=_DNSMASQ_TAGS)
|
||||
@registry.register("GET", "/dnsmasq/leases")
|
||||
def get_leases(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return _get_lease_table()
|
||||
dm = _get_dnsmasq_state()
|
||||
if dm:
|
||||
return dm.get("leases", [])
|
||||
return []
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/static-lease/add", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("POST", "/dnsmasq/static-lease/add")
|
||||
def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -270,16 +234,18 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
if hostname is not None:
|
||||
leases[i]["hostname"] = hostname
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
entry: dict[str, Any] = {"mac": mac, "ip": ip}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
leases.append(entry)
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"mac": mac, "ip": ip, "hostname": hostname}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/static-lease/remove", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("DELETE", "/dnsmasq/static-lease/remove")
|
||||
def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -295,10 +261,11 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
if len(cfg["dhcp"]["static_leases"]) == before:
|
||||
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"mac": mac}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/dns-record/add", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("POST", "/dnsmasq/dns-record/add")
|
||||
def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -315,16 +282,18 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
if hostname is not None:
|
||||
records[i]["hostname"] = hostname
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"name": name, "address": address, "hostname": hostname}
|
||||
entry: dict[str, Any] = {"name": name, "address": address}
|
||||
if hostname:
|
||||
entry["hostname"] = hostname
|
||||
records.append(entry)
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"name": name, "address": address, "hostname": hostname}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/dnsmasq/dns-record/remove", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("DELETE", "/dnsmasq/dns-record/remove")
|
||||
def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -334,26 +303,26 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
cfg = _get_config()
|
||||
records = cfg["dns"]["custom_records"]
|
||||
before = len(records)
|
||||
cfg["dns"]["custom_records"] = [
|
||||
r for r in records if r["name"] != name
|
||||
]
|
||||
cfg["dns"]["custom_records"] = [r for r in records if r["name"] != name]
|
||||
if len(cfg["dns"]["custom_records"]) == before:
|
||||
raise NotFoundError(f"DNS record '{name}' not found")
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/upstreams", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("POST", "/dnsmasq/upstreams")
|
||||
def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "servers" not in body:
|
||||
raise ValueError("'servers' is required")
|
||||
cfg = _get_config()
|
||||
cfg["dns"]["upstreams"] = list(body["servers"])
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"upstreams": cfg["dns"]["upstreams"]}
|
||||
|
||||
|
||||
@registry.register("POST", "/dnsmasq/domain", invalidate=_DNSMASQ_TAGS)
|
||||
@registry.register("POST", "/dnsmasq/domain")
|
||||
def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -361,4 +330,5 @@ def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
cfg["dns"]["domain"] = domain if domain else None
|
||||
_save_config(cfg)
|
||||
refresh_state(["dnsmasq"])
|
||||
return {"domain": cfg["dns"]["domain"]}
|
||||
|
||||
+106
-217
@@ -1,28 +1,21 @@
|
||||
"""Firewall daemon handler.
|
||||
|
||||
Executes firewall-cmd and ip commands with sudo, returns structured results.
|
||||
Parsing helpers are imported from lib.firewall.
|
||||
Reads from the pre-computed state for status endpoints. Executes
|
||||
firewall-cmd with sudo for mutations. Refers state after each mutation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from daemon.server import NotFoundError, registry
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import load_json, run, save_json
|
||||
from lib.firewall import (
|
||||
_normalize_target,
|
||||
_parse_active_zones,
|
||||
_parse_zone_output,
|
||||
)
|
||||
from lib.firewall import (
|
||||
config_pending as _config_pending,
|
||||
)
|
||||
from lib.firewall import (
|
||||
get_config as _get_lib_config,
|
||||
)
|
||||
from lib.firewall import (
|
||||
save_backup as _save_backup,
|
||||
)
|
||||
@@ -30,16 +23,16 @@ from lib.firewall import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
DATA_DIR = PROJECT_DIR / "data" / "firewall"
|
||||
RULES_FILE = DATA_DIR / "rules.json"
|
||||
CONFIG_DIR = PROJECT_DIR / "config" / "firewall"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
DEFAULT_CONFIG = {"zones": {}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
"""Return the current firewall state from the state store."""
|
||||
from lib.state import state as state_store
|
||||
|
||||
return state_store.get("firewall")
|
||||
|
||||
|
||||
def _ensure_config_file() -> None:
|
||||
@@ -62,10 +55,6 @@ def _reload() -> None:
|
||||
run(["firewall-cmd", "--reload"], sudo=True)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _fp_to_str(fp: dict[str, Any]) -> str:
|
||||
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
|
||||
if "toaddr" in fp:
|
||||
@@ -85,92 +74,22 @@ def _get_forward_ports(zone_name: str) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _get_state() -> dict[str, Any]:
|
||||
"""Return the complete current state of firewalld."""
|
||||
zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||
active = _parse_active_zones(active_raw)
|
||||
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
|
||||
link_out = run(["ip", "-o", "link", "show"], sudo=True)
|
||||
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
|
||||
|
||||
iface_map: dict[str, dict[str, Any]] = {}
|
||||
for line in link_out.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
raw_name = parts[1].rstrip(":")
|
||||
state = "UNKNOWN"
|
||||
mtu = None
|
||||
mac = None
|
||||
for i, p in enumerate(parts):
|
||||
if p == "state" and i + 1 < len(parts):
|
||||
state = parts[i + 1]
|
||||
if p == "mtu" and i + 1 < len(parts):
|
||||
mtu = int(parts[i + 1])
|
||||
if p.startswith("link/ether") and i + 1 < len(parts):
|
||||
mac = parts[i + 1]
|
||||
iface_map[raw_name] = {
|
||||
"name": raw_name,
|
||||
"display_name": raw_name.partition("@")[0],
|
||||
"mac": mac,
|
||||
"state": state,
|
||||
"mtu": mtu,
|
||||
"ips": [],
|
||||
"ipv6": [],
|
||||
"zone": None,
|
||||
}
|
||||
|
||||
for line in addr_out.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
addr_name = parts[1]
|
||||
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
|
||||
for entry in iface_map.values():
|
||||
if entry["display_name"] == addr_name:
|
||||
entry[addr_key].append(parts[3])
|
||||
break
|
||||
|
||||
for zone_name, ifaces in active.items():
|
||||
for raw_if in ifaces:
|
||||
clean = raw_if.partition("@")[0]
|
||||
for entry in iface_map.values():
|
||||
if entry["display_name"] == clean or entry["name"] == raw_if:
|
||||
entry["zone"] = zone_name
|
||||
break
|
||||
|
||||
ifaces = list(iface_map.values())
|
||||
|
||||
zones: dict[str, dict[str, Any]] = {}
|
||||
for zn in zone_names:
|
||||
try:
|
||||
zones[zn] = _parse_zone_output(
|
||||
zn, run(["firewall-cmd", f"--zone={zn}", "--list-all"], sudo=True)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {
|
||||
"active_zones": active,
|
||||
"interfaces": ifaces,
|
||||
"available_services": services,
|
||||
"zones": zones,
|
||||
"rich_rules": {n: z.get("rich-rules", []) for n, z in zones.items()},
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
def _config_apply() -> dict[str, Any]:
|
||||
"""Apply the declarative config to live firewalld."""
|
||||
from lib.firewall import get_config as _get_lib_config
|
||||
|
||||
cfg = _get_lib_config()
|
||||
cfg_zones = cfg.get("zones", {})
|
||||
|
||||
_save_backup(_get_state())
|
||||
full_state: dict[str, Any] = {
|
||||
"active_zones": {},
|
||||
"interfaces": [],
|
||||
"available_services": [],
|
||||
"zones": {},
|
||||
"rich_rules": {},
|
||||
"timestamp": "",
|
||||
}
|
||||
_save_backup(full_state)
|
||||
|
||||
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
applied: list[str] = []
|
||||
@@ -314,7 +233,15 @@ def _config_apply() -> dict[str, Any]:
|
||||
applied.append(zone_name)
|
||||
|
||||
_reload()
|
||||
backup_path = _save_backup(_get_state())
|
||||
full_state = {
|
||||
"active_zones": {},
|
||||
"interfaces": [],
|
||||
"available_services": [],
|
||||
"zones": {},
|
||||
"rich_rules": {},
|
||||
"timestamp": "",
|
||||
}
|
||||
backup_path = _save_backup(full_state)
|
||||
logger.info("Firewall config applied to %d zones", len(applied))
|
||||
return {
|
||||
"applied_zones": applied,
|
||||
@@ -323,114 +250,67 @@ def _config_apply() -> dict[str, Any]:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# Routes — GET endpoints read from state, mutations call refresh_state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_READ_TAGS = {"firewall", "interfaces", "zones"}
|
||||
|
||||
def _get_fw_state() -> dict[str, Any]:
|
||||
fw = _get_state()
|
||||
if fw is None:
|
||||
return {}
|
||||
return fw
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/interfaces", cache_tags=_READ_TAGS)
|
||||
@registry.register("GET", "/firewall/interfaces")
|
||||
def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
link_out = run(["ip", "-o", "link", "show"], sudo=True)
|
||||
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
|
||||
zones_out = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||
|
||||
iface_map: dict[str, dict[str, Any]] = {}
|
||||
for line in link_out.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
raw_name = parts[1].rstrip(":")
|
||||
state = "UNKNOWN"
|
||||
mtu = None
|
||||
mac = None
|
||||
for i, p in enumerate(parts):
|
||||
if p == "state" and i + 1 < len(parts):
|
||||
state = parts[i + 1]
|
||||
if p == "mtu" and i + 1 < len(parts):
|
||||
mtu = int(parts[i + 1])
|
||||
if p.startswith("link/ether") and i + 1 < len(parts):
|
||||
mac = parts[i + 1]
|
||||
iface_map[raw_name] = {
|
||||
"name": raw_name,
|
||||
"display_name": raw_name.partition("@")[0],
|
||||
"mac": mac,
|
||||
"state": state,
|
||||
"mtu": mtu,
|
||||
"ips": [],
|
||||
"ipv6": [],
|
||||
"zone": None,
|
||||
}
|
||||
|
||||
for line in addr_out.splitlines():
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
addr_name = parts[1]
|
||||
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
|
||||
for entry in iface_map.values():
|
||||
if entry["display_name"] == addr_name:
|
||||
entry[addr_key].append(parts[3])
|
||||
break
|
||||
|
||||
active = _parse_active_zones(zones_out)
|
||||
for zone_name, ifaces in active.items():
|
||||
for raw_if in ifaces:
|
||||
clean = raw_if.partition("@")[0]
|
||||
for entry in iface_map.values():
|
||||
if entry["display_name"] == clean or entry["name"] == raw_if:
|
||||
entry["zone"] = zone_name
|
||||
break
|
||||
|
||||
return list(iface_map.values())
|
||||
fw = _get_fw_state()
|
||||
return fw.get("interfaces", [])
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones", cache_tags=_READ_TAGS)
|
||||
@registry.register("GET", "/firewall/zones")
|
||||
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
active = _parse_active_zones(run(["firewall-cmd", "--get-active-zones"], sudo=True))
|
||||
return {"active": active, "available": available}
|
||||
fw = _get_fw_state()
|
||||
active = fw.get("active_zones", {})
|
||||
zones = fw.get("zones", {})
|
||||
return {"active": active, "available": list(zones.keys())}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones/info", cache_tags=_READ_TAGS)
|
||||
@registry.register("GET", "/firewall/zones/info")
|
||||
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
zone = body["zone"]
|
||||
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
||||
fw = _get_fw_state()
|
||||
zones = fw.get("zones", {})
|
||||
if zone not in zones:
|
||||
raise NotFoundError(f"Zone '{zone}' does not exist")
|
||||
raw = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
||||
return _parse_zone_output(zone, raw)
|
||||
return zones[zone]
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/zones/all", cache_tags=_READ_TAGS)
|
||||
@registry.register("GET", "/firewall/zones/all")
|
||||
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
active = _parse_active_zones(run(["firewall-cmd", "--get-active-zones"], sudo=True))
|
||||
fw = _get_fw_state()
|
||||
active = fw.get("active_zones", {})
|
||||
zones = fw.get("zones", {})
|
||||
result: list[dict[str, Any]] = []
|
||||
for zone_name in active:
|
||||
try:
|
||||
raw = run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True)
|
||||
result.append(_parse_zone_output(zone_name, raw))
|
||||
except Exception:
|
||||
continue
|
||||
if zone_name in zones:
|
||||
result.append(zones[zone_name])
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/services", cache_tags=_READ_TAGS)
|
||||
@registry.register("GET", "/firewall/services")
|
||||
def get_services(_request: Any, _body: Any) -> list[str]:
|
||||
return run(["firewall-cmd", "--get-services"], sudo=True).split()
|
||||
fw = _get_fw_state()
|
||||
return fw.get("available_services", [])
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/config", cache_tags=_READ_TAGS)
|
||||
@registry.register("GET", "/firewall/config")
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/config", invalidate=_READ_TAGS)
|
||||
@registry.register("POST", "/firewall/config")
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "zones" not in body:
|
||||
raise ValueError("'zones' key is required")
|
||||
@@ -438,10 +318,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
raise ValueError("'zones' must be a dict")
|
||||
_save_config(body)
|
||||
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
|
||||
refresh_state(["firewall"])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/firewall/config", invalidate=_READ_TAGS)
|
||||
@registry.register("PATCH", "/firewall/config")
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body must be a JSON object")
|
||||
@@ -451,22 +332,25 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
logger.info("Firewall config patched: %s", sorted(body.keys()))
|
||||
refresh_state(["firewall"])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/config/pending", cache_tags=_READ_TAGS)
|
||||
def config_pending(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _config_pending(_get_state())
|
||||
@registry.register("GET", "/firewall/config/pending")
|
||||
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
fw = _get_fw_state()
|
||||
return fw.get("pending", {})
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/config/apply", invalidate=_READ_TAGS)
|
||||
@registry.register("POST", "/firewall/config/apply")
|
||||
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
result = _config_apply()
|
||||
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
|
||||
refresh_state(["firewall"])
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/create", invalidate=_READ_TAGS)
|
||||
@registry.register("POST", "/firewall/zones/create")
|
||||
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -488,10 +372,11 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
)
|
||||
_reload()
|
||||
logger.info("Zone '%s' created (target=%s)", zone_name, target)
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone_name}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/zones/delete", invalidate=_READ_TAGS)
|
||||
@registry.register("DELETE", "/firewall/zones/delete")
|
||||
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
@@ -502,10 +387,11 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
|
||||
_reload()
|
||||
logger.info("Zone '%s' deleted", zone)
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/interfaces", invalidate=_READ_TAGS)
|
||||
@registry.register("POST", "/firewall/zones/interfaces")
|
||||
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -544,10 +430,11 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
)
|
||||
_reload()
|
||||
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone, "interfaces": interfaces}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/zones/services", invalidate=_READ_TAGS)
|
||||
@registry.register("POST", "/firewall/zones/services")
|
||||
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -582,10 +469,11 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
|
||||
sudo=True,
|
||||
)
|
||||
_reload()
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone, "services": services}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/rich-rules/add", invalidate=_READ_TAGS)
|
||||
@registry.register("POST", "/firewall/rich-rules/add")
|
||||
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -595,7 +483,6 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
raise ValueError("'zone' and 'rule' are required")
|
||||
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
||||
raise NotFoundError(f"Zone '{zone}' does not exist")
|
||||
from uuid import uuid4
|
||||
|
||||
run(
|
||||
[
|
||||
@@ -613,10 +500,11 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
entry = {"id": rule_id, "rule": rule}
|
||||
cfg["zones"][zone]["rich_rules"].append(entry)
|
||||
_save_config(cfg)
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone, "id": rule_id, "rule": rule}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/rich-rules/remove", invalidate=_READ_TAGS)
|
||||
@registry.register("DELETE", "/firewall/rich-rules/remove")
|
||||
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -650,34 +538,22 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
|
||||
]
|
||||
_save_config(cfg)
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone, "id": rule_id}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/rich-rules", cache_tags=_READ_TAGS)
|
||||
@registry.register("GET", "/firewall/rich-rules")
|
||||
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
if not body or "zone" not in body:
|
||||
raise ValueError("'zone' is required")
|
||||
zone = body["zone"]
|
||||
raw = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True)
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return []
|
||||
rules: list[str] = []
|
||||
current: list[str] = []
|
||||
for line in raw.splitlines():
|
||||
r = line.rstrip()
|
||||
if not r.endswith(";"):
|
||||
current.append(r)
|
||||
else:
|
||||
current.append(r)
|
||||
rules.append(" ".join(current))
|
||||
current = []
|
||||
if current:
|
||||
rules.append(" ".join(current))
|
||||
fw = _get_fw_state()
|
||||
rich_rules = fw.get("rich_rules", {})
|
||||
cfg = _get_config()
|
||||
cfg_entries = cfg.get("zones", {}).get(zone, {}).get("rich_rules", [])
|
||||
result: list[dict[str, Any]] = []
|
||||
for rule_str in rules:
|
||||
zone_rules = rich_rules.get(zone, [])
|
||||
for rule_str in zone_rules:
|
||||
matched = next((e for e in cfg_entries if e.get("rule") == rule_str), None)
|
||||
if matched:
|
||||
result.append({"id": matched["id"], "rule": rule_str})
|
||||
@@ -686,7 +562,7 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/masquerade", invalidate=_READ_TAGS)
|
||||
@registry.register("POST", "/firewall/masquerade")
|
||||
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -697,10 +573,11 @@ def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
|
||||
action = "--add-masquerade" if enable else "--remove-masquerade"
|
||||
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
|
||||
_reload()
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone, "masquerade": bool(enable)}
|
||||
|
||||
|
||||
@registry.register("POST", "/firewall/forward-port/add", invalidate=_READ_TAGS)
|
||||
@registry.register("POST", "/firewall/forward-port/add")
|
||||
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -711,7 +588,6 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
toport = body.get("toport")
|
||||
if not zone or port is None or not proto:
|
||||
raise ValueError("'zone', 'port', and 'proto' are required")
|
||||
from uuid import uuid4
|
||||
|
||||
fwd = f"port={port}/proto={proto}"
|
||||
if toaddr and toport:
|
||||
@@ -740,10 +616,11 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
|
||||
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
|
||||
cfg["zones"][zone]["forward_ports"].append(entry)
|
||||
_save_config(cfg)
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/firewall/forward-port/remove", invalidate=_READ_TAGS)
|
||||
@registry.register("DELETE", "/firewall/forward-port/remove")
|
||||
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -752,7 +629,8 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
proto = body.get("proto", "").strip()
|
||||
if not zone or port is None or not proto:
|
||||
raise ValueError("'zone', 'port', and 'proto' are required")
|
||||
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
||||
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
if zone not in available:
|
||||
raise NotFoundError(f"Zone '{zone}' does not exist")
|
||||
fwd = f"port={port}/proto={proto}"
|
||||
cfg = _get_config()
|
||||
@@ -785,9 +663,20 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
|
||||
]
|
||||
_save_config(cfg)
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone, "port": int(port), "proto": proto}
|
||||
|
||||
|
||||
@registry.register("GET", "/firewall/state", cache_tags=_READ_TAGS)
|
||||
@registry.register("GET", "/firewall/state")
|
||||
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return _get_state()
|
||||
fw = _get_state()
|
||||
if fw is None:
|
||||
return {}
|
||||
return {
|
||||
"active_zones": fw.get("active_zones", {}),
|
||||
"interfaces": fw.get("interfaces", []),
|
||||
"available_services": fw.get("available_services", []),
|
||||
"zones": fw.get("zones", {}),
|
||||
"rich_rules": fw.get("rich_rules", {}),
|
||||
"timestamp": fw.get("timestamp", ""),
|
||||
}
|
||||
|
||||
+8
-10
@@ -12,11 +12,9 @@ from lib.common import run_proc
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
APP_LOG_FILE = PROJECT_DIR / "data" / "logs" / "vacuum-wall.log"
|
||||
_APP_LOG_FILE = PROJECT_DIR / "data" / "logs" / "vacuum-wall.log"
|
||||
_MAX_LINES = 200
|
||||
|
||||
_LOG_TAGS = {"logs"}
|
||||
|
||||
|
||||
def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
|
||||
try:
|
||||
@@ -36,7 +34,7 @@ def _tail_file(path: str, n: int = _MAX_LINES, sudo: bool = False) -> str:
|
||||
def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
|
||||
try:
|
||||
result = run_proc(
|
||||
["journalctl", "-u", unit, "--no-pager", "-n", str(n)],
|
||||
["journalctl", "--unit=" + unit, "-n", str(n)],
|
||||
sudo=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
@@ -47,26 +45,26 @@ def _sudo_journalctl(unit: str, n: int = _MAX_LINES) -> str:
|
||||
return f"(error reading journal: {exc})\n"
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/journal", cache_tags=_LOG_TAGS)
|
||||
@registry.register("GET", "/logs/journal")
|
||||
def journal(_request, _body) -> str:
|
||||
return _sudo_journalctl("vacuum-wall")
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/nginx/access", cache_tags=_LOG_TAGS)
|
||||
@registry.register("GET", "/logs/nginx/access")
|
||||
def nginx_access(_request, _body) -> str:
|
||||
return _tail_file("/var/log/nginx/access.log", sudo=True)
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/nginx/error", cache_tags=_LOG_TAGS)
|
||||
@registry.register("GET", "/logs/nginx/error")
|
||||
def nginx_error(_request, _body) -> str:
|
||||
return _tail_file("/var/log/nginx/error.log", sudo=True)
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/dnsmasq", cache_tags=_LOG_TAGS)
|
||||
@registry.register("GET", "/logs/dnsmasq")
|
||||
def dnsmasq_log(_request, _body) -> str:
|
||||
return _sudo_journalctl("dnsmasq")
|
||||
|
||||
|
||||
@registry.register("GET", "/logs/app", cache_tags=_LOG_TAGS)
|
||||
@registry.register("GET", "/logs/app")
|
||||
def app_log(_request, _body) -> str:
|
||||
return _tail_file(str(APP_LOG_FILE))
|
||||
return _tail_file(str(_APP_LOG_FILE))
|
||||
|
||||
+45
-32
@@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.server import NotFoundError, registry
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,7 +48,11 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"ssl": {**DEFAULT_SSL},
|
||||
}
|
||||
|
||||
_NGINX_TAGS = {"nginx"}
|
||||
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
from lib.state import state as state_store
|
||||
|
||||
return state_store.get("nginx")
|
||||
|
||||
|
||||
def _get_config() -> dict[str, Any]:
|
||||
@@ -122,9 +126,7 @@ def _write_ssl_snippet() -> None:
|
||||
|
||||
|
||||
def _test_config() -> tuple[bool, str]:
|
||||
result = run_proc(
|
||||
["nginx", "-t"], sudo=True, check=False
|
||||
)
|
||||
result = run_proc(["nginx", "-t"], sudo=True, check=False)
|
||||
ok = result.returncode == 0
|
||||
output = (result.stderr or result.stdout or "").strip()
|
||||
if not output and ok:
|
||||
@@ -133,9 +135,7 @@ def _test_config() -> tuple[bool, str]:
|
||||
|
||||
|
||||
def _reload_nginx() -> None:
|
||||
result = run_proc(
|
||||
["nginx", "-s", "reload"], sudo=True, check=False
|
||||
)
|
||||
result = run_proc(["nginx", "-s", "reload"], sudo=True, check=False)
|
||||
if result.returncode != 0:
|
||||
logger.error("nginx reload failed: %s", result.stderr.strip())
|
||||
else:
|
||||
@@ -210,20 +210,35 @@ def _write_htpasswd(user: str, password: str) -> None:
|
||||
os.replace(tmp, HTPASSWD_FILE)
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/config", cache_tags=_NGINX_TAGS)
|
||||
def _get_nginx_state() -> dict[str, Any]:
|
||||
ng = _get_state()
|
||||
if ng is None:
|
||||
return {}
|
||||
return ng
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/config")
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
ng = _get_nginx_state()
|
||||
if ng:
|
||||
return ng.get("config", {})
|
||||
return _get_config()
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/config", invalidate=_NGINX_TAGS)
|
||||
@registry.register("POST", "/nginx/config")
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
_save_config(body)
|
||||
refresh_state(["nginx"])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/nginx/config", invalidate=_NGINX_TAGS)
|
||||
@registry.register("PATCH", "/nginx/config")
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -232,27 +247,19 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
refresh_state(["nginx"])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/nginx/domains", cache_tags=_NGINX_TAGS)
|
||||
@registry.register("GET", "/nginx/domains")
|
||||
def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
cfg = _get_config()
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, dom in cfg.get("domains", {}).items():
|
||||
site = SITES_DIR / f"{name}.conf"
|
||||
result.append(
|
||||
{
|
||||
"domain": name,
|
||||
"backend": dom.get("backend", {}),
|
||||
"online": site.exists(),
|
||||
"force_ssl": dom.get("force_ssl", True),
|
||||
}
|
||||
)
|
||||
return result
|
||||
ng = _get_nginx_state()
|
||||
if ng:
|
||||
return ng.get("domains", [])
|
||||
return []
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/domains/add", invalidate=_NGINX_TAGS)
|
||||
@registry.register("POST", "/nginx/domains/add")
|
||||
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -285,10 +292,11 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
entry["headers"] = extra_headers
|
||||
cfg["domains"][domain] = entry
|
||||
_save_config(cfg)
|
||||
refresh_state(["nginx"])
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("DELETE", "/nginx/domains/remove", invalidate=_NGINX_TAGS)
|
||||
@registry.register("DELETE", "/nginx/domains/remove")
|
||||
def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -303,10 +311,11 @@ def remove_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
site = SITES_DIR / f"{domain}.conf"
|
||||
if site.exists():
|
||||
site.unlink()
|
||||
refresh_state(["nginx"])
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/domains/update", invalidate=_NGINX_TAGS)
|
||||
@registry.register("POST", "/nginx/domains/update")
|
||||
def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -324,10 +333,11 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
else:
|
||||
entry[key] = val
|
||||
_save_config(cfg)
|
||||
refresh_state(["nginx"])
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/apply", invalidate=_NGINX_TAGS)
|
||||
@registry.register("POST", "/nginx/apply")
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
_write_ssl_snippet()
|
||||
_write_all_sites()
|
||||
@@ -336,22 +346,24 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
if not ok:
|
||||
raise RuntimeError(f"nginx config test failed: {msg}")
|
||||
_reload_nginx()
|
||||
refresh_state(["nginx"])
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/test", invalidate=_NGINX_TAGS)
|
||||
@registry.register("POST", "/nginx/test")
|
||||
def test(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
valid, output = _test_config()
|
||||
return {"valid": valid, "output": output}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/ssl-apply", invalidate=_NGINX_TAGS)
|
||||
@registry.register("POST", "/nginx/ssl-apply")
|
||||
def ssl_apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
_write_ssl_snippet()
|
||||
refresh_state(["nginx"])
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/nginx/management", invalidate=_NGINX_TAGS)
|
||||
@registry.register("POST", "/nginx/management")
|
||||
def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -373,6 +385,7 @@ def set_management_proxy(_request: Any, body: dict[str, Any] | None) -> dict[str
|
||||
_save_config(cfg)
|
||||
if auth_user and auth_pass:
|
||||
_write_htpasswd(auth_user, auth_pass)
|
||||
refresh_state(["nginx"])
|
||||
return {"domain": domain}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from daemon.server import NotFoundError, registry
|
||||
from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import deep_merge, load_json, run, run_proc, save_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,7 +40,11 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"peers": {},
|
||||
}
|
||||
|
||||
_WG_TAGS = {"wireguard"}
|
||||
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
from lib.state import state as state_store
|
||||
|
||||
return state_store.get("wireguard")
|
||||
|
||||
|
||||
def _get_config() -> dict[str, Any]:
|
||||
@@ -60,8 +64,22 @@ def _generate_conf(cfg: dict[str, Any]) -> str:
|
||||
)
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/config", cache_tags=_WG_TAGS)
|
||||
def _get_wg_state() -> dict[str, Any]:
|
||||
wg = _get_state()
|
||||
if wg is None:
|
||||
return {}
|
||||
return wg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/config")
|
||||
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
return wg.get("config", {})
|
||||
cfg = _get_config()
|
||||
safe = dict(cfg)
|
||||
if "interface" in safe:
|
||||
@@ -70,7 +88,7 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
return safe
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/config", invalidate=_WG_TAGS)
|
||||
@registry.register("POST", "/wireguard/config")
|
||||
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -83,10 +101,11 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
if current_key:
|
||||
body.setdefault("interface", {})["private_key"] = current_key
|
||||
_save_config(body)
|
||||
refresh_state(["wireguard"])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("PATCH", "/wireguard/config", invalidate=_WG_TAGS)
|
||||
@registry.register("PATCH", "/wireguard/config")
|
||||
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -97,10 +116,11 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
current = _get_config()
|
||||
merged = deep_merge(current, body)
|
||||
_save_config(merged)
|
||||
refresh_state(["wireguard"])
|
||||
return {"config_saved": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/apply", invalidate=_WG_TAGS)
|
||||
@registry.register("POST", "/wireguard/apply")
|
||||
def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
conf_text = _generate_conf(cfg)
|
||||
@@ -116,90 +136,29 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
local_tmp.unlink(missing_ok=True)
|
||||
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
|
||||
refresh_state(["wireguard"])
|
||||
return {"applied": True}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/down", invalidate=_WG_TAGS)
|
||||
@registry.register("POST", "/wireguard/down")
|
||||
def down(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
run([WG_QUICK_BIN, "down", name], sudo=True)
|
||||
logger.info("WireGuard tunnel '%s' brought down", name)
|
||||
refresh_state(["wireguard"])
|
||||
return {"down": True}
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/status", cache_tags=_WG_TAGS)
|
||||
@registry.register("GET", "/wireguard/status")
|
||||
def status(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
name = cfg["interface"]["name"]
|
||||
result: dict[str, Any] = {"up": False, "interface": {}, "peers": []}
|
||||
try:
|
||||
res = run_proc([WG_BIN, "show", name], sudo=True, check=False)
|
||||
if res.returncode != 0:
|
||||
return result
|
||||
raw = res.stdout.strip()
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
current_peer: dict[str, Any] | None = None
|
||||
peers: list[dict[str, Any]] = []
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("interface:"):
|
||||
result["up"] = True
|
||||
result["interface"] = {}
|
||||
current_peer = None
|
||||
continue
|
||||
if line.startswith("public key:"):
|
||||
result["interface"]["public_key"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
if line.startswith("listening port:"):
|
||||
result["interface"]["listen_port"] = int(line.split(":", 1)[1].strip())
|
||||
continue
|
||||
if line.startswith("fwmark:"):
|
||||
result["interface"]["fwmark"] = line.split(":", 1)[1].strip()
|
||||
continue
|
||||
if line.startswith("peer:"):
|
||||
cur_key = line.split(":", 1)[1].strip()
|
||||
current_peer = {
|
||||
"public_key": cur_key,
|
||||
"endpoint": None,
|
||||
"allowed_ips": [],
|
||||
"latest_handshake": None,
|
||||
"transfer_received": 0,
|
||||
"transfer_sent": 0,
|
||||
"persistent_keepalive": None,
|
||||
}
|
||||
peers.append(current_peer)
|
||||
continue
|
||||
if current_peer is None:
|
||||
continue
|
||||
if line.startswith("endpoint:"):
|
||||
current_peer["endpoint"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("allowed ips:"):
|
||||
current_peer["allowed_ips"] = line.split(":", 1)[1].strip().split(", ")
|
||||
elif line.startswith("latest handshake:"):
|
||||
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("transfer:"):
|
||||
rest = line.split(":", 1)[1].strip().split(", ")
|
||||
if rest:
|
||||
current_peer["transfer_received"] = rest[0].strip()
|
||||
if len(rest) > 1:
|
||||
current_peer["transfer_sent"] = rest[1].strip()
|
||||
elif line.startswith("persistent-keepalive:"):
|
||||
try:
|
||||
current_peer["persistent_keepalive"] = int(
|
||||
line.split(":", 1)[1].strip()
|
||||
)
|
||||
except ValueError:
|
||||
current_peer["persistent_keepalive"] = None
|
||||
result["peers"] = peers
|
||||
return result
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
return wg.get("status", {"up": False, "interface": {}, "peers": []})
|
||||
return {"up": False, "interface": {}, "peers": []}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/initialize", invalidate=_WG_TAGS)
|
||||
@registry.register("POST", "/wireguard/initialize")
|
||||
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg = _get_config()
|
||||
if cfg["interface"].get("private_key"):
|
||||
@@ -212,13 +171,14 @@ def initialize(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
cfg["interface"]["public_key"] = public_key
|
||||
_save_config(cfg)
|
||||
logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16])
|
||||
refresh_state(["wireguard"])
|
||||
safe = dict(cfg)
|
||||
safe["interface"] = dict(safe["interface"])
|
||||
safe["interface"].pop("private_key", None)
|
||||
return {"initialized": True, "config": safe}
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/peers/add", invalidate=_WG_TAGS)
|
||||
@registry.register("POST", "/wireguard/peers/add")
|
||||
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -251,12 +211,13 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
}
|
||||
logger.info("WireGuard peer '%s' added", name)
|
||||
_save_config(cfg)
|
||||
refresh_state(["wireguard"])
|
||||
peer_out = dict(peers[name])
|
||||
peer_out.pop("private_key", None)
|
||||
return peer_out
|
||||
|
||||
|
||||
@registry.register("DELETE", "/wireguard/peers/remove", invalidate=_WG_TAGS)
|
||||
@registry.register("DELETE", "/wireguard/peers/remove")
|
||||
def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not body:
|
||||
raise ValueError("Request body required")
|
||||
@@ -270,11 +231,15 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
del peers[name]
|
||||
_save_config(cfg)
|
||||
logger.info("WireGuard peer '%s' removed", name)
|
||||
refresh_state(["wireguard"])
|
||||
return {"name": name}
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/peers", cache_tags=_WG_TAGS)
|
||||
@registry.register("GET", "/wireguard/peers")
|
||||
def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
return wg.get("peers", [])
|
||||
cfg = _get_config()
|
||||
result: list[dict[str, Any]] = []
|
||||
for name, info in cfg.get("peers", {}).items():
|
||||
@@ -285,10 +250,12 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
@registry.register("GET", "/wireguard/peer-status", cache_tags=_WG_TAGS)
|
||||
@registry.register("GET", "/wireguard/peer-status")
|
||||
def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
|
||||
st = status(None, None)
|
||||
return st.get("peers", [])
|
||||
wg = _get_wg_state()
|
||||
if wg:
|
||||
return wg.get("status", {}).get("peers", [])
|
||||
return []
|
||||
|
||||
|
||||
@registry.register("POST", "/wireguard/generate-client")
|
||||
|
||||
Reference in New Issue
Block a user