Files
vacuum-wall/webui/server.py
T
mteehan 2f215793e9 docs: add docstrings to all API endpoints and daemon handlers
Add comprehensive docstrings to firewall, DHCP, proxy, wireguard, certs,
and logs API endpoints. Document parameters, return values, and error cases
for the documentation system.
2026-05-30 16:15:45 +00:00

513 lines
14 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 datetime import datetime
from pathlib import Path
from typing import Any
from flask import Flask, render_template, request
from daemon.client import get
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.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()
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
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),
("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,
)
return response
# ---------------------------------------------------------------------------
# Jinja2 custom filters
# ---------------------------------------------------------------------------
@app.template_filter("timestamp")
def timestamp_filter(value):
"""Convert an ISO-8601 timestamp string to ``YYYY-MM-DD HH:MM:SS``.
Args:
value: ISO timestamp string (may end with ``Z``).
Returns:
Formatted date string, or original value on parse failure.
"""
if not value:
return ""
try:
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
return str(value)
@app.template_filter("bytes")
def bytes_filter(value):
"""Convert a byte count to a human-readable size string (B/KB/MB…).
Args:
value: Numeric byte count.
Returns:
Formatted size string, or original value on parse failure.
"""
try:
num = float(value)
except (ValueError, TypeError):
return str(value)
if num < 0:
return "0 B"
for unit in ("B", "KB", "MB", "GB", "TB"):
if abs(num) < 1024:
return f"{num:.1f} {unit}"
num /= 1024
return f"{num:.1f} PB"
@app.template_filter("duration")
def duration_filter(value):
"""Convert a duration in seconds to a human-readable string.
Args:
value: Duration in seconds.
Returns:
Formatted string (e.g. ``3d 2h 15m 30s``), or original value on failure.
"""
try:
total = int(float(value))
except (ValueError, TypeError):
return str(value)
if total < 0:
return "0s"
parts = []
days, remainder = divmod(total, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, seconds = divmod(remainder, 60)
if days:
parts.append(f"{days}d")
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
parts.append(f"{seconds}s")
return " ".join(parts)
@app.template_filter("json_pretty")
def json_pretty_filter(value):
"""Serialize *value* as indented JSON for template display.
Args:
value: Any JSON-serializable object.
Returns:
Pretty-printed JSON string with 2-space indent.
"""
import json
try:
return json.dumps(value, indent=2, default=str)
except (TypeError, ValueError):
return str(value)
# ---------------------------------------------------------------------------
# Page routes
# ---------------------------------------------------------------------------
def _safely(fn, default=None):
"""Call *fn* and return *default* on any exception.
Args:
fn: Zero-argument callable to execute.
default: Fallback value returned when *fn* raises.
Returns:
The result of ``fn()``, or *default* if an exception occurred.
"""
try:
return fn()
except Exception as exc:
logger.warning("WebUI data load failed: %s", exc)
return default
def _get_service_status(dnsmasq_info, wg_info):
"""Build a service status dict for the dashboard template.
Args:
dnsmasq_info: Dnsmasq status payload from the daemon.
wg_info: WireGuard status payload from the daemon.
Returns:
Dict mapping service names to ``{running: bool}``.
"""
services = {}
if dnsmasq_info:
services["Dnsmasq"] = {
"running": dnsmasq_info.get("service_active", False),
}
if wg_info:
services["WireGuard"] = {
"running": wg_info.get("up", False),
}
return services
def _fw_config_get() -> dict[str, Any]:
"""Read the current firewall config from the daemon."""
return get("/firewall/config")
def _load_status_all() -> dict[str, Any]:
"""Load all subsystem status from the daemon in a single call."""
return get("/status/all")
@app.route("/")
def root_redirect():
"""Redirect root URL to the dashboard.
GET /
Returns:
Redirect response to the dashboard page.
"""
from flask import redirect, url_for
return redirect(url_for("dashboard"))
@app.route("/dashboard")
def dashboard():
"""Render the main dashboard overview page.
GET /
Template context:
active_zones (dict): Active firewalld zones and bound interfaces.
interfaces (list): Available network interfaces with zone bindings.
dnsmasq (dict): Dnsmasq status information.
domains (list): Configured proxy domains.
certs (list): ACME certificate inventory.
wg_status (dict): WireGuard tunnel status.
services (dict): Service running indicators (Dnsmasq, WireGuard).
firewall_config (dict): Declarative firewall JSON config.
firewall_pending (dict): Pending firewall rules awaiting apply.
"""
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
dm_state = all_status.get("dnsmasq", {}) or {}
ng_state = all_status.get("nginx", {}) or {}
ac_state = all_status.get("acme", {}) or {}
wg_state = all_status.get("wireguard", {}) or {}
active_zones = {k: v for k, v in fw_state.get("active_zones", {}).items()}
interfaces = fw_state.get("interfaces", [])
dnsmasq = dm_state.get("status", {})
domains = ng_state.get("domains", [])
certs = ac_state.get("certs", [])
wg = wg_state.get("status", {})
return render_template(
"dashboard.html",
active_zones=active_zones,
interfaces=interfaces,
dnsmasq=dnsmasq,
domains=domains,
certs=certs,
wg_status=wg,
services=_get_service_status(dnsmasq, wg),
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/interfaces")
def interfaces_page():
"""Render the network interfaces management page.
GET /interfaces
Template context:
interfaces (list): Available network interfaces.
zones (list): Zone names bound to interfaces.
firewall_config (dict): Declarative firewall JSON config.
firewall_pending (dict): Pending firewall rules awaiting apply.
"""
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template(
"interfaces.html",
interfaces=fw_state.get("interfaces", []),
zones=fw_state.get("active_zones", {}).keys() or [],
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/zones")
def zones_page():
"""Render the firewall zones management page.
GET /zones
Template context:
zones (list): All zone configurations.
services (list): Available service identifiers for zone policies.
firewall_config (dict): Declarative firewall JSON config.
firewall_pending (dict): Pending firewall rules awaiting apply.
"""
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template(
"zones.html",
zones=list(fw_state.get("zones", {}).values()),
services=fw_state.get("available_services", []),
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=fw_state.get("pending", {}),
)
@app.route("/rules")
def rules_page():
"""Render the firewall rich-rules editor page.
GET /rules
Template context:
zones (list): Zone names containing rich rules.
rules (dict | None): Zone name → rich rule mappings (``None`` if empty).
"""
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
zones = list(fw_state.get("zones", {}).keys())
rules: dict[str, list[str]] = {}
for zname, zcfg in fw_state.get("zones", {}).items():
rr = zcfg.get("rich-rules", [])
if rr:
rules[zname] = rr
return render_template("rules.html", zones=zones, rules=rules or None)
@app.route("/nat")
def nat_page():
"""Render the NAT rules management page.
GET /nat
Template context:
zones (list): Zone configurations containing NAT rules.
"""
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
return render_template("nat.html", zones=list(fw_state.get("zones", {}).values()))
@app.route("/dhcp")
def dhcp_page():
"""Render the DHCP/Dnsmasq configuration page.
GET /dhcp
Template context:
config (dict): Dnsmasq configuration settings.
status (dict): Dnsmasq runtime status.
leases (list): Current DHCP lease table.
"""
all_status = _safely(_load_status_all, {})
dm_state = all_status.get("dnsmasq", {}) or {}
return render_template(
"dhcp.html",
config=dm_state.get("config", {}),
status=dm_state.get("status", {}),
leases=dm_state.get("leases", []),
)
@app.route("/proxy")
def proxy_page():
"""Render the reverse proxy / SSL termination management page.
GET /proxy
Template context:
domains (list): Configured proxy domains with upstream targets.
config (dict): Nginx configuration settings.
"""
all_status = _safely(_load_status_all, {})
ng_state = all_status.get("nginx", {}) or {}
return render_template(
"proxy.html",
domains=ng_state.get("domains", []),
config=ng_state.get("config", {}),
)
@app.route("/certs")
def certs_page():
"""Render the SSL certificate management page.
GET /certs
Template context:
certs (list): ACME certificate inventory.
email (str): Configured ACME registration email.
"""
all_status = _safely(_load_status_all, {})
ac_state = all_status.get("acme", {}) or {}
return render_template(
"certs.html",
certs=ac_state.get("certs", []),
email=ac_state.get("email", ""),
)
@app.route("/wireguard")
def wireguard_page():
"""Render the WireGuard VPN management page.
GET /wireguard
Template context:
config (dict): WireGuard tunnel configuration.
status (dict): WireGuard runtime status.
"""
all_status = _safely(_load_status_all, {})
wg_state = all_status.get("wireguard", {}) or {}
return render_template(
"wireguard.html",
config=wg_state.get("config", {}),
status=wg_state.get("status", {}),
)
@app.route("/logs")
def logs_page():
"""Render the system logs viewer page.
GET /logs
"""
return render_template("logs.html")
if __name__ == "__main__":
logger.info("Starting Flask on 127.0.0.1:9090")
app.run(host="127.0.0.1", port=9090)