""" 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)