Files
vacuum-wall/webui/server.py
T
mteehan 200e078bc5 refactor: introduce two-user daemon architecture with socket-based communication
- Add daemon/ module with aiohttp server, sync client, and handler registry
- Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard)
- Add system/acme-deploy.py, vacuum-walld sudoers and systemd service
- Update API routes to use daemon client instead of lib/ directly
- Update lib/, tests/, and webui/ for new architecture
- Update docs and deployment scripts
2026-05-27 23:39:33 +00:00

306 lines
8.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 logging
import os
import sys
import time
from datetime import datetime
from pathlib import Path
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())
# ---------------------------------------------------------------------------
# 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):
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):
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):
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):
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
def _fw_config_get():
"""Read firewall config via daemon."""
return get("/firewall/config")
@app.route("/")
def dashboard():
active_zones = _safely(
lambda: {k: v for k, v in get("/firewall/zones").get("active", {}).items()}, {}
)
interfaces = _safely(lambda: get("/firewall/interfaces"), [])
dnsmasq = _safely(lambda: get("/dnsmasq/status"), {})
domains = _safely(lambda: get("/nginx/domains"), [])
certs = _safely(lambda: get("/acme/list"), [])
wg = _safely(lambda: get("/wireguard/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=_safely(lambda: get("/firewall/config/pending"), {}),
)
@app.route("/interfaces")
def interfaces_page():
return render_template(
"interfaces.html",
interfaces=_safely(lambda: get("/firewall/interfaces"), []),
zones=_safely(lambda: get("/firewall/zones").get("available", []), []),
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=_safely(lambda: get("/firewall/config/pending"), {}),
)
@app.route("/zones")
def zones_page():
firewall_config = _safely(_fw_config_get, {})
firewall_pending = _safely(lambda: get("/firewall/config/pending"), {})
return render_template(
"zones.html",
zones=_safely(lambda: get("/firewall/zones/all"), []),
services=_safely(lambda: get("/firewall/services"), []),
firewall_config=firewall_config,
firewall_pending=firewall_pending,
)
@app.route("/rules")
def rules_page():
zones = list(_safely(lambda: get("/firewall/zones").get("active", {}).keys(), []))
raw = _safely(_fw_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():
return render_template(
"nat.html", zones=_safely(lambda: get("/firewall/zones/all"), [])
)
@app.route("/dhcp")
def dhcp_page():
return render_template(
"dhcp.html",
config=_safely(lambda: get("/dnsmasq/config"), {}),
status=_safely(lambda: get("/dnsmasq/status"), {}),
leases=_safely(lambda: get("/dnsmasq/leases"), []),
)
@app.route("/proxy")
def proxy_page():
return render_template(
"proxy.html",
domains=_safely(lambda: get("/nginx/domains"), []),
config=_safely(lambda: get("/nginx/config"), {}),
)
@app.route("/certs")
def certs_page():
email_data = _safely(lambda: get("/acme/email"), {"email": ""})
return render_template(
"certs.html",
certs=_safely(lambda: get("/acme/list"), []),
email=email_data.get("email", ""),
)
@app.route("/wireguard")
def wireguard_page():
return render_template(
"wireguard.html",
config=_safely(lambda: get("/wireguard/config"), {}),
status=_safely(lambda: get("/wireguard/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)