WireGuard access classes, firewall nftables fixes, network sync event refactor

- WireGuard: refactor to multi-interface 'access classes' model; extract config
  generation and helpers into lib/wireguard.py; add per-class up/down endpoints
  and API routes; update UI with class management pages and QR code component
- Firewall: fix zone creation with --new-zone before --set-target; skip
  masquerade on public zone; add masquerade propagation for nftables backend
  so NAT works when internal zones exit via public
- Network: rename sync event subsystem 'network' -> 'networkd'; always stamp
  config hash even when deployment fails (fixes pending-changes detection)
- DHCP: add new API endpoint and update frontend page
- State/Sync: update state collectors and sync buses for new subsystems
- Docs: update API and config documentation for new endpoints and schemas
This commit is contained in:
2026-07-20 03:57:16 +00:00
parent dadabd7954
commit 04417cf05c
19 changed files with 2688 additions and 455 deletions
+461 -143
View File
@@ -2,101 +2,73 @@
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 daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
DELETE_WIREGUARD_PEERS_REMOVE,
GET_WIREGUARD_CLASS_STATUS,
GET_WIREGUARD_CLASSES,
GET_WIREGUARD_CONFIG,
GET_WIREGUARD_PEER_STATUS,
GET_WIREGUARD_PEERS,
GET_WIREGUARD_STATUS,
PATCH_WIREGUARD_CLASSES,
PATCH_WIREGUARD_CONFIG,
POST_WIREGUARD_APPLY,
POST_WIREGUARD_CLASS_INIT_KEYS,
POST_WIREGUARD_CLASSES,
POST_WIREGUARD_CLASSES_UP,
POST_WIREGUARD_CONFIG,
POST_WIREGUARD_DOWN,
POST_WIREGUARD_GENERATE_CLIENT,
POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD,
)
from daemon.server import NotFoundError, refresh_state, registry
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from lib.common import (
_APPLY_HASH_KEY,
config_hash,
deep_merge,
load_json,
run,
run_proc,
save_json,
)
from lib.sync import SyncEvent, bus
from lib.wireguard import (
_class_interface_name,
_class_peers,
_ensure_access_classes,
_wg_conf_path,
generate_class_conf,
generate_class_keypair,
generate_conf,
generate_keypair,
)
from lib.wireguard import (
generate_client_conf as _gen_client_conf,
)
from lib.wireguard import (
get_config as _get_wireguard_config,
)
from lib.wireguard import (
get_peers as _get_wireguard_peers,
)
from lib.wireguard import (
save_config as _save_wireguard_config,
)
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
WG_QUICK_BIN = "wg-quick"
WG_BIN = "wg"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
autoescape=False,
lstrip_blocks=True,
trim_blocks=True,
)
DEFAULT_CONFIG: dict[str, Any] = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {},
}
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached WireGuard state from the global state store."""
from lib.state import state as state_store
return state_store.get("wireguard")
def _get_config() -> dict[str, Any]:
"""Load and merge the WireGuard config with defaults."""
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the WireGuard config to disk."""
save_json(CONFIG_PATH, cfg)
def _generate_conf(cfg: dict[str, Any]) -> str:
"""Render the WireGuard server config file from Jinja template."""
tmpl = ENV.get_template("wireguard.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interface=cfg["interface"],
peers=cfg.get("peers", {}),
)
def _get_wg_state() -> dict[str, Any]:
"""Return cached WireGuard state, or empty dict if not yet loaded."""
wg = _get_state()
if wg is None:
return {}
return wg
from lib.state import state as state_store
wg = state_store.get("wireguard")
return {} if wg is None else wg
# ---------------------------------------------------------------------------
@@ -109,32 +81,52 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
wg = _get_wg_state()
if wg:
return wg.get("config", {})
cfg = _get_config()
cfg = _get_wireguard_config()
safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
if "access_classes" in safe:
for ck, cv in safe["access_classes"].items():
if isinstance(cv, dict):
safe["access_classes"][ck] = dict(cv)
safe["access_classes"][ck].pop("private_key", None)
return safe
@registry.register(POST_WIREGUARD_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/config — replace config, preserving existing private key.
"""POST /wireguard/config — replace config, preserving existing private keys.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
current = _get_config()
current = _get_wireguard_config()
current_key = current.get("interface", {}).get("private_key", "")
if "interface" in body:
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
if current_key:
body.setdefault("interface", {})["private_key"] = current_key
_save_config(body)
# Preserve class private keys
if "access_classes" in body:
current_classes = current.get("access_classes", {})
for ck, cv in body.get("access_classes", {}).items():
if isinstance(cv, dict) and ck in current_classes:
cur_class_pk = current_classes[ck].get("private_key", "")
if cur_class_pk and ck in body["access_classes"]:
body["access_classes"][ck]["private_key"] = cur_class_pk
elif "access_classes" not in body:
body["access_classes"] = current.get("access_classes", {})
_save_wireguard_config(body)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_saved"})
)
@@ -151,13 +143,21 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""
if not body:
raise ValueError("Request body required")
if "interface" in body:
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
current = _get_config()
# Strip class private keys from patch body
if "access_classes" in body:
for _ck, cv in body["access_classes"].items():
if isinstance(cv, dict):
cv.pop("private_key", None)
current = _get_wireguard_config()
merged = deep_merge(current, body)
_save_config(merged)
_save_wireguard_config(merged)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_patched"})
)
@@ -167,36 +167,99 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register(POST_WIREGUARD_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo."""
cfg = _get_config()
conf_text = _generate_conf(cfg)
_save_config(cfg)
local_tmp = Path("/run/vacuum-wall/wg0.conf.tmp")
local_tmp.parent.mkdir(exist_ok=True)
local_tmp.write_text(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
cfg_after = _get_config()
"""POST /wireguard/apply — render config, write to disk, bring up tunnels via sudo.
In multi-interface mode, applies each class with assigned peers
independently. Falls back to legacy single-interface mode.
"""
cfg = _get_wireguard_config()
classes = cfg.get("access_classes", {})
affected: list[str] = []
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 as e:
logger.warning("Skipping class '%s' during apply: %s", class_key, e)
continue
ifname = _class_interface_name(class_key)
conf_path = _wg_conf_path(ifname)
local_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp")
local_tmp.parent.mkdir(exist_ok=True)
local_tmp.write_text(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, check=False)
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
affected.append(f"{ifname}.conf")
if not affected:
# Legacy single-interface mode
conf_text = generate_conf(cfg)
ifname = cfg["interface"]["name"]
conf_path = _wg_conf_path(ifname)
local_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp")
local_tmp.parent.mkdir(exist_ok=True)
local_tmp.write_text(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, check=False)
logger.info("WireGuard tunnel '%s' brought up", ifname)
cfg_after = _get_wireguard_config()
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
_save_config(cfg_after)
_save_wireguard_config(cfg_after)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"applied": True, "synced": sync_result.affected_subsystems}
return {
"applied": True,
"synced": sync_result.affected_subsystems,
"interfaces": affected,
}
@registry.register(POST_WIREGUARD_DOWN)
def down(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo."""
cfg = _get_config()
name = cfg["interface"]["name"]
run([WG_QUICK_BIN, "down", name], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", name)
"""POST /wireguard/down — bring down all WireGuard tunnel interfaces."""
cfg = _get_wireguard_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
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
# 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
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"})
)
@@ -204,39 +267,160 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
return {"down": True}
@registry.register(POST_WIREGUARD_CLASSES_UP)
def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/classes/<key>/up — bring up a single class's tunnel.
Raises:
ValueError: When body or key is missing.
NotFoundError: When class does not exist.
"""
if not body:
raise ValueError("Request body required")
class_key = body.get("class_key", "").strip()
if not class_key:
raise ValueError("'class_key' is required")
cfg = _get_wireguard_config()
class_cfg = cfg.get("access_classes", {}).get(class_key)
if not class_cfg:
raise NotFoundError(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_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp")
local_tmp.parent.mkdir(exist_ok=True)
local_tmp.write_text(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, check=False)
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_up", "class_key": class_key})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"up": True, "interface": ifname}
@registry.register(DELETE_WIREGUARD_CLASSES_DOWN)
def class_down(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /wireguard/classes/<key>/down — bring down a single class's tunnel.
Raises:
ValueError: When body or key is missing.
"""
if not body:
raise ValueError("Request body required")
class_key = body.get("class_key", "").strip()
if not class_key:
raise ValueError("'class_key' is required")
cfg = _get_wireguard_config()
if class_key not in cfg.get("access_classes", {}):
raise NotFoundError(f"Access class '{class_key}' not found")
ifname = _class_interface_name(class_key)
try:
run([WG_QUICK_BIN, "down", ifname], sudo=True)
logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname)
except Exception:
pass
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_down", "class_key": class_key})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"down": True, "interface": ifname}
@registry.register(GET_WIREGUARD_STATUS)
def status(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/status — return current WireGuard status from cache."""
wg = _get_wg_state()
if wg:
return wg.get("status", {"up": False, "interface": {}, "peers": []})
return {"up": False, "interface": {}, "peers": []}
return wg.get(
"status", {"up": False, "interface": {}, "peers": [], "classes": {}}
)
return {"up": False, "interface": {}, "peers": [], "classes": {}}
@registry.register(POST_WIREGUARD_INITIALIZE)
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/initialize — generate keypair and store in config (idempotent)."""
cfg = _get_config()
"""POST /wireguard/initialize — generate keypair and store in config (idempotent).
Also generates key pairs for each access class interface.
"""
cfg = _get_wireguard_config()
if cfg["interface"].get("private_key"):
_ensure_access_classes(cfg)
# Generate keys for classes that need them (idempotent, saves internally)
for class_key in cfg.get("access_classes", {}):
generate_class_keypair(class_key)
return {"initialized": False, "reason": "already initialized"}
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()
cfg["interface"]["private_key"] = private_key
cfg["interface"]["public_key"] = public_key
_save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16])
# Fresh init
priv, pub = generate_keypair()
cfg["interface"]["private_key"] = priv
cfg["interface"]["public_key"] = pub
_ensure_access_classes(cfg)
# Generate class keys
for class_key in cfg.get("access_classes", {}):
generate_class_keypair(class_key)
_save_wireguard_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "initialized"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
safe = dict(cfg)
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
for ck, cv in safe.get("access_classes", {}).items():
if isinstance(cv, dict):
safe["access_classes"][ck] = dict(cv)
safe["access_classes"][ck].pop("private_key", None)
return {"initialized": True, "config": safe}
@registry.register(POST_WIREGUARD_CLASS_INIT_KEYS)
def init_class_keys(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/classes/keys/<key> — generate keypair for a class.
Raises:
ValueError: When body or key is missing.
NotFoundError: When class does not exist.
"""
if not body:
raise ValueError("Request body required")
class_key = body.get("class_key", "").strip()
if not class_key:
raise ValueError("'class_key' is required")
cfg = _get_wireguard_config()
if class_key not in cfg.get("access_classes", {}):
raise NotFoundError(f"Access class '{class_key}' not found")
class_cfg = cfg["access_classes"][class_key]
if class_cfg.get("private_key"):
return {
"generated": False,
"class_key": class_key,
"reason": "already has keys",
}
_, pub = generate_class_keypair(class_key)
return {"generated": True, "class_key": class_key, "public_key": pub}
@registry.register(POST_WIREGUARD_PEERS_ADD)
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/peers/add — add new peer or update existing one.
@@ -249,34 +433,39 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
cfg = _get_config()
cfg = _get_wireguard_config()
peers = cfg.setdefault("peers", {})
allowed_ips = body.get("allowed_ips", [])
if name in peers:
peer = peers[name]
peer["endpoint"] = body.get("endpoint")
peer["allowed_ips"] = allowed_ips
peer["persistent_keepalive"] = body.get("persistent_keepalive")
if body.get("preshared_key") is not None:
if "endpoint" in body:
peer["endpoint"] = body["endpoint"]
if "allowed_ips" in body:
peer["allowed_ips"] = body["allowed_ips"]
if "persistent_keepalive" in body:
peer["persistent_keepalive"] = body["persistent_keepalive"]
if "preshared_key" in body:
peer["preshared_key"] = body["preshared_key"]
if "description" in body:
peer["description"] = body["description"]
if "access_class" in body:
peer["access_class"] = body["access_class"]
logger.info("WireGuard peer '%s' updated", name)
_peer_action = "peer_updated"
else:
res = run_proc([WG_BIN, "genkey"], sudo=True)
priv = res.stdout.strip()
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=priv)
pub = res2.stdout.strip()
priv, pub = generate_keypair()
peers[name] = {
"public_key": pub,
"private_key": priv,
"endpoint": body.get("endpoint"),
"allowed_ips": allowed_ips,
"allowed_ips": body.get("allowed_ips", []),
"persistent_keepalive": body.get("persistent_keepalive"),
"preshared_key": body.get("preshared_key"),
"description": body.get("description", ""),
"access_class": body.get("access_class"),
}
logger.info("WireGuard peer '%s' added", name)
_peer_action = "peer_added"
_save_config(cfg)
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": _peer_action, "peer_name": name}
@@ -301,12 +490,12 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
cfg = _get_config()
cfg = _get_wireguard_config()
peers = cfg.setdefault("peers", {})
if name not in peers:
raise NotFoundError(f"Peer '{name}' not found")
del peers[name]
_save_config(cfg)
_save_wireguard_config(cfg)
logger.info("WireGuard peer '%s' removed", name)
sync_result = bus.emit(
SyncEvent(
@@ -323,14 +512,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
wg = _get_wg_state()
if wg:
return wg.get("peers", [])
cfg = _get_config()
result: list[dict[str, Any]] = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
entry.pop("private_key", None)
result.append(entry)
return result
return _get_wireguard_peers()
@registry.register(GET_WIREGUARD_PEER_STATUS)
@@ -342,10 +524,32 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
return []
@registry.register(GET_WIREGUARD_CLASS_STATUS)
def get_class_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /wireguard/classes/<key>/status — return status for a specific class interface.
Raises:
NotFoundError: When class does not exist.
"""
wg = _get_wg_state()
if wg:
classes = wg.get("status", {}).get("classes", {})
if body:
key = body.get("class_key", "")
if key and key not in classes:
raise NotFoundError(f"Access class '{key}' not found")
return classes.get(key, {"up": False, "interface": {}, "peers": []})
return classes
return {"up": False, "interface": {}, "peers": []}
@registry.register(POST_WIREGUARD_GENERATE_CLIENT)
def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/generate-client — render client-side WireGuard config for a peer.
Uses the peer's access class subnet and port to derive the correct
server address and endpoint port.
Raises:
ValueError: When body, name, or server_endpoint is missing.
NotFoundError: When peer does not exist or has no private key.
@@ -358,30 +562,144 @@ def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str
server_endpoint = body.get("server_endpoint", "")
if not server_endpoint:
raise ValueError("'server_endpoint' is required")
cfg = _get_config()
cfg = _get_wireguard_config()
if name not in cfg.get("peers", {}):
raise NotFoundError(f"Peer '{name}' not found")
peer = cfg["peers"][name]
client_priv = peer.get("private_key", "")
if not client_priv:
if not peer.get("private_key"):
raise NotFoundError(f"Peer '{name}' has no private key")
iface = cfg["interface"]
sorted_peers = sorted(cfg.get("peers", {}).keys())
peer_index = sorted_peers.index(name) + 2
srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24"
addr_part, prefix = srv_addr.rsplit("/", 1)
prefix_base = addr_part.rsplit(".", 1)[0]
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
tmpl = ENV.get_template("wireguard-client.conf")
conf = tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
conf = _gen_client_conf(
peer_name=name,
client_priv=client_priv,
client_addr=client_addr,
server_pubkey=iface.get("public_key", ""),
server_endpoint=server_endpoint,
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"),
server_pubkey=cfg["interface"].get("public_key"),
)
return {"config": conf}
# ---------------------------------------------------------------------------
# Access Classes
@registry.register(GET_WIREGUARD_CLASSES)
def list_classes(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/classes — return access classes (with private keys stripped)."""
cfg = _get_wireguard_config()
classes = cfg.get("access_classes", {})
safe: dict[str, Any] = {}
for k, v in classes.items():
if isinstance(v, dict):
entry = dict(v)
entry.pop("private_key", None)
safe[k] = entry
else:
safe[k] = v
return safe
@registry.register(POST_WIREGUARD_CLASSES)
def create_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/classes — create a new access class with phase-2 fields.
Raises:
ValueError: When body is missing or key conflicts.
"""
if not body:
raise ValueError("Request body required")
key = body.get("key", "").strip()
if not key:
raise ValueError("'key' is required")
if not key.isalnum() or not key.islower():
raise ValueError("'key' must be lowercase alphanumeric")
name = body.get("name", key)
description = body.get("description", "")
cfg = _get_wireguard_config()
classes = cfg.setdefault("access_classes", {})
if key in classes:
raise ConflictError(f"Access class '{key}' already exists")
classes[key] = {
"name": name,
"description": description,
"subnet": body.get("subnet"),
"listen_port": body.get("listen_port"),
"lan_access": body.get("lan_access", False),
"private_key": "",
"public_key": "",
}
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_created"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
out = dict(classes[key])
out.pop("private_key", None)
return out
@registry.register(PATCH_WIREGUARD_CLASSES)
def update_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /wireguard/classes/<key> — update an access class.
Raises:
ValueError: When body is missing.
NotFoundError: When class does not exist.
"""
if not body:
raise ValueError("Request body required")
key = body.get("key", "").strip()
if not key:
raise ValueError("'key' is required")
cfg = _get_wireguard_config()
classes = cfg.setdefault("access_classes", {})
if key not in classes:
raise NotFoundError(f"Access class '{key}' not found")
class_cfg = classes[key]
for field in ("name", "description", "subnet", "listen_port", "lan_access"):
if field in body:
class_cfg[field] = body[field]
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_updated"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
out = dict(classes[key])
out.pop("private_key", None)
return {"key": key, **out}
@registry.register(DELETE_WIREGUARD_CLASSES)
def delete_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /wireguard/classes/<key> — remove an access class.
Raises:
ValueError: When body is missing.
NotFoundError: When class does not exist.
ConflictError: When peers still reference the class.
"""
if not body:
raise ValueError("Request body required")
key = body.get("key", "").strip()
if not key:
raise ValueError("'key' is required")
cfg = _get_wireguard_config()
classes = cfg.setdefault("access_classes", {})
if key not in classes:
raise NotFoundError(f"Access class '{key}' not found")
# Check if any peers reference this class
peers_reusing = [
name
for name, info in cfg.get("peers", {}).items()
if info.get("access_class") == key
]
if peers_reusing:
raise ConflictError(
f"Cannot delete class '{key}': {len(peers_reusing)} peer(s) reference it: {', '.join(peers_reusing)}"
)
del classes[key]
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_deleted"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"key": key}