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
+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"]}