dc96e15643
- 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)
357 lines
9.8 KiB
Python
357 lines
9.8 KiB
Python
"""
|
|
server.py - Vacuum Wall management WebUI entry point.
|
|
|
|
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
|
|
|
|
from daemon.client import get
|
|
from lib.logging import setup_logging
|
|
from webui.api.certs import bp as certs_bp
|
|
from webui.api.dhcp import bp as dhcp_bp
|
|
from webui.api.firewall import bp as firewall_bp
|
|
from webui.api.logs import bp as logs_bp
|
|
from webui.api.proxy import bp as proxy_bp
|
|
from webui.api.wireguard import bp as wireguard_bp
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging — must be first so subsequent modules inherit the config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
setup_logging()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
logger.info(
|
|
"Python %s.%s.%s",
|
|
sys.version_info.major,
|
|
sys.version_info.minor,
|
|
sys.version_info.micro,
|
|
)
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
app = Flask(__name__)
|
|
app.config["SECRET_KEY"] = os.urandom(32).hex()
|
|
|
|
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
|
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
|
|
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
|
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
|
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
|
|
app.register_blueprint(logs_bp, url_prefix="/api/logs")
|
|
|
|
BLUEPRINTS = [
|
|
("firewall", firewall_bp),
|
|
("dhcp", dhcp_bp),
|
|
("proxy", proxy_bp),
|
|
("certs", certs_bp),
|
|
("wireguard", wireguard_bp),
|
|
("logs", logs_bp),
|
|
]
|
|
|
|
for name, _ in BLUEPRINTS:
|
|
logger.info("Registered blueprint '%s' at /api/%s", name, name)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request logging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.before_request
|
|
def _log_request_start():
|
|
request._start_time = time.monotonic()
|
|
|
|
|
|
@app.after_request
|
|
def _log_request_finish(response):
|
|
elapsed_ms = (
|
|
time.monotonic() - getattr(request, "_start_time", time.monotonic())
|
|
) * 1000
|
|
logger.info(
|
|
"%s %s -> %d (%.1f ms)",
|
|
request.method,
|
|
request.path,
|
|
response.status_code,
|
|
elapsed_ms,
|
|
)
|
|
return response
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Jinja2 custom filters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.template_filter("timestamp")
|
|
def timestamp_filter(value):
|
|
if not value:
|
|
return ""
|
|
try:
|
|
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
except (ValueError, TypeError):
|
|
return str(value)
|
|
|
|
|
|
@app.template_filter("bytes")
|
|
def bytes_filter(value):
|
|
try:
|
|
num = float(value)
|
|
except (ValueError, TypeError):
|
|
return str(value)
|
|
if num < 0:
|
|
return "0 B"
|
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
if abs(num) < 1024:
|
|
return f"{num:.1f} {unit}"
|
|
num /= 1024
|
|
return f"{num:.1f} PB"
|
|
|
|
|
|
@app.template_filter("duration")
|
|
def duration_filter(value):
|
|
try:
|
|
total = int(float(value))
|
|
except (ValueError, TypeError):
|
|
return str(value)
|
|
if total < 0:
|
|
return "0s"
|
|
parts = []
|
|
days, remainder = divmod(total, 86400)
|
|
hours, remainder = divmod(remainder, 3600)
|
|
minutes, seconds = divmod(remainder, 60)
|
|
if days:
|
|
parts.append(f"{days}d")
|
|
if hours:
|
|
parts.append(f"{hours}h")
|
|
if minutes:
|
|
parts.append(f"{minutes}m")
|
|
parts.append(f"{seconds}s")
|
|
return " ".join(parts)
|
|
|
|
|
|
@app.template_filter("json_pretty")
|
|
def json_pretty_filter(value):
|
|
import json
|
|
|
|
try:
|
|
return json.dumps(value, indent=2, default=str)
|
|
except (TypeError, ValueError):
|
|
return str(value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Page routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _safely(fn, default=None):
|
|
"""Call *fn* and return *default* on any exception."""
|
|
try:
|
|
return fn()
|
|
except Exception as exc:
|
|
logger.warning("WebUI data load failed: %s", exc)
|
|
return default
|
|
|
|
|
|
def _get_service_status(dnsmasq_info, wg_info):
|
|
"""Build a service status dict for the dashboard template."""
|
|
services = {}
|
|
if dnsmasq_info:
|
|
services["Dnsmasq"] = {
|
|
"running": dnsmasq_info.get("service_active", False),
|
|
}
|
|
if wg_info:
|
|
services["WireGuard"] = {
|
|
"running": wg_info.get("up", False),
|
|
}
|
|
return services
|
|
|
|
|
|
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():
|
|
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",
|
|
active_zones=active_zones,
|
|
interfaces=interfaces,
|
|
dnsmasq=dnsmasq,
|
|
domains=domains,
|
|
certs=certs,
|
|
wg_status=wg,
|
|
services=_get_service_status(dnsmasq, wg),
|
|
firewall_config=_safely(_fw_config_get, {}),
|
|
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=fw_state.get("interfaces", []),
|
|
zones=fw_state.get("active_zones", {}).keys() or [],
|
|
firewall_config=_safely(_fw_config_get, {}),
|
|
firewall_pending=fw_state.get("pending", {}),
|
|
)
|
|
|
|
|
|
@app.route("/zones")
|
|
def zones_page():
|
|
all_status = _safely(_load_status_all, {})
|
|
fw_state = all_status.get("firewall", {}) or {}
|
|
return render_template(
|
|
"zones.html",
|
|
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():
|
|
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)
|
|
|
|
|
|
@app.route("/nat")
|
|
def nat_page():
|
|
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=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=ng_state.get("domains", []),
|
|
config=ng_state.get("config", {}),
|
|
)
|
|
|
|
|
|
@app.route("/certs")
|
|
def certs_page():
|
|
all_status = _safely(_load_status_all, {})
|
|
ac_state = all_status.get("acme", {}) or {}
|
|
return render_template(
|
|
"certs.html",
|
|
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=wg_state.get("config", {}),
|
|
status=wg_state.get("status", {}),
|
|
)
|
|
|
|
|
|
@app.route("/logs")
|
|
def logs_page():
|
|
return render_template("logs.html")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logger.info("Starting Flask on 127.0.0.1:9090")
|
|
app.run(host="127.0.0.1", port=9090)
|