Files
mteehan faa076370d refactor: daemon collectors, thin webui proxies, pure config reads
- move state collectors from lib/state.py to daemon/collectors/ (7
  modules, registration side-effect; daemon/server.py imports the
  package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
  collapses the try/except daemon-proxy boilerplate in all 8
  blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
  truth for zone interfaces (absent key = empty, no hands-off
  zones); pure validate_coverage() enforced at save (400) and apply
  (409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
  new lib/bootstrap.py creates runtime dirs and persists the
  one-shot nginx legacy migration at daemon start, after
  system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
  /status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
  log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
  HARDEN.md dropped (plan implemented); apply-confirm force wording

Tests: 917 passed; ruff check + format clean.
2026-09-03 00:40:56 +00:00

757 lines
23 KiB
Python

"""WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
Generates wg-quick configurations, manages peers, and controls
the WireGuard tunnel interface. Supports multi-interface mode where
each access class gets its own WireGuard interface.
"""
import logging
import os
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.common import deep_merge, load_json, run, run_proc, save_json
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
WG_QUICK_BIN = "wg-quick"
WG_BIN = "wg"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
autoescape=False,
lstrip_blocks=True,
trim_blocks=True,
)
def _wg_conf_path(ifname: str) -> str:
"""Return the system WG conf path for an interface name."""
return f"/etc/wireguard/{ifname}.conf"
def _default_class_fields() -> dict[str, Any]:
"""Return the default set of fields for an access class entry."""
return {
"name": "",
"description": "",
"subnet": None,
"listen_port": None,
"lan_access": False,
"private_key": "",
"public_key": "",
}
DEFAULT_CONFIG: dict[str, Any] = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"server_endpoint": "",
"description": "",
"post_up": None,
"post_down": None,
},
"access_classes": {
"full": {
"name": "Full LAN Access",
"description": "Peers get full access to internal networks",
"subnet": "10.137.0.0/24",
"listen_port": 51820,
"lan_access": True,
"private_key": "",
"public_key": "",
},
"internet": {
"name": "Internet Only",
"description": "Peers can only reach the internet",
"subnet": "10.137.1.0/24",
"listen_port": 51821,
"lan_access": False,
"private_key": "",
"public_key": "",
},
},
"peers": {},
}
# --- Core config persistence ---
def get_config() -> dict[str, Any]:
"""Load the current WireGuard configuration from the JSON store."""
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
def save_config(cfg: dict[str, Any]) -> None:
"""Persist *cfg* to the JSON store atomically."""
save_json(CONFIG_PATH, cfg)
# --- Key generation ---
def generate_keypair() -> tuple[str, str]:
"""Generate a WireGuard private/public key pair using ``wg`` CLI."""
res = run_proc([WG_BIN, "genkey"], sudo=False)
private_key = res.stdout.strip()
res2 = run_proc([WG_BIN, "pubkey"], sudo=False, input=private_key)
public_key = res2.stdout.strip()
return private_key, public_key
# --- Helpers ---
def _class_interface_name(class_key: str) -> str:
"""Derive interface name for an access class key."""
return f"wg-{class_key}"
def _class_zone_name(class_key: str) -> str:
"""Derive firewall zone name for an access class key."""
return f"vpn-{class_key}"
def get_class_interface_name(class_key: str) -> str:
"""Public wrapper for `_class_interface_name`."""
return _class_interface_name(class_key)
def get_class_zone_name(class_key: str) -> str:
"""Public wrapper for `_class_zone_name`."""
return _class_zone_name(class_key)
def _class_peers(cfg: dict[str, Any], class_key: str) -> dict[str, Any]:
"""Return peers assigned to a given access class."""
return {
name: info
for name, info in cfg.get("peers", {}).items()
if isinstance(info, dict) and info.get("access_class") == class_key
}
# --- wg-<class>.conf generation ---
def generate_class_conf(cfg: dict[str, Any], class_key: str) -> str | None:
"""Render a wg-quick config file for a single access class.
Returns ``None`` when the class has no peers assigned.
"""
classes = cfg.get("access_classes", {})
class_cfg = classes.get(class_key)
if not class_cfg or not isinstance(class_cfg, dict):
return None
peers = _class_peers(cfg, class_key)
if not peers:
return None
ifname = _class_interface_name(class_key)
subnet = class_cfg.get("subnet")
if not subnet:
subnet = "10.137.0.0/24"
_, prefix = subnet.rsplit("/", 1)
base = subnet.rsplit(".", 1)[0]
addr = f"{base}.1/{prefix}"
listen_port = class_cfg.get("listen_port")
if not listen_port:
listen_port = 51820
private_key = class_cfg.get("private_key", "")
if not private_key:
raise ValueError(
f"Access class '{class_key}' has no private key — generate one first."
)
class_iface = {
"name": ifname,
"listen_port": listen_port,
"private_key": private_key,
"addresses": [addr],
"post_up": cfg.get("interface", {}).get("post_up"),
"post_down": cfg.get("interface", {}).get("post_down"),
}
tmpl = ENV.get_template("wireguard.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interface=class_iface,
peers=peers,
)
def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a valid wg-quick config file from *cfg* using Jinja2.
Legacy single-interface mode — uses the top-level ``interface`` block
and peers without an ``access_class`` assigned.
"""
fallback_peers = {
n: p
for n, p in cfg.get("peers", {}).items()
if isinstance(p, dict) and not p.get("access_class")
}
tmpl = ENV.get_template("wireguard.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interface=cfg["interface"],
peers=fallback_peers if fallback_peers else {},
)
# --- Apply / down ---
def apply() -> None:
"""Write the current config to disk and bring tunnels up with wg-quick.
In multi-interface mode, applies each access class's interface independently.
Falls back to legacy single-interface mode when no classes have peers.
"""
cfg = get_config()
classes = cfg.get("access_classes", {})
applied = False
for class_key in classes:
class_cfg = classes.get(class_key)
if not class_cfg or not isinstance(class_cfg, dict):
continue
if not _class_peers(cfg, class_key):
continue
try:
conf_text = generate_class_conf(cfg, class_key)
if not conf_text:
continue
except ValueError:
continue
ifname = _class_interface_name(class_key)
conf_path = _wg_conf_path(ifname)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
local_tmp = local_dir / f"{ifname}.conf.tmp"
with open(local_tmp, "w") as f:
f.write(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
run(["chown", "root:root", conf_path], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
applied = True
if applied:
save_config(cfg)
elif applied is False:
# Legacy single-interface fallback
legacy_apply(cfg)
def legacy_apply(cfg: dict[str, Any]) -> None:
"""Legacy single-interface apply."""
conf_text = generate_conf(cfg)
save_config(cfg)
ifname = cfg["interface"]["name"]
conf_path = _wg_conf_path(ifname)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
local_tmp = local_dir / f"{ifname}.conf.tmp"
with open(local_tmp, "w") as f:
f.write(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
run(["chown", "root:root", conf_path], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", ifname)
def apply_class(class_key: str) -> None:
"""Apply config for a single access class interface."""
cfg = get_config()
class_cfg = cfg.get("access_classes", {}).get(class_key)
if not class_cfg or not isinstance(class_cfg, dict):
raise ValueError(f"Access class '{class_key}' not found")
conf_text = generate_class_conf(cfg, class_key)
if not conf_text:
raise ValueError(f"No peers assigned to class '{class_key}'")
ifname = _class_interface_name(class_key)
conf_path = _wg_conf_path(ifname)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
local_tmp = local_dir / f"{ifname}.conf.tmp"
with open(local_tmp, "w") as f:
f.write(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
run(["chown", "root:root", conf_path], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True)
save_config(cfg)
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
def down() -> None:
"""Bring all WireGuard tunnel interfaces down.
In multi-interface mode, brings down each class interface with peers.
"""
cfg = get_config()
classes = cfg.get("access_classes", {})
for class_key in classes:
class_cfg = classes.get(class_key)
if not class_cfg or not isinstance(class_cfg, dict):
continue
if ifname := _class_interface_name(class_key):
try:
run([WG_QUICK_BIN, "down", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", ifname)
except Exception:
pass
# Also try legacy interface (skip if name matches any class interface)
ifname = cfg["interface"].get("name", "")
if ifname:
class_names = {_class_interface_name(k) for k in classes}
if ifname not in class_names:
try:
run([WG_QUICK_BIN, "down", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", ifname)
except Exception:
pass
def down_class(class_key: str) -> None:
"""Bring down a single access class interface."""
ifname = _class_interface_name(class_key)
run([WG_QUICK_BIN, "down", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", ifname)
# --- Status ---
def status() -> dict[str, Any]:
"""Query the live tunnel state via ``wg show``.
In multi-interface mode, collects status for all class interfaces.
Returns combined status dict keyed by interface name.
"""
cfg = get_config()
result: dict[str, Any] = {
"up": False,
"interface": {},
"peers": [],
"classes": {},
}
# Collect per-class status
classes = cfg.get("access_classes", {})
for class_key in classes:
ifname = _class_interface_name(class_key)
try:
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
if res.returncode != 0:
result["classes"][class_key] = {"up": False, "peers": []}
continue
class_status = parse_wg_show_output(res.stdout.strip())
result["classes"][class_key] = class_status
if class_status["up"]:
result["up"] = True
except Exception:
result["classes"][class_key] = {"up": False, "peers": []}
# Legacy single-interface status (still collected for backward compat)
try:
ifname = cfg["interface"].get("name", "wg0")
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
if res.returncode == 0:
parsed = parse_wg_show_output(res.stdout.strip())
result["up"] = parsed["up"]
result["interface"] = parsed.get("interface", {})
result["peers"] = parsed.get("peers", [])
except Exception:
pass
return result
def parse_wg_show_output(raw: str) -> dict[str, Any]:
"""Parse ``wg show`` output into structured dict.
Returns ``{"up", "interface", "peers"}`` where *interface* carries
``public_key``, ``listen_port`` and (when present) ``fwmark``.
"""
result: dict[str, Any] = {
"up": False,
"interface": {},
"peers": [],
}
current_peer: dict[str, Any] | None = None
peers: list[dict[str, Any]] = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("interface:"):
result["up"] = True
result["interface"] = {}
current_peer = None
continue
if line.startswith("public key:"):
result["interface"]["public_key"] = line.split(":", 1)[1].strip()
continue
if line.startswith("listening port:"):
val = line.split(":", 1)[1].strip()
result["interface"]["listen_port"] = int(val)
continue
if line.startswith("fwmark:"):
result["interface"]["fwmark"] = line.split(":", 1)[1].strip()
continue
if line.startswith("peer:"):
cur_key = line.split(":", 1)[1].strip()
current_peer = {
"public_key": cur_key,
"endpoint": None,
"allowed_ips": [],
"latest_handshake": None,
"transfer_received": "0",
"transfer_sent": "0",
"persistent_keepalive": None,
}
peers.append(current_peer)
continue
if current_peer is None:
continue
if line.startswith("endpoint:"):
current_peer["endpoint"] = line.split(":", 1)[1].strip()
continue
if line.startswith("allowed ips:"):
vals = line.split(":", 1)[1].strip().split(", ")
current_peer["allowed_ips"] = vals
continue
if line.startswith("latest handshake:"):
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
continue
if line.startswith("transfer:"):
rest = line.split(":", 1)[1].strip()
parts = rest.split(", ")
if parts:
current_peer["transfer_received"] = parts[0].strip()
if len(parts) > 1:
current_peer["transfer_sent"] = parts[1].strip()
continue
if line.startswith("persistent-keepalive:"):
val = line.split(":", 1)[1].strip()
try:
current_peer["persistent_keepalive"] = int(val)
except ValueError:
current_peer["persistent_keepalive"] = None
result["peers"] = peers
return result
# --- Peer management ---
_UNSET = object()
def add_peer(
name: str,
endpoint: str | None | object = _UNSET,
allowed_ips: list[str] | None | object = _UNSET,
persistent_keepalive: int | None | object = _UNSET,
preshared_key: str | None = None,
description: str | None = None,
access_class: str | None = None,
) -> dict[str, Any]:
"""Add (or update) a peer in the configuration."""
cfg = get_config()
peers = cfg.setdefault("peers", {})
if name in peers:
peer = peers[name]
if endpoint is not _UNSET:
peer["endpoint"] = endpoint
if allowed_ips is not _UNSET:
peer["allowed_ips"] = allowed_ips if allowed_ips is not None else []
if persistent_keepalive is not _UNSET:
peer["persistent_keepalive"] = persistent_keepalive
if preshared_key is not None:
peer["preshared_key"] = preshared_key
if description is not None:
peer["description"] = description
if access_class is not None:
peer["access_class"] = access_class
logger.info("WireGuard peer '%s' updated", name)
else:
priv, pub = generate_keypair()
peer = {
"public_key": pub,
"private_key": priv,
"endpoint": endpoint if endpoint is not _UNSET else None,
"allowed_ips": allowed_ips if allowed_ips is not _UNSET else [],
"persistent_keepalive": persistent_keepalive
if persistent_keepalive is not _UNSET
else None,
"preshared_key": preshared_key,
"description": description,
"access_class": access_class,
}
peers[name] = peer
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
save_config(cfg)
peer_out = dict(peer)
peer_out.pop("private_key", None)
return peer_out
def remove_peer(name: str) -> None:
"""Remove a peer from the configuration by name."""
cfg = get_config()
cfg.setdefault("peers", {}).pop(name, None)
save_config(cfg)
logger.info("WireGuard peer '%s' removed", name)
def get_peers() -> list[dict[str, Any]]:
"""List all configured peers (from the JSON store, *not* live)."""
cfg = get_config()
peers: list[dict[str, Any]] = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
entry.pop("private_key", None)
peers.append(entry)
return peers
def get_peer_status() -> list[dict[str, Any]]:
"""Return live peer status from ``wg show`` for all interfaces."""
st = status()
all_peers: list[dict[str, Any]] = []
for _class_key, class_st in st.get("classes", {}).items():
for p in class_st.get("peers", []):
merged = dict(p)
merged["access_class"] = _class_key
all_peers.append(merged)
if not all_peers:
all_peers = st.get("peers", [])
return all_peers
# --- Client config generation ---
def generate_client_conf(
peer_name: str,
server_endpoint: str,
server_pubkey: str | None = None,
) -> str:
"""Build a client-side wg-quick config snippet for *peer_name*.
Uses the peer's access class to derive server address from the class
subnet and the class's listen port.
"""
cfg = get_config()
iface = cfg["interface"]
peer = cfg["peers"].get(peer_name)
if peer is None:
raise KeyError(f"Peer '{peer_name}' not found in configuration")
client_priv = peer.get("private_key", "")
if not client_priv:
raise ValueError(
f"Peer '{peer_name}' has no private key — cannot generate client config."
)
# Determine class info for address/port
access_class = peer.get("access_class")
sorted_peers = sorted(cfg.get("peers", {}).keys())
peer_index = sorted_peers.index(peer_name) + 2
if access_class and access_class in cfg.get("access_classes", {}):
class_cfg = cfg["access_classes"][access_class]
subnet = class_cfg.get("subnet", "10.137.0.0/24")
listen_port = class_cfg.get("listen_port", 51820)
else:
subnet = iface.get("addresses", ["10.137.0.1/24"])[0]
listen_port = iface.get("listen_port", 51820)
if "/" not in subnet:
subnet = f"{subnet}/24"
addr = subnet.rsplit(".", 1)[0]
prefix = subnet.rsplit("/", 1)[1]
client_addr = f"{addr}.{peer_index}/{prefix}"
sk = server_pubkey or iface.get("public_key", "")
ep = server_endpoint or iface.get("server_endpoint", "")
if ep and listen_port:
host = ep.split(":")[0]
ep = f"{host}:{listen_port}"
tmpl = ENV.get_template("wireguard-client.conf")
conf = tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
peer_name=peer_name,
client_priv=client_priv,
client_addr=client_addr,
server_pubkey=sk,
server_endpoint=ep,
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"),
)
logger.info("Client config generated for peer '%s'", peer_name)
return conf
# --- Interface-level setters ---
def set_listen_port(port: int) -> None:
"""Update the server listen port in the stored configuration."""
if not (1 <= port <= 65535):
raise ValueError("Listen port must be in range 1..65535")
cfg = get_config()
cfg["interface"]["listen_port"] = port
save_config(cfg)
logger.info("WireGuard listen port set to %d", port)
def set_post_up(cmd: str | None) -> None:
"""Set (or clear) the PostUp hook command."""
cfg = get_config()
cfg["interface"]["post_up"] = cmd
save_config(cfg)
def set_post_down(cmd: str | None) -> None:
"""Set (or clear) the PostDown hook command."""
cfg = get_config()
cfg["interface"]["post_down"] = cmd
save_config(cfg)
# --- Class key generation ---
def generate_class_keypair(class_key: str) -> tuple[str, str]:
"""Generate a key pair for an access class interface."""
cfg = get_config()
classes = cfg.setdefault("access_classes", {})
if class_key not in classes:
raise ValueError(f"Access class '{class_key}' not found")
class_cfg = classes[class_key]
if class_cfg.get("private_key"):
return class_cfg["private_key"], class_cfg["public_key"]
priv, pub = generate_keypair()
class_cfg["private_key"] = priv
class_cfg["public_key"] = pub
save_config(cfg)
logger.info("Key pair generated for class '%s'", class_key)
return priv, pub
# --- Initialise ---
def _ensure_class_defaults(cfg: dict[str, Any]) -> None:
"""Ensure access classes have required Phase-2 fields."""
classes = cfg.setdefault("access_classes", {})
for _key, c in classes.items():
if not isinstance(c, dict):
continue
for field, default in _default_class_fields().items():
if field not in c:
c[field] = default
def _ensure_access_classes(cfg: dict[str, Any]) -> None:
"""Pre-seed default access classes if missing or empty (idempotent).
Also upgrades existing classes with Phase-2 fields.
"""
classes = cfg.setdefault("access_classes", {})
if not classes:
full_defaults = deepcopy(DEFAULT_CONFIG["access_classes"])
classes.update(full_defaults)
return
_ensure_class_defaults(cfg)
def initialize() -> dict[str, Any]:
"""Perform first-time WireGuard setup."""
cfg = get_config()
if cfg["interface"].get("private_key"):
_ensure_access_classes(cfg)
save_config(cfg)
return cfg
priv, pub = generate_keypair()
cfg["interface"]["private_key"] = priv
cfg["interface"]["public_key"] = pub
_ensure_access_classes(cfg)
save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
return cfg
__all__ = [
"DEFAULT_CONFIG",
"add_peer",
"apply",
"apply_class",
"down",
"down_class",
"generate_class_conf",
"generate_class_keypair",
"generate_client_conf",
"generate_conf",
"generate_keypair",
"get_class_interface_name",
"get_class_zone_name",
"get_config",
"get_peer_status",
"get_peers",
"initialize",
"parse_wg_show_output",
"remove_peer",
"save_config",
"set_listen_port",
"set_post_down",
"set_post_up",
"status",
]