""" 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 pathlib import Path from flask import Flask, abort, request from werkzeug.middleware.proxy_fix import ProxyFix from daemon.client import get from daemon.iface import GET_STATUS_ALL 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.network import bp as network_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() STATIC_DIR = Path(__file__).resolve().parent / "static" # Cache-control: short TTL in dev, aggressive caching in prod (versioned assets) _DEV_MODE = bool(os.environ.get("VACUUM_WALL_DEV")) or False app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 5 if _DEV_MODE else 31536000 # Flask is behind nginx — trust X-Forwarded-* headers for scheme/host detection app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) app.register_blueprint(firewall_bp, url_prefix="/api/firewall") app.register_blueprint(network_bp, url_prefix="/api/network") 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), ("network", network_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, ) # Set cache headers: short in dev, long with staleness tolerance in prod if response.content_type.startswith("text/html"): # index.html: always short cache so browser revalidates response.headers["Cache-Control"] = "no-cache" elif response.content_type.startswith(("text/javascript", "text/css")): if _DEV_MODE: response.headers["Cache-Control"] = "max-age=5" else: response.headers["Cache-Control"] = ( "public, max-age=31536000, stale-while-revalidate=86400" ) return response # --------------------------------------------------------------------------- # API proxy routes # --------------------------------------------------------------------------- @app.route("/api/status/all") def api_status_all(): """Return aggregated status from all subsystems. Proxies the daemon's ``/status/all`` endpoint for SPA consumption. Returns: JSON response with state data for all subsystems. """ try: return {"ok": True, "data": get(GET_STATUS_ALL)} except Exception as exc: logger.warning("Status all failed: %s", exc) return {"ok": False, "error": str(exc)}, 500 # --------------------------------------------------------------------------- # SPA catch-all # --------------------------------------------------------------------------- SPA_DIR = STATIC_DIR @app.route("/") @app.route("/") def spa_page(path=""): """Single-page application catch-all. Serves ``index.html`` (rendered as a Jinja2 template) for all non-API, non-static paths. The client-side router handles navigation and defaults to ``#dashboard``. """ if path.startswith("api/") or path.startswith("static/"): abort(404) scheme = "wss" if request.is_secure else "ws" ws_url = f"{scheme}://{request.host}/ws" html = (SPA_DIR / "index.html").read_text() return html.replace("__WS_URL_PLACEHOLDER__", ws_url) if __name__ == "__main__": logger.info("Starting Flask on 127.0.0.1:9090") app.run(host="127.0.0.1", port=9090)