Files
vacuum-wall/webui/server.py
T
mteehan 37039351be fix htmx refactor route mismatches and remaining TODO items
- wireguard: POST /peers with JSON encoding (was /add-peer)
- rules: delete by rule_id in URL path (was JSON body); pass rule objects with id from server; add hx-disable to initial render
- nat: port forward delete uses URL path params to match blueprint
- nat: masquerade toggle uses native hx-post/hx-vals (was inline fetch)
- app.js renderers updated to use URL path deletes for rules and forwards
- remove TODO.md
2026-05-17 01:17:12 +00:00

322 lines
8.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 logging
import os
import sys
import time
from datetime import datetime
from pathlib import Path
from flask import Flask, render_template, request
from lib.acme import get_email, list_certs
from lib.dnsmasq import get_config as dnsmasq_config
from lib.dnsmasq import get_lease_table
from lib.dnsmasq import get_status as dnsmasq_status
from lib.firewall import (
config_get,
config_pending,
get_active_zones,
get_interfaces,
get_zone_info,
)
from lib.logging import setup_logging
from lib.nginx import get_config as nginx_config
from lib.nginx import get_domains
from lib.wireguard import get_config as wg_config
from lib.wireguard import status as wg_status
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())
# ---------------------------------------------------------------------------
# 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():
request._start_time = time.monotonic()
@app.after_request
def _log_request_finish(response):
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 timestamp string to a human-readable date."""
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):
"""Format a byte count to a human-readable string (KB / MB / GB)."""
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):
"""Format a duration in seconds to a human-readable string."""
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):
"""Pretty-print a JSON-serialisable value for debug displays."""
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."""
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."""
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
@app.route("/")
def dashboard():
active_zones = _safely(get_active_zones, {})
interfaces = _safely(get_interfaces, [])
dnsmasq = _safely(dnsmasq_status, {})
domains = _safely(get_domains, [])
certs = _safely(list_certs, [])
wg = _safely(wg_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(config_get, {}),
firewall_pending=_safely(config_pending, {}),
)
@app.route("/interfaces")
def interfaces_page():
firewall_config = _safely(config_get, {})
firewall_pending = _safely(config_pending, {})
return render_template(
"interfaces.html",
interfaces=_safely(get_interfaces, []),
active_zones=_safely(get_active_zones, {}),
firewall_config=firewall_config,
firewall_pending=firewall_pending,
)
@app.route("/zones")
def zones_page():
firewall_config = _safely(config_get, {})
firewall_pending = _safely(config_pending, {})
zones_data = {}
for name in _safely(get_active_zones, {}):
zones_data[name] = _safely(lambda n=name: get_zone_info(n), {})
return render_template(
"zones.html",
zones=zones_data,
interfaces=_safely(get_interfaces, []),
services=_safely(
lambda: __import__(
"lib.firewall", fromlist=["get_services"]
).get_services(),
[],
),
firewall_config=firewall_config,
firewall_pending=firewall_pending,
)
@app.route("/rules")
def rules_page():
zones = list(_safely(get_active_zones, {}).keys())
raw = _safely(config_get, {})
rules = {}
for zname, zcfg in raw.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():
zones = {}
for name in _safely(get_active_zones, {}):
zones[name] = _safely(lambda n=name: get_zone_info(n), {})
return render_template("nat.html", zones=zones)
@app.route("/dhcp")
def dhcp_page():
return render_template(
"dhcp.html",
config=_safely(dnsmasq_config, {}),
status=_safely(dnsmasq_status, {}),
leases=_safely(get_lease_table, []),
)
@app.route("/proxy")
def proxy_page():
return render_template(
"proxy.html", domains=_safely(get_domains, []), config=_safely(nginx_config, {})
)
@app.route("/certs")
def certs_page():
return render_template(
"certs.html", certs=_safely(list_certs, []), email=_safely(get_email, "")
)
@app.route("/wireguard")
def wireguard_page():
return render_template(
"wireguard.html", config=_safely(wg_config, {}), status=_safely(wg_status, {})
)
@app.route("/logs")
def logs_page():
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)