Files
vacuum-wall/webui/server.py
T
mteehan 0ed275835d fix: auth review fixes — token revocation, WS auth, seeding, and hardening
Refresh/logout and token robustness
- drop the post-rotation refresh_tokens row delete in auth_refresh so
  logout blacklists the current (rotated) refresh token; remove the
  dead _clear_refresh_token_after_rotation helper and clear_active_refresh_token
- reject non-object JWT payloads in _extract_unverified_sub so crafted
  Authorization headers return 401 instead of crashing with 500

SQLite user store
- make builtin-admin seeding idempotent: on a concurrent first start the
  losing seeder re-checks, finds the winner, and returns instead of
  raising IntegrityError
- per-thread sqlite connections + busy_timeout so Flask worker threads
  don't hit cross-thread ProgrammingError / SQLITE_BUSY
- add LogsDirectory + /var/log/vacuum-wall to ReadWritePaths in both
  systemd units so the fallback admin password actually lands on disk

Frontend
- skip apiFetch 401-recovery for public auth endpoints so a failed
  login no longer logs out a valid session
- add /passkeys to the nav (passkey registration was unreachable);
  remove the dead checkWebAuthnCapable export
- drop the CSP-blocked inline WS-URL script and the
  __WS_URL_PLACEHOLDER__ plumbing; the WS URL is derived from location

Daemon / WS
- parse Sec-WebSocket-Protocol manually (web.Request.get_subprotocols
  does not exist in aiohttp 3.13); X-Auth-Token is a custom-nginx
  fallback only — docstring and security docs corrected

Install / system
- bootstrap_auth.py is now idempotent: preserves existing auth config
  and syncs the admin password on re-runs (new reset_password helper)
- WebUI server block renders auth_basic off (the UI is JWT-protected)
- install.sh chown/chmod skips .git to avoid git dubious-ownership
  breakage
- tolerate unreadable /etc/wireguard during system import

Contracts / docs
- create_user returns 409 on duplicate username per docs/api.md
- correct docs/api.md response shapes, docs/security.md blacklist
  cleanup wording + one-refresh-per-user caveat, stale WS-URL
  references, and the .htpasswd description

Tests: +7 regression tests (rotation/logout revocation, crafted-token
401, concurrent seeding); placeholder-substitution tests replaced with
serve-as-is SPA root tests.
2026-08-17 01:45:15 +00:00

335 lines
11 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"),
}
# ── Personal auth routes (operates on own account, no subsystem permission needed) ──
# These routes require a valid JWT but do NOT require an "auth" permission entry.
# A user with only "firewall:read" can still view session, change password, logout, etc.
# Method-agnostic — covers all HTTP methods for future-proofing.
_AUTH_PERSONAL_PATHS = (
"/api/auth/session",
"/api/auth/password",
"/api/auth/logout",
)
_AUTH_PERSONAL_PREFIXES = (
"/api/auth/webauthn/register-",
"/api/auth/webauthn/credentials",
"/api/auth/webauthn/creds/",
)
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 _is_personal_auth(method: str, path: str) -> bool:
"""Check if route is a personal auth operation (no subsystem permission needed)."""
if path in _AUTH_PERSONAL_PATHS:
return True
return any(path.startswith(prefix) for prefix in _AUTH_PERSONAL_PREFIXES)
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")
if not session_header:
return jsonify({"ok": False, "error": "unauthorized"}), 401
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 (skip personal auth routes)
subsystem = _subsystem_from_path(path)
if subsystem and not _is_personal_auth(method, path):
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
# NOTE: connect-src 'self' is safe because all XHR/fetch/WS calls go through
# nginx on the same origin. If WS or API routing ever changes to use a
# different host/port directly, the CSP must be updated accordingly.
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."""
return (SPA_DIR / "index.html").read_text()
@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)