""" 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, jsonify, request from werkzeug.middleware.proxy_fix import ProxyFix from lib.auth import validate_token from lib.db import get_db from lib.logging import setup_logging from webui.api.auth import bp as auth_bp 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.status import bp as status_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) get_db() app.register_blueprint(auth_bp, url_prefix="/api/auth") 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") app.register_blueprint(status_bp, url_prefix="/api/status") BLUEPRINTS = [ ("auth", auth_bp), ("firewall", firewall_bp), ("network", network_bp), ("dhcp", dhcp_bp), ("proxy", proxy_bp), ("certs", certs_bp), ("wireguard", wireguard_bp), ("logs", logs_bp), ("status", status_bp), ] for name, _ in BLUEPRINTS: logger.info("Registered blueprint '%s' at /api/%s", name, name) # ── Public endpoints (no auth required) ── _AUTH_EXEMPT = { ("GET", "/"), ("POST", "/api/auth/login"), ("POST", "/api/auth/refresh"), ("POST", "/api/auth/webauthn/authenticate-begin"), ("POST", "/api/auth/webauthn/authenticate-finish"), } def _subsystem_from_path(path: str) -> str | None: """Extract subsystem name from API path.""" if not path.startswith("/api/"): return None parts = path.split("/") if len(parts) >= 3: return parts[2] return None def _has_permission(perms: dict, subsystem: str, method: str) -> bool: """Check if user has permission for subsystem + method.""" level = perms.get(subsystem) if method == "GET": return level in ("read", "rw") return level == "rw" # ── JWT authentication middleware ── @app.before_request def _auth_middleware(): """Validate JWT from Authorization header for API routes. Exempts: static routes, vendor files, and public auth endpoints. Attaches request._user_ctx with user info for downstream handlers. """ method = request.method path = request.path # Exempt specific paths if (method, path) in _AUTH_EXEMPT: return if method == "GET" and path.startswith("/vendor/"): return if method in ("GET", "HEAD") and path.startswith("/static/"): return # For non-API routes, skip auth if not path.startswith("/api/"): return # Extract token from Authorization header auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): return jsonify({"ok": False, "error": "unauthorized"}), 401 token_string = auth_header[7:] # strip "Bearer " session_header = request.headers.get("X-Session-Id") payload = validate_token( token_string, token_type="access", session_id=session_header ) if payload is None: return jsonify({"ok": False, "error": "unauthorized"}), 401 username = payload.get("sub") if not username: return jsonify({"ok": False, "error": "unauthorized"}), 401 user_permissions = payload.get("permissions", {}) # Check subsystem permissions subsystem = _subsystem_from_path(path) if subsystem: if subsystem not in user_permissions: return jsonify({"ok": False, "error": "forbidden"}), 403 if not _has_permission(user_permissions, subsystem, method): return jsonify({"ok": False, "error": "forbidden"}), 403 request._user_ctx = { "username": username, "permissions": user_permissions, "jti": payload.get("jti"), } return # --------------------------------------------------------------------------- # 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, ) # Content Security Policy — prevent inline script execution and XSS if "Content-Security-Policy" not in response.headers: response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "script-src 'self'; " "style-src 'self'; " "img-src 'self' data:; " "font-src 'self'; " "connect-src 'self'; " "frame-ancestors 'none'; " "base-uri 'self'; " "form-action 'self'" ) # set X-Content-Type-Options to prevent MIME sniffing response.headers["X-Content-Type-Options"] = "nosniff" # 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 # --------------------------------------------------------------------------- # SPA entry point — serve index.html for /, 404 for everything else # --------------------------------------------------------------------------- SPA_DIR = STATIC_DIR VENDOR_DIR = PROJECT_DIR / "vendor" @app.route("/") def spa_root(): """Serve the SPA entry point. No catch-all — client handles routing.""" 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) @app.route("/vendor/") def vendor_files(filename): """Serve vendored JS libraries (htm.js, etc.).""" from flask import send_file target = (VENDOR_DIR / filename).resolve() if not target.is_relative_to(VENDOR_DIR): abort(404) return send_file(target) @app.errorhandler(404) def not_found(e): """Return 404 JSON for API clients, 404 HTML for everything else.""" if request.path.startswith("/api/"): return {"ok": False, "error": "Not found"}, 404 return "", 404 if __name__ == "__main__": logger.info("Starting Flask on 127.0.0.1:9090") app.run(host="127.0.0.1", port=9090)