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 7beba44b4b
19 changed files with 1957 additions and 986 deletions
+88 -37
View File
@@ -5,12 +5,16 @@ Serves the Flask application on 127.0.0.1:9090. Nginx terminates SSL
and enforces basic authentication before proxying to this port.
"""
import contextlib
import importlib
import logging
import os
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from flask import Flask, render_template, request
@@ -41,6 +45,26 @@ logger.info(
logger.info("Project directory: %s", PROJECT_DIR)
logger.info("Process ID: %d", os.getpid())
_reloading = False
def _sighup_handler(signum, frame):
global _reloading
if _reloading:
return
_reloading = True
logger.info("Received SIGHUP, reloading modules...")
for mod_name, mod in sys.modules.items():
if mod_name.startswith("webui.") or mod_name.startswith("lib."):
with contextlib.suppress(Exception):
importlib.reload(mod)
logger.info("Modules reloaded, sending SIGTERM to restart under systemd...")
signal.signal(signal.SIGTERM, signal.SIG_DFL)
os.kill(os.getpid(), signal.SIGTERM)
signal.signal(signal.SIGHUP, _sighup_handler)
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
@@ -184,21 +208,38 @@ def _get_service_status(dnsmasq_info, wg_info):
return services
def _fw_config_get():
def _fw_config_get() -> dict[str, Any]:
"""Read firewall config via daemon."""
return get("/firewall/config")
def _load_status_all() -> dict[str, Any]:
"""Load all system state in one call."""
return get("/status/all")
@app.route("/")
def root_redirect():
from flask import redirect, url_for
return redirect(url_for("dashboard"))
@app.route("/dashboard")
def dashboard():
active_zones = _safely(
lambda: {k: v for k, v in get("/firewall/zones").get("active", {}).items()}, {}
)
interfaces = _safely(lambda: get("/firewall/interfaces"), [])
dnsmasq = _safely(lambda: get("/dnsmasq/status"), {})
domains = _safely(lambda: get("/nginx/domains"), [])
certs = _safely(lambda: get("/acme/list"), [])
wg = _safely(lambda: get("/wireguard/status"), {})
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
dm_state = all_status.get("dnsmasq", {}) or {}
ng_state = all_status.get("nginx", {}) or {}
ac_state = all_status.get("acme", {}) or {}
wg_state = all_status.get("wireguard", {}) or {}
active_zones = {k: v for k, v in fw_state.get("active_zones", {}).items()}
interfaces = fw_state.get("interfaces", [])
dnsmasq = dm_state.get("status", {})
domains = ng_state.get("domains", [])
certs = ac_state.get("certs", [])
wg = wg_state.get("status", {})
return render_template(
"dashboard.html",
@@ -210,41 +251,44 @@ def dashboard():
wg_status=wg,
services=_get_service_status(dnsmasq, wg),
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/interfaces")
def interfaces_page():
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template(
"interfaces.html",
interfaces=_safely(lambda: get("/firewall/interfaces"), []),
zones=_safely(lambda: get("/firewall/zones").get("available", []), []),
interfaces=fw_state.get("interfaces", []),
zones=fw_state.get("active_zones", {}).keys() or [],
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/zones")
def zones_page():
firewall_config = _safely(_fw_config_get, {})
firewall_pending = _safely(lambda: get("/firewall/config/pending"), {})
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template(
"zones.html",
zones=_safely(lambda: get("/firewall/zones/all"), []),
services=_safely(lambda: get("/firewall/services"), []),
firewall_config=firewall_config,
firewall_pending=firewall_pending,
zones=list(fw_state.get("zones", {}).values()),
services=fw_state.get("available_services", []),
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/rules")
def rules_page():
zones = list(_safely(lambda: get("/firewall/zones").get("active", {}).keys(), []))
raw = _safely(_fw_config_get, {})
rules = {}
for zname, zcfg in raw.get("zones", {}).items():
rr = zcfg.get("rich_rules", [])
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
zones = list(fw_state.get("zones", {}).keys())
rules: dict[str, list[str]] = {}
for zname, zcfg in fw_state.get("zones", {}).items():
rr = zcfg.get("rich-rules", [])
if rr:
rules[zname] = rr
return render_template("rules.html", zones=zones, rules=rules or None)
@@ -252,46 +296,53 @@ def rules_page():
@app.route("/nat")
def nat_page():
return render_template(
"nat.html", zones=_safely(lambda: get("/firewall/zones/all"), [])
)
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template("nat.html", zones=list(fw_state.get("zones", {}).values()))
@app.route("/dhcp")
def dhcp_page():
all_status = _safely(_load_status_all, {})
dm_state = all_status.get("dnsmasq", {}) or {}
return render_template(
"dhcp.html",
config=_safely(lambda: get("/dnsmasq/config"), {}),
status=_safely(lambda: get("/dnsmasq/status"), {}),
leases=_safely(lambda: get("/dnsmasq/leases"), []),
config=dm_state.get("config", {}),
status=dm_state.get("status", {}),
leases=dm_state.get("leases", []),
)
@app.route("/proxy")
def proxy_page():
all_status = _safely(_load_status_all, {})
ng_state = all_status.get("nginx", {}) or {}
return render_template(
"proxy.html",
domains=_safely(lambda: get("/nginx/domains"), []),
config=_safely(lambda: get("/nginx/config"), {}),
domains=ng_state.get("domains", []),
config=ng_state.get("config", {}),
)
@app.route("/certs")
def certs_page():
email_data = _safely(lambda: get("/acme/email"), {"email": ""})
all_status = _safely(_load_status_all, {})
ac_state = all_status.get("acme", {}) or {}
return render_template(
"certs.html",
certs=_safely(lambda: get("/acme/list"), []),
email=email_data.get("email", ""),
certs=ac_state.get("certs", []),
email=ac_state.get("email", ""),
)
@app.route("/wireguard")
def wireguard_page():
all_status = _safely(_load_status_all, {})
wg_state = all_status.get("wireguard", {}) or {}
return render_template(
"wireguard.html",
config=_safely(lambda: get("/wireguard/config"), {}),
status=_safely(lambda: get("/wireguard/status"), {}),
config=wg_state.get("config", {}),
status=wg_state.get("status", {}),
)