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:
2026-05-30 05:45:40 +00:00
parent c091063248
commit dc96e15643
19 changed files with 1960 additions and 986 deletions
+374 -107
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+49 -82
View File
@@ -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")