feaf253403
The SPA uses hash-based routing, so the catch-all route was dead code. Replace with explicit routes for / and /vendor/<path>, plus a 404 handler that returns JSON for /api/ paths and empty HTML otherwise.
217 lines
6.8 KiB
Python
217 lines
6.8 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, 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 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)
|