Files
vacuum-wall/webui/server.py
T
2026-06-17 03:41:08 +00:00

206 lines
6.4 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 catch-all
# ---------------------------------------------------------------------------
SPA_DIR = STATIC_DIR
@app.route("/")
@app.route("/<path:path>")
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)