bc72db903c
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
521 lines
15 KiB
Python
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)
|