Files
vacuum-wall/webui/server.py
T
mteehan bc72db903c feat: add networkd subsystem and fix code review issues
Phase 1-4: Networkd subsystem
- lib/network.py: systemd-networkd config renderer (.network INI files)
  with full schema support: [Match], [Link], [Network], [Address], [Route],
  [DHCPv4], [DHCPv6] sections. One Address/=DNS= line per value per spec.
  Route sections use #N suffix per systemd.syntax(7).
- lib/network.py: generate_network_files() with 50-<name>.network prefix
  and stale file cleanup
- lib/network.py: collect_upstream_dns() filters local/private DNS
- lib/network.py: infer_dhcp_ranges() and infer_zones() helpers
- daemon/handlers/network.py: routes for GET/POST /network/interfaces
  and full apply with DNS upstream sync to dnsmasq
- webui/api/network.py: Flask blueprint for /api/network/* endpoints
- webui/api: interfaces page updated with IP config inline editing
- lib/state.py: networkd collector using parse_networkctl_status()
- system/sudoers.d/vacuum-walld: networkctl + systemd-network rules
- system/systemd/vacuum-walld.service: ReadWritePaths for /etc/systemd/network
- install.sh: ACME email now optional, configured from WebUI
- lib/acme.py: get_email() falls back to declarative config

Phase 5: Code review fixes
- daemon/server.py: path params now win over JSON body and query params
  in request body merge (prevents config save name override)
- daemon/server.py: remove dead 'import re'
- daemon/handlers/network.py: replace Path.mkdir() with sudo mkdir
  for /etc/systemd/network (ProtectSystem=strict compatibility)
- system/sudoers.d/vacuum-walld: pin systemctl to specific commands
  (reload/is-active dnsmasq instead of wildcard)
- system/sudoers.d/vacuum-walld: restore !requiretty and section comment
- lib/network.py: remove unused _MANAGEMENT_PORTS constant
- webui/api/network.py: remove redundant body[\name\] = name in save_interface

Tests: 332 passing (110 new/updated), ruff clean
2026-06-01 03:15:50 +00:00

521 lines
15 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 lib.network import get_config
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()
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,
)
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 {}
network_config = _safely(get_config, {})
return render_template(
"interfaces.html",
interfaces=fw_state.get("interfaces", []),
network_config=network_config,
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 {}
fw_state = all_status.get("firewall", {}) or {}
return render_template(
"dhcp.html",
config=dm_state.get("config", {}),
status=dm_state.get("status", {}),
leases=dm_state.get("leases", []),
interfaces=fw_state.get("interfaces", []),
)
@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)