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:
+461
-143
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user