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:
+88
-37
@@ -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", {}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user