Files
vacuum-wall/webui/server.py
T
mteehan ca110c321d style: format docs, fix user_permissions variable scoping in auth middleware
Apply ruff line-wrapping formatting to docs and test files.
Clarify auth middleware: extract user_permissions once before
subsystem check, removing conditional variable scoping.
2026-07-27 18:37:11 +00:00

310 lines
9.6 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 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/<path:filename>")
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)