""" 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): """Handle SIGHUP by reloading modules then restarting via SIGTERM. Reloads all ``webui.*`` and ``lib.*`` modules, re-registers blueprints, and requests systemd restart by sending SIGTERM with default handler. """ 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(): """Record request start time for duration tracking.""" request._start_time = time.monotonic() @app.after_request def _log_request_finish(response): """Log request duration and status code after response generation. Args: response: The HTTP response object. Returns: The unchanged response object. """ 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): """Convert an ISO-8601 timestamp string to ``YYYY-MM-DD HH:MM:SS``. Args: value: ISO timestamp string (may end with ``Z``). Returns: Formatted date string, or original value on parse failure. """ 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): """Convert a byte count to a human-readable size string (B/KB/MB…). Args: value: Numeric byte count. Returns: Formatted size string, or original value on parse failure. """ 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): """Convert a duration in seconds to a human-readable string. Args: value: Duration in seconds. Returns: Formatted string (e.g. ``3d 2h 15m 30s``), or original value on failure. """ 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): """Serialize *value* as indented JSON for template display. Args: value: Any JSON-serializable object. Returns: Pretty-printed JSON string with 2-space indent. """ 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. Args: fn: Zero-argument callable to execute. default: Fallback value returned when *fn* raises. Returns: The result of ``fn()``, or *default* if an exception occurred. """ 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. Args: dnsmasq_info: Dnsmasq status payload from the daemon. wg_info: WireGuard status payload from the daemon. Returns: Dict mapping service names to ``{running: bool}``. """ 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 the current firewall config from the daemon.""" return get("/firewall/config") def _load_status_all() -> dict[str, Any]: """Load all subsystem status from the daemon in a single call.""" return get("/status/all") @app.route("/") def root_redirect(): """Redirect root URL to the dashboard. GET / Returns: Redirect response to the dashboard page. """ from flask import redirect, url_for return redirect(url_for("dashboard")) @app.route("/dashboard") def dashboard(): """Render the main dashboard overview page. GET / Template context: active_zones (dict): Active firewalld zones and bound interfaces. interfaces (list): Available network interfaces with zone bindings. dnsmasq (dict): Dnsmasq status information. domains (list): Configured proxy domains. certs (list): ACME certificate inventory. wg_status (dict): WireGuard tunnel status. services (dict): Service running indicators (Dnsmasq, WireGuard). firewall_config (dict): Declarative firewall JSON config. firewall_pending (dict): Pending firewall rules awaiting apply. """ 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(): """Render the network interfaces management page. GET /interfaces Template context: interfaces (list): Available network interfaces. zones (list): Zone names bound to interfaces. firewall_config (dict): Declarative firewall JSON config. firewall_pending (dict): Pending firewall rules awaiting apply. """ 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(): """Render the firewall zones management page. GET /zones Template context: zones (list): All zone configurations. services (list): Available service identifiers for zone policies. firewall_config (dict): Declarative firewall JSON config. firewall_pending (dict): Pending firewall rules awaiting apply. """ 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(): """Render the firewall rich-rules editor page. GET /rules Template context: zones (list): Zone names containing rich rules. rules (dict | None): Zone name → rich rule mappings (``None`` if empty). """ 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(): """Render the NAT rules management page. GET /nat Template context: zones (list): Zone configurations containing NAT rules. """ 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(): """Render the DHCP/Dnsmasq configuration page. GET /dhcp Template context: config (dict): Dnsmasq configuration settings. status (dict): Dnsmasq runtime status. leases (list): Current DHCP lease table. """ 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(): """Render the reverse proxy / SSL termination management page. GET /proxy Template context: domains (list): Configured proxy domains with upstream targets. config (dict): Nginx configuration settings. """ 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(): """Render the SSL certificate management page. GET /certs Template context: certs (list): ACME certificate inventory. email (str): Configured ACME registration email. """ 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(): """Render the WireGuard VPN management page. GET /wireguard Template context: config (dict): WireGuard tunnel configuration. status (dict): WireGuard runtime status. """ 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(): """Render the system logs viewer page. GET /logs """ 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)