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
+49 -14
View File
@@ -136,16 +136,22 @@ def _config_apply() -> dict[str, Any]:
need_create = zone_name not in available need_create = zone_name not in available
if need_create: if need_create:
target = _normalize_target(zone_cfg.get("target", "DEFAULT")) # Create new zone first (--new-zone is required before --set-target)
run( run(
[ ["firewall-cmd", f"--new-zone={zone_name}", "--permanent"],
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={target}",
"--permanent",
],
sudo=True, sudo=True,
) )
target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
if target != "default":
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={target}",
"--permanent",
],
sudo=True,
)
_reload() _reload()
else: else:
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT")) desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
@@ -223,13 +229,15 @@ def _config_apply() -> dict[str, Any]:
) )
# Step 4: Toggle masquerade if explicitly set (None means "don't change"). # Step 4: Toggle masquerade if explicitly set (None means "don't change").
mq = zone_cfg.get("masquerade", False) # Skip 'public' — Step 7 handles masquerade propagation for nftables.
if mq is not None: if zone_name != "public":
action = "--add-masquerade" if mq else "--remove-masquerade" mq = zone_cfg.get("masquerade", False)
run( if mq is not None:
["firewall-cmd", f"--zone={zone_name}", action, "--permanent"], action = "--add-masquerade" if mq else "--remove-masquerade"
sudo=True, run(
) ["firewall-cmd", f"--zone={zone_name}", action, "--permanent"],
sudo=True,
)
# Step 5: Reconcile rich rules — remove all current, add desired. # Step 5: Reconcile rich rules — remove all current, add desired.
# Note: firewall-cmd doesn't track rule IDs for rich rules, so we # Note: firewall-cmd doesn't track rule IDs for rich rules, so we
@@ -296,6 +304,33 @@ def _config_apply() -> dict[str, Any]:
applied.append(zone_name) applied.append(zone_name)
# Step 7: Ensure masquerade propagation for nftables backend.
# With firewalld's nftables backend, POSTROUTING policy chains route traffic
# to the OUTPUT interface's zone chain. Traffic from internal zones (eth1)
# exiting through public (eth0) hits public's POSTROUTING chain, not
# internal's. If any non-public zone has masquerade enabled but the public
# zone doesn't, NAT silently fails — so propagate masquerade to public.
_any_non_public_mq = any(
z.get("masquerade", False) for zn, z in cfg_zones.items() if zn != "public"
)
_public_mq = cfg_zones.get("public", {}).get("masquerade", False)
if _any_non_public_mq and not _public_mq:
logger.info("Propagating masquerade to public zone for nftables compatibility")
run(
["firewall-cmd", "--zone=public", "--add-masquerade", "--permanent"],
sudo=True,
)
cfg.setdefault("zones", {}).setdefault("public", {})["masquerade"] = True
_save_config(cfg)
elif not _any_non_public_mq and _public_mq:
logger.info("No non-public zone needs masquerade, removing from public zone")
run(
["firewall-cmd", "--zone=public", "--remove-masquerade", "--permanent"],
sudo=True,
)
cfg.setdefault("zones", {}).setdefault("public", {})["masquerade"] = False
_save_config(cfg)
_reload() _reload()
full_state = { full_state = {
"active_zones": {}, "active_zones": {},
+12 -10
View File
@@ -214,16 +214,18 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
) )
logger.info("Interface '%s' config saved (applied=%s)", name, deployed) logger.info("Interface '%s' config saved (applied=%s)", name, deployed)
if deployed: # Always stamp the hash so pending-changes detection stays current
cfg_after = get_config() # even when deployment fails (e.g. in containerized environments).
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) # The hash represents the JSON config state, not the system state.
save_config(cfg_after) cfg_after = get_config()
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
save_config(cfg_after)
sync_result = bus.emit( sync_result = bus.emit(
SyncEvent( SyncEvent(
"network", "config_saved", {"action": "interface_saved", "interface": name} "networkd", "config_saved", {"action": "interface_saved", "interface": name}
) )
) )
refresh_state(["network", *sync_result.affected_subsystems]) refresh_state(["networkd", *sync_result.affected_subsystems])
return { return {
"name": name, "name": name,
"applied": deployed, "applied": deployed,
@@ -296,9 +298,9 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
save_config(cfg_after) save_config(cfg_after)
sync_result = bus.emit( sync_result = bus.emit(
SyncEvent("network", "config_saved", {"action": "config_applied"}) SyncEvent("networkd", "config_saved", {"action": "config_applied"})
) )
refresh_state(["network", *sync_result.affected_subsystems]) refresh_state(["networkd", *sync_result.affected_subsystems])
logger.info( logger.info(
"Network config applied: %d interfaces, %d stale cleaned", "Network config applied: %d interfaces, %d stale cleaned",
@@ -364,7 +366,7 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
logger.info("sysctl %s set to %s", name, value) logger.info("sysctl %s set to %s", name, value)
sync_result = bus.emit( sync_result = bus.emit(
SyncEvent("network", "config_saved", {"action": "sysctl_set", "name": name}) SyncEvent("networkd", "config_saved", {"action": "sysctl_set", "name": name})
) )
refresh_state(["network", *sync_result.affected_subsystems]) refresh_state(["networkd", *sync_result.affected_subsystems])
return {"name": name, "value": value} return {"name": name, "value": value}
+461 -143
View File
@@ -2,101 +2,73 @@
import logging import logging
import os import os
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.iface import ( from daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
DELETE_WIREGUARD_PEERS_REMOVE, DELETE_WIREGUARD_PEERS_REMOVE,
GET_WIREGUARD_CLASS_STATUS,
GET_WIREGUARD_CLASSES,
GET_WIREGUARD_CONFIG, GET_WIREGUARD_CONFIG,
GET_WIREGUARD_PEER_STATUS, GET_WIREGUARD_PEER_STATUS,
GET_WIREGUARD_PEERS, GET_WIREGUARD_PEERS,
GET_WIREGUARD_STATUS, GET_WIREGUARD_STATUS,
PATCH_WIREGUARD_CLASSES,
PATCH_WIREGUARD_CONFIG, PATCH_WIREGUARD_CONFIG,
POST_WIREGUARD_APPLY, POST_WIREGUARD_APPLY,
POST_WIREGUARD_CLASS_INIT_KEYS,
POST_WIREGUARD_CLASSES,
POST_WIREGUARD_CLASSES_UP,
POST_WIREGUARD_CONFIG, POST_WIREGUARD_CONFIG,
POST_WIREGUARD_DOWN, POST_WIREGUARD_DOWN,
POST_WIREGUARD_GENERATE_CLIENT, POST_WIREGUARD_GENERATE_CLIENT,
POST_WIREGUARD_INITIALIZE, POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD, 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 ( from lib.common import (
_APPLY_HASH_KEY, _APPLY_HASH_KEY,
config_hash, config_hash,
deep_merge, deep_merge,
load_json,
run, run,
run_proc,
save_json,
) )
from lib.sync import SyncEvent, bus 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__) 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_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]: def _get_wg_state() -> dict[str, Any]:
"""Return cached WireGuard state, or empty dict if not yet loaded.""" """Return cached WireGuard state, or empty dict if not yet loaded."""
wg = _get_state() from lib.state import state as state_store
if wg is None:
return {} wg = state_store.get("wireguard")
return wg 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() wg = _get_wg_state()
if wg: if wg:
return wg.get("config", {}) 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} safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
if "interface" in safe: if "interface" in safe:
safe["interface"] = dict(safe["interface"]) safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None) 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 return safe
@registry.register(POST_WIREGUARD_CONFIG) @registry.register(POST_WIREGUARD_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: 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: Raises:
ValueError: When request body is missing. ValueError: When request body is missing.
""" """
if not body: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
current = _get_config()
current = _get_wireguard_config()
current_key = current.get("interface", {}).get("private_key", "") current_key = current.get("interface", {}).get("private_key", "")
if "interface" in body: if "interface" in body:
body = dict(body) body = dict(body)
body["interface"] = dict(body["interface"]) body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None) body["interface"].pop("private_key", None)
if current_key: if current_key:
body.setdefault("interface", {})["private_key"] = 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( sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_saved"}) 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: if not body:
raise ValueError("Request body required") raise ValueError("Request body required")
if "interface" in body: if "interface" in body:
body = dict(body) body = dict(body)
body["interface"] = dict(body["interface"]) body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None) 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) merged = deep_merge(current, body)
_save_config(merged) _save_wireguard_config(merged)
sync_result = bus.emit( sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_patched"}) 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) @registry.register(POST_WIREGUARD_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]: def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo.""" """POST /wireguard/apply — render config, write to disk, bring up tunnels via sudo.
cfg = _get_config()
conf_text = _generate_conf(cfg) In multi-interface mode, applies each class with assigned peers
_save_config(cfg) independently. Falls back to legacy single-interface mode.
local_tmp = Path("/run/vacuum-wall/wg0.conf.tmp") """
local_tmp.parent.mkdir(exist_ok=True) cfg = _get_wireguard_config()
local_tmp.write_text(conf_text) classes = cfg.get("access_classes", {})
os.chmod(local_tmp, 0o600) affected: list[str] = []
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False) for class_key in classes:
local_tmp.unlink(missing_ok=True) class_cfg = classes.get(class_key)
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True) if not class_cfg or not isinstance(class_cfg, dict):
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"]) continue
cfg_after = _get_config() 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) cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
_save_config(cfg_after) _save_wireguard_config(cfg_after)
sync_result = bus.emit( sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_applied"}) SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
) )
refresh_state(["wireguard", *sync_result.affected_subsystems]) 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) @registry.register(POST_WIREGUARD_DOWN)
def down(_request: Any, _body: Any) -> dict[str, Any]: def down(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo.""" """POST /wireguard/down — bring down all WireGuard tunnel interfaces."""
cfg = _get_config() cfg = _get_wireguard_config()
name = cfg["interface"]["name"] classes = cfg.get("access_classes", {})
run([WG_QUICK_BIN, "down", name], sudo=True) for class_key in classes:
logger.info("WireGuard tunnel '%s' brought down", name) 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( sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"}) SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"})
) )
@@ -204,39 +267,160 @@ def down(_request: Any, _body: Any) -> dict[str, Any]:
return {"down": True} 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) @registry.register(GET_WIREGUARD_STATUS)
def status(_request: Any, _body: Any) -> dict[str, Any]: def status(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/status — return current WireGuard status from cache.""" """GET /wireguard/status — return current WireGuard status from cache."""
wg = _get_wg_state() wg = _get_wg_state()
if wg: if wg:
return wg.get("status", {"up": False, "interface": {}, "peers": []}) return wg.get(
return {"up": False, "interface": {}, "peers": []} "status", {"up": False, "interface": {}, "peers": [], "classes": {}}
)
return {"up": False, "interface": {}, "peers": [], "classes": {}}
@registry.register(POST_WIREGUARD_INITIALIZE) @registry.register(POST_WIREGUARD_INITIALIZE)
def initialize(_request: Any, _body: Any) -> dict[str, Any]: def initialize(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/initialize — generate keypair and store in config (idempotent).""" """POST /wireguard/initialize — generate keypair and store in config (idempotent).
cfg = _get_config()
Also generates key pairs for each access class interface.
"""
cfg = _get_wireguard_config()
if cfg["interface"].get("private_key"): 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"} return {"initialized": False, "reason": "already initialized"}
res = run_proc([WG_BIN, "genkey"], sudo=False)
private_key = res.stdout.strip() # Fresh init
res2 = run_proc([WG_BIN, "pubkey"], sudo=False, input=private_key) priv, pub = generate_keypair()
public_key = res2.stdout.strip() cfg["interface"]["private_key"] = priv
cfg["interface"]["private_key"] = private_key cfg["interface"]["public_key"] = pub
cfg["interface"]["public_key"] = public_key _ensure_access_classes(cfg)
_save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16]) # 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( sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "initialized"}) SyncEvent("wireguard", "config_saved", {"action": "initialized"})
) )
refresh_state(["wireguard", *sync_result.affected_subsystems]) refresh_state(["wireguard", *sync_result.affected_subsystems])
safe = dict(cfg) safe = dict(cfg)
safe["interface"] = dict(safe["interface"]) safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None) 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} 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) @registry.register(POST_WIREGUARD_PEERS_ADD)
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/peers/add — add new peer or update existing one. """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() name = body.get("name", "").strip()
if not name: if not name:
raise ValueError("'name' is required") raise ValueError("'name' is required")
cfg = _get_config() cfg = _get_wireguard_config()
peers = cfg.setdefault("peers", {}) peers = cfg.setdefault("peers", {})
allowed_ips = body.get("allowed_ips", [])
if name in peers: if name in peers:
peer = peers[name] peer = peers[name]
peer["endpoint"] = body.get("endpoint") if "endpoint" in body:
peer["allowed_ips"] = allowed_ips peer["endpoint"] = body["endpoint"]
peer["persistent_keepalive"] = body.get("persistent_keepalive") if "allowed_ips" in body:
if body.get("preshared_key") is not None: 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"] 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) logger.info("WireGuard peer '%s' updated", name)
_peer_action = "peer_updated" _peer_action = "peer_updated"
else: else:
res = run_proc([WG_BIN, "genkey"], sudo=True) priv, pub = generate_keypair()
priv = res.stdout.strip()
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=priv)
pub = res2.stdout.strip()
peers[name] = { peers[name] = {
"public_key": pub, "public_key": pub,
"private_key": priv, "private_key": priv,
"endpoint": body.get("endpoint"), "endpoint": body.get("endpoint"),
"allowed_ips": allowed_ips, "allowed_ips": body.get("allowed_ips", []),
"persistent_keepalive": body.get("persistent_keepalive"), "persistent_keepalive": body.get("persistent_keepalive"),
"preshared_key": body.get("preshared_key"), "preshared_key": body.get("preshared_key"),
"description": body.get("description", ""),
"access_class": body.get("access_class"),
} }
logger.info("WireGuard peer '%s' added", name) logger.info("WireGuard peer '%s' added", name)
_peer_action = "peer_added" _peer_action = "peer_added"
_save_config(cfg) _save_wireguard_config(cfg)
sync_result = bus.emit( sync_result = bus.emit(
SyncEvent( SyncEvent(
"wireguard", "config_saved", {"action": _peer_action, "peer_name": name} "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() name = body.get("name", "").strip()
if not name: if not name:
raise ValueError("'name' is required") raise ValueError("'name' is required")
cfg = _get_config() cfg = _get_wireguard_config()
peers = cfg.setdefault("peers", {}) peers = cfg.setdefault("peers", {})
if name not in peers: if name not in peers:
raise NotFoundError(f"Peer '{name}' not found") raise NotFoundError(f"Peer '{name}' not found")
del peers[name] del peers[name]
_save_config(cfg) _save_wireguard_config(cfg)
logger.info("WireGuard peer '%s' removed", name) logger.info("WireGuard peer '%s' removed", name)
sync_result = bus.emit( sync_result = bus.emit(
SyncEvent( SyncEvent(
@@ -323,14 +512,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
wg = _get_wg_state() wg = _get_wg_state()
if wg: if wg:
return wg.get("peers", []) return wg.get("peers", [])
cfg = _get_config() return _get_wireguard_peers()
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
@registry.register(GET_WIREGUARD_PEER_STATUS) @registry.register(GET_WIREGUARD_PEER_STATUS)
@@ -342,10 +524,32 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
return [] 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) @registry.register(POST_WIREGUARD_GENERATE_CLIENT)
def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: 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. """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: Raises:
ValueError: When body, name, or server_endpoint is missing. ValueError: When body, name, or server_endpoint is missing.
NotFoundError: When peer does not exist or has no private key. 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", "") server_endpoint = body.get("server_endpoint", "")
if not server_endpoint: if not server_endpoint:
raise ValueError("'server_endpoint' is required") raise ValueError("'server_endpoint' is required")
cfg = _get_config() cfg = _get_wireguard_config()
if name not in cfg.get("peers", {}): if name not in cfg.get("peers", {}):
raise NotFoundError(f"Peer '{name}' not found") raise NotFoundError(f"Peer '{name}' not found")
peer = cfg["peers"][name] peer = cfg["peers"][name]
client_priv = peer.get("private_key", "") if not peer.get("private_key"):
if not client_priv:
raise NotFoundError(f"Peer '{name}' has no private key") raise NotFoundError(f"Peer '{name}' has no private key")
iface = cfg["interface"]
sorted_peers = sorted(cfg.get("peers", {}).keys()) conf = _gen_client_conf(
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"),
peer_name=name, peer_name=name,
client_priv=client_priv,
client_addr=client_addr,
server_pubkey=iface.get("public_key", ""),
server_endpoint=server_endpoint, server_endpoint=server_endpoint,
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]), server_pubkey=cfg["interface"].get("public_key"),
preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"),
) )
return {"config": conf} 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}
+14
View File
@@ -99,6 +99,20 @@ DELETE_WIREGUARD_PEERS_REMOVE: Endpoint = _ep("DELETE", "/wireguard/peers/remove
GET_WIREGUARD_PEERS: Endpoint = _ep("GET", "/wireguard/peers") GET_WIREGUARD_PEERS: Endpoint = _ep("GET", "/wireguard/peers")
GET_WIREGUARD_PEER_STATUS: Endpoint = _ep("GET", "/wireguard/peer-status") GET_WIREGUARD_PEER_STATUS: Endpoint = _ep("GET", "/wireguard/peer-status")
POST_WIREGUARD_GENERATE_CLIENT: Endpoint = _ep("POST", "/wireguard/generate-client") POST_WIREGUARD_GENERATE_CLIENT: Endpoint = _ep("POST", "/wireguard/generate-client")
GET_WIREGUARD_CLASSES: Endpoint = _ep("GET", "/wireguard/classes")
POST_WIREGUARD_CLASSES: Endpoint = _ep("POST", "/wireguard/classes")
PATCH_WIREGUARD_CLASSES: Endpoint = _ep("PATCH", "/wireguard/classes/<key>")
DELETE_WIREGUARD_CLASSES: Endpoint = _ep("DELETE", "/wireguard/classes/<key>")
POST_WIREGUARD_CLASSES_UP: Endpoint = _ep("POST", "/wireguard/classes/<class_key>/up")
DELETE_WIREGUARD_CLASSES_DOWN: Endpoint = _ep(
"DELETE", "/wireguard/classes/<class_key>/down"
)
GET_WIREGUARD_CLASS_STATUS: Endpoint = _ep(
"GET", "/wireguard/classes/<class_key>/status"
)
POST_WIREGUARD_CLASS_INIT_KEYS: Endpoint = _ep(
"POST", "/wireguard/classes/keys/<class_key>"
)
# ---- ACME / Certs ---- # ---- ACME / Certs ----
GET_ACME_LIST: Endpoint = _ep("GET", "/acme/list") GET_ACME_LIST: Endpoint = _ep("GET", "/acme/list")
+80
View File
@@ -1320,6 +1320,86 @@ This is the only endpoint that returns a WireGuard private key. All other endpoi
Returns HTTP `404` if the peer is not found. Returns HTTP `404` if the peer is not found.
### Access Classes
Manage VPN access classes that categorize peers by access level (e.g., full LAN access, internet-only).
#### List Access Classes
```
GET /api/wireguard/classes
```
Return all configured access classes.
**Response (`data`):**
Object keyed by class identifier, each with `name` and `description` fields.
#### Create Access Class
```
POST /api/wireguard/classes
```
Create a new access class.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | `string` | Yes | Class identifier (alphanumeric) |
| `name` | `string` | No | Display name (defaults to key) |
| `description` | `string` | No | Description text |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `key` | `string` | Class key |
| `name` | `string` | Display name |
| `description` | `string` | Description |
Returns HTTP `409` if the key already exists.
#### Update Access Class
```
PATCH /api/wireguard/classes
```
Update an existing access class.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | `string` | Yes | Class identifier |
| `name` | `string` | No | New display name |
| `description` | `string` | No | New description |
**Response (`data`):** Updated class object with `key`, `name`, `description`.
Returns HTTP `404` if the class is not found.
#### Delete Access Class
```
DELETE /api/wireguard/classes
```
Remove an access class. Cannot delete a class that has peers assigned to it.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | `string` | Yes | Class identifier |
**Response (`data`):** `{ "key": "<key>" }`
Returns HTTP `404` if the class is not found. Returns HTTP `409` if peers reference the class.
--- ---
## Network API ## Network API
+59 -3
View File
@@ -268,7 +268,7 @@ The application reads `.account.conf` to determine registration status. If the f
**File**: `config/wireguard/config.json` **File**: `config/wireguard/config.json`
This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`. The file is created automatically when `initialize()` generates the server key pair via `wg genkey` / `wg pubkey`. This file defines the WireGuard server interface, access classes, and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`. The file is created automatically when `initialize()` generates the server key pair and pre-seeds default access classes.
```json ```json
{ {
@@ -278,9 +278,31 @@ This file defines the WireGuard server interface and all connected peers. The ap
"private_key": "<generated>", "private_key": "<generated>",
"public_key": "<generated>", "public_key": "<generated>",
"addresses": ["10.137.0.1/24"], "addresses": ["10.137.0.1/24"],
"server_endpoint": "vpn.example.com:51820",
"description": "Main WireGuard server",
"post_up": null, "post_up": null,
"post_down": null "post_down": null
}, },
"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": "<generated>",
"public_key": "<generated>"
},
"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": "<generated>",
"public_key": "<generated>"
}
},
"peers": { "peers": {
"alice": { "alice": {
"public_key": "<auto-generated>", "public_key": "<auto-generated>",
@@ -288,7 +310,9 @@ This file defines the WireGuard server interface and all connected peers. The ap
"endpoint": "203.0.113.1:51820", "endpoint": "203.0.113.1:51820",
"allowed_ips": ["0.0.0.0/0"], "allowed_ips": ["0.0.0.0/0"],
"persistent_keepalive": 25, "persistent_keepalive": 25,
"preshared_key": null "preshared_key": null,
"description": "Alice's office laptop",
"access_class": "full"
} }
} }
} }
@@ -303,9 +327,39 @@ This file defines the WireGuard server interface and all connected peers. The ap
| `private_key` | string | Yes (after init) | Base64-encoded private key for the server interface. Generated automatically by `initialize()` via `wg genkey`. | | `private_key` | string | Yes (after init) | Base64-encoded private key for the server interface. Generated automatically by `initialize()` via `wg genkey`. |
| `public_key` | string | Yes (after init) | Corresponding public key. Generated automatically by `initialize()` via `wg pubkey`. | | `public_key` | string | Yes (after init) | Corresponding public key. Generated automatically by `initialize()` via `wg pubkey`. |
| `addresses` | array | No | IP address(es) assigned to the server interface in CIDR notation (e.g., `10.137.0.1/24`). Default: `["10.137.0.1/24"]`. | | `addresses` | array | No | IP address(es) assigned to the server interface in CIDR notation (e.g., `10.137.0.1/24`). Default: `["10.137.0.1/24"]`. |
| `server_endpoint` | string | No | External hostname:port for client connection. Used in generated client configs. Default: `""`. |
| `description` | string | No | Free-text description of the WireGuard server. Default: `""`. |
| `post_up` | string | No | Shell command to run after the interface is brought up. Common uses: adding NAT rules, enabling IP forwarding for the tunnel. Set to `null` to omit. Default: `null`. | | `post_up` | string | No | Shell command to run after the interface is brought up. Common uses: adding NAT rules, enabling IP forwarding for the tunnel. Set to `null` to omit. Default: `null`. |
| `post_down` | string | No | Shell command to run after the interface is brought down. Used to clean up rules added by `post_up`. Set to `null` to omit. Default: `null`. | | `post_down` | string | No | Shell command to run after the interface is brought down. Used to clean up rules added by `post_up`. Set to `null` to omit. Default: `null`. |
### Access Classes
Access classes define categories of VPN access. Each class gets its own WireGuard interface (``wg-<key>``), firewall zone (``vpn-<key>``), subnet, and listen port. Peers are assigned to a class and their config is rendered to that class's interface. Pre-seeded with `full` and `internet` defaults on first initialization. Manageable via `GET/POST/PATCH/DELETE /api/wireguard/classes`. Per-class tunnel lifecycle: `POST /api/wireguard/classes/<key>/up`, `POST /api/wireguard/classes/<key>/down`.
| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Human-readable display name for the class. |
| `description` | string | No | Optional description of what access level this class provides. Default: `""`. |
| `subnet` | string | Yes | CIDR subnet for the class's WireGuard interface (e.g., ``10.137.0.0/24``). Server address is derived as ``<base>.1/<prefix>``. |
| `listen_port` | integer | Yes | UDP port for the class's WireGuard interface. Must be unique per class. |
| `lan_access` | boolean | No | When ``true``, the sync subscriber adds inter-zone accept rules for internal subnets, allowing peers to reach the LAN. When ``false``, peers can only reach the internet via masquerade. Default: ``false``. |
| `private_key` | string | Yes (auto) | Base64-encoded private key for the class's WireGuard interface. Auto-generated via ``POST /api/wireguard/classes/keys/<key>``. |
| `public_key` | string | Yes (auto) | Corresponding public key. Auto-generated with ``private_key``. |
### Multi-Interface Behavior
When peers are assigned to an access class, the daemon:
1. Renders a separate ``wg-<key>.conf`` for each class that has assigned peers.
2. Each class interface gets its own private/public key pair.
3. The sync subscriber creates a ``vpn-<key>`` firewall zone per class with masquerade enabled.
4. Classes with ``lan_access=true`` get additional inter-zone rules for internal subnets.
5. ``apply`` brings up all class interfaces independently. Per-class ``up``/``down`` endpoints control individual tunnels.
### Legacy Single-Interface Mode
When no peers are assigned to any access class, the system falls back to the legacy single-interface mode where all peers share ``wg0``.
### Peer Fields ### Peer Fields
Peers are stored in an object keyed by a human-readable identifier (e.g., `alice`, `office-laptop`). Each peer entry defines a WireGuard peer configuration. When `add_peer()` is called, the peer's key pair is auto-generated. The `private_key` is stored for client configuration generation but stripped from all API responses. Peers are stored in an object keyed by a human-readable identifier (e.g., `alice`, `office-laptop`). Each peer entry defines a WireGuard peer configuration. When `add_peer()` is called, the peer's key pair is auto-generated. The `private_key` is stored for client configuration generation but stripped from all API responses.
@@ -318,6 +372,8 @@ Peers are stored in an object keyed by a human-readable identifier (e.g., `alice
| `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Default: `[]` (no routing restrictions from the server side). `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. | | `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Default: `[]` (no routing restrictions from the server side). `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. |
| `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. Default: `null`. | | `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. Default: `null`. |
| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. Default: `null`. | | `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. Default: `null`. |
| `description` | string | No | Optional description for the peer. Default: `""`. |
| `access_class` | string | No | Key of the access class this peer belongs to (e.g., `"full"`, `"internet"`). `null` means unassigned. Default: `null`. |
### Client Configuration Generation ### Client Configuration Generation
@@ -488,7 +544,7 @@ are updated automatically through the event bus.
| Trigger Subsystem | Affected Subsystem | What Happens | | Trigger Subsystem | Affected Subsystem | What Happens |
|---|---|---| |---|---|---|
| dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services. Removing the last range removes them. DHCP ranges also back-propagate gateway (interface IP) so clients receive their default route. | | dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services. Removing the last range removes them. DHCP ranges also back-propagate gateway (interface IP) so clients receive their default route. |
| wireguard (peer add/remove) | firewall | `vpn` zone is created or maintained with `wg0` interface, masquerade, UDP 51820 rule, and inter-zone accept rules for each peer's allowed_ips subnets. Cleanup runs when no active peers exist. | | wireguard (peer add/remove) | firewall | Per-class `vpn-<key>` zones are created with `wg-<key>` interface, masquerade, UDP port rule, and inter-zone accept rules (only when ``lan_access=true``). Falls back to single `vpn` zone in legacy mode. Cleanup removes stale rules when classes have no peers. |
| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Masquerade-enabled zones ensure DHCP ranges carry the gateway. Zones with dhcp service but no range are logged as warnings. | | firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Masquerade-enabled zones ensure DHCP ranges carry the gateway. Zones with dhcp service but no range are logged as warnings. |
| network (interface config) | firewall | Zone interface assignments in firewall config are updated — new interfaces are flagged, stale ones removed. | | network (interface config) | firewall | Zone interface assignments in firewall config are updated — new interfaces are flagged, stale ones removed. |
| network (interface config) | dnsmasq | Suggested DHCP ranges are logged when an interface has a static IP but no DHCP range. | | network (interface config) | dnsmasq | Suggested DHCP ranges are logged when an interface has a static IP but no DHCP range. |
+186 -76
View File
@@ -279,64 +279,72 @@ def _strip_volatile(
for k in pop_keys: for k in pop_keys:
stripped.pop(k, None) stripped.pop(k, None)
for vpath in volatile: for vpath in volatile:
# Determine if this path uses list-of-dicts pattern (e.g. "peers[].transfer"). _strip_volatile_path(stripped, vpath)
# The [] marker signals that the parent key holds a list of dicts, and we return stripped
# must strip the volatile sub-key from each dict in the list.
list_marker = vpath.index("[]") if "[]" in vpath else -1
if list_marker != -1: def _strip_volatile_item(item: dict[str, Any], keys: list[str]) -> None:
# Split into prefix (path before []), item keys (path after []). """Recursively strip volatile keys from *item*, handling nested ``[]`` markers."""
# e.g. "status.peers[].transfer_received" → prefix=["status","peers"], for i, k in enumerate(keys):
# item_keys=["transfer_received"] if "[]" in k:
prefix = vpath[:list_marker].split(".") base_key = k.replace("[]", "")
item_keys = ( rest = keys[i + 1 :]
vpath[list_marker + 3 :].split(".") target = item.get(base_key, [])
if list_marker + 3 < len(vpath) if isinstance(target, list):
else [] for t in target:
) if isinstance(t, dict):
# Navigate to the list container via the prefix path _strip_volatile_item(t, rest)
parent = stripped elif isinstance(target, dict):
for seg in prefix: for v in target.values():
if isinstance(v, dict):
_strip_volatile_item(v, rest)
return
elif i == len(keys) - 1:
item[k] = None
return
else:
if isinstance(item, dict) and k in item:
item = item[k]
else:
return
def _strip_volatile_path(stripped: dict[str, Any], vpath: str) -> None:
"""Strip a single volatile path from *stripped*, supporting nested ``[]`` markers."""
list_marker = vpath.index("[]") if "[]" in vpath else -1
if list_marker == -1:
segments = vpath.split(".")
parent = stripped
for i, seg in enumerate(segments):
if i == len(segments) - 1:
if isinstance(parent, dict) and seg in parent:
parent[seg] = None
else:
if isinstance(parent, dict) and seg in parent: if isinstance(parent, dict) and seg in parent:
parent = parent[seg] parent = parent[seg]
else: else:
break return
if isinstance(parent, list): return
items = parent # Split into prefix and item keys.
elif isinstance(parent, dict): prefix = vpath[:list_marker].split(".")
logger.debug( item_keys = (
"_strip_volatile: %s resolved to dict, falling back to .values()", vpath[list_marker + 3 :].split(".") if list_marker + 3 < len(vpath) else []
vpath, )
) parent = stripped
items = parent.values() for seg in prefix:
else: if isinstance(parent, dict) and seg in parent:
continue parent = parent[seg]
for item in items:
# parent should now be a list; iterate each dict and strip sub-keys
if isinstance(item, dict):
curr = item
for i, ik in enumerate(item_keys):
if i == len(item_keys) - 1:
curr[ik] = None
else:
if isinstance(curr, dict) and ik in curr:
curr = curr[ik]
else:
break
else: else:
# Scalar/dict path: navigate via segments and set final key to None return
segments = vpath.split(".") if isinstance(parent, list):
parent = stripped items = parent
for i, seg in enumerate(segments): elif isinstance(parent, dict):
if i == len(segments) - 1: items = list(parent.values())
if isinstance(parent, dict) and seg in parent: else:
parent[seg] = None return
else: for item in items:
if isinstance(parent, dict) and seg in parent: if isinstance(item, dict) and item_keys:
parent = parent[seg] _strip_volatile_item(item, item_keys)
else:
break
return stripped
def _diff_layers( def _diff_layers(
@@ -872,10 +880,11 @@ register_collector("acme", _collect_acme)
def _collect_wireguard() -> dict[str, Any]: def _collect_wireguard() -> dict[str, Any]:
"""Collect WireGuard config, status, and peers. """Collect WireGuard config, per-class status, and peers.
Returns: Returns:
Dict containing interface config, runtime status, and peers. Dict containing interface config, per-class runtime status,
combined peers, and overall tunnel status.
""" """
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json" CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
@@ -886,9 +895,12 @@ def _collect_wireguard() -> dict[str, Any]:
"private_key": "", "private_key": "",
"public_key": "", "public_key": "",
"addresses": ["10.137.0.1/24"], "addresses": ["10.137.0.1/24"],
"server_endpoint": "",
"description": "",
"post_up": None, "post_up": None,
"post_down": None, "post_down": None,
}, },
"access_classes": {},
"peers": {}, "peers": {},
} }
@@ -907,11 +919,18 @@ def _collect_wireguard() -> dict[str, Any]:
cfg cfg
) )
# Safe config (strip private key and internal hash) # Safe config (strip private keys from interface and access classes)
safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
if "interface" in safe: if "interface" in safe:
safe["interface"] = dict(safe["interface"]) safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None) safe["interface"].pop("private_key", None)
if "access_classes" in safe:
safe["access_classes"] = {}
for ck, cv in cfg.get("access_classes", {}).items():
if isinstance(cv, dict):
entry = dict(cv)
entry.pop("private_key", None)
safe["access_classes"][ck] = entry
# Peers list (safe) # Peers list (safe)
peers: list[dict[str, Any]] = [] peers: list[dict[str, Any]] = []
@@ -921,35 +940,49 @@ def _collect_wireguard() -> dict[str, Any]:
entry.pop("private_key", None) entry.pop("private_key", None)
peers.append(entry) peers.append(entry)
# Runtime status # Runtime status — per-class interfaces
status: dict[str, Any] = {"up": False, "interface": {}, "peers": []} status: dict[str, Any] = {
name = cfg["interface"]["name"] "up": False,
peer_name = name if isinstance(name, str) else "wg0" "interface": {},
try: "peers": [],
res = run_proc(["wg", "show", peer_name], sudo=True, check=False) "classes": {},
if res.returncode == 0: }
classes = cfg.get("access_classes", {})
any_up = False
for class_key in classes:
class_cfg = classes.get(class_key)
if not isinstance(class_cfg, dict):
continue
ifname = f"wg-{class_key}"
try:
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
if res.returncode != 0:
status["classes"][class_key] = {
"up": False,
"interface": {},
"peers": [],
}
continue
raw = res.stdout.strip() raw = res.stdout.strip()
current_peer: dict[str, Any] | None = None current_peer: dict[str, Any] | None = None
status_peers: list[dict[str, Any]] = [] class_peers: list[dict[str, Any]] = []
cls_up = False
cls_iface: dict[str, Any] = {}
for line in raw.splitlines(): for line in raw.splitlines():
line = line.strip() line = line.strip()
if not line: if not line:
continue continue
if line.startswith("interface:"): if line.startswith("interface:"):
status["up"] = True cls_up = True
status["interface"] = {} cls_iface = {}
current_peer = None current_peer = None
continue continue
if line.startswith("public key:"): if line.startswith("public key:"):
status["interface"]["public_key"] = line.split(":", 1)[1].strip() cls_iface["public_key"] = line.split(":", 1)[1].strip()
continue continue
if line.startswith("listening port:"): if line.startswith("listening port:"):
status["interface"]["listen_port"] = int( cls_iface["listen_port"] = int(line.split(":", 1)[1].strip())
line.split(":", 1)[1].strip()
)
continue
if line.startswith("fwmark:"):
status["interface"]["fwmark"] = line.split(":", 1)[1].strip()
continue continue
if line.startswith("peer:"): if line.startswith("peer:"):
cur_key = line.split(":", 1)[1].strip() cur_key = line.split(":", 1)[1].strip()
@@ -962,7 +995,7 @@ def _collect_wireguard() -> dict[str, Any]:
"transfer_sent": "0", "transfer_sent": "0",
"persistent_keepalive": None, "persistent_keepalive": None,
} }
status_peers.append(current_peer) class_peers.append(current_peer)
continue continue
if current_peer is None: if current_peer is None:
continue continue
@@ -985,10 +1018,84 @@ def _collect_wireguard() -> dict[str, Any]:
current_peer["persistent_keepalive"] = int( current_peer["persistent_keepalive"] = int(
line.split(":", 1)[1].strip() line.split(":", 1)[1].strip()
) )
status["peers"] = status_peers status["classes"][class_key] = {
"up": cls_up,
"interface": cls_iface,
"peers": class_peers,
}
if cls_up:
any_up = True
except Exception:
status["classes"][class_key] = {"up": False, "interface": {}, "peers": []}
# Also collect legacy single-interface status
try:
ifname = cfg["interface"].get("name", "wg0")
legacy_peers: list[dict[str, Any]] = []
current_peer: dict[str, Any] | None = None
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
if res.returncode == 0:
raw = res.stdout.strip()
status["up"] = True
status["interface"] = {}
for line in raw.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("interface:"):
status["interface"] = {}
current_peer = None
continue
if line.startswith("public key:"):
status["interface"]["public_key"] = line.split(":", 1)[1].strip()
continue
if line.startswith("listening port:"):
status["interface"]["listen_port"] = int(
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,
}
legacy_peers.append(current_peer)
continue
if current_peer is None:
continue
if line.startswith("endpoint:"):
current_peer["endpoint"] = line.split(":", 1)[1].strip()
elif line.startswith("allowed ips:"):
current_peer["allowed_ips"] = (
line.split(":", 1)[1].strip().split(", ")
)
elif line.startswith("latest handshake:"):
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
elif line.startswith("transfer:"):
rest = line.split(":", 1)[1].strip().split(", ")
if rest:
current_peer["transfer_received"] = rest[0].strip()
if len(rest) > 1:
current_peer["transfer_sent"] = rest[1].strip()
elif line.startswith("persistent-keepalive:"):
with contextlib.suppress(ValueError):
current_peer["persistent_keepalive"] = int(
line.split(":", 1)[1].strip()
)
status["peers"] = legacy_peers
any_up = True
except Exception: except Exception:
pass pass
if any_up:
status["up"] = True
status["pending_changes"] = pending_changes status["pending_changes"] = pending_changes
return { return {
"config": safe, "config": safe,
@@ -1006,6 +1113,9 @@ register_volatile(
"status.peers[].transfer_received", "status.peers[].transfer_received",
"status.peers[].transfer_sent", "status.peers[].transfer_sent",
"status.peers[].latest_handshake", "status.peers[].latest_handshake",
"status.classes[].peers[].transfer_received",
"status.classes[].peers[].transfer_sent",
"status.classes[].peers[].latest_handshake",
} }
), ),
) )
+200 -52
View File
@@ -11,6 +11,7 @@ from dataclasses import dataclass, field
from typing import Any from typing import Any
from lib.common import get_interface_ip from lib.common import get_interface_ip
from lib.state import state as _state_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -392,8 +393,6 @@ class DnsToFirewallSync:
changes: list[str], changes: list[str],
) -> None: ) -> None:
"""Ensure DHCP ranges for *zone_ifaces* carry the gateway (interface IP).""" """Ensure DHCP ranges for *zone_ifaces* carry the gateway (interface IP)."""
from lib.common import get_interface_ip
ranges = dnsmasq_cfg.get("dhcp", {}).get("ranges", []) ranges = dnsmasq_cfg.get("dhcp", {}).get("ranges", [])
for r in ranges: for r in ranges:
iface = r.get("interface", "") iface = r.get("interface", "")
@@ -410,7 +409,23 @@ class DnsToFirewallSync:
class WgToFirewallSync: class WgToFirewallSync:
"""Sync subscriber: wireguard config_saved → update firewall config.""" """Sync subscriber: wireguard config_saved → update firewall zones per access class.
Each access class with peers gets its own firewall zone (``vpn-<key>``).
Classes with ``lan_access=True`` get inter-zone accept rules for all
internal subnets. Classes with ``lan_access=False`` (internet-only) get
no internal subnet rules.
"""
@staticmethod
def _wg_class_interface_name(class_key: str) -> str:
"""Derive interface name for an access class."""
return f"wg-{class_key}"
@staticmethod
def _wg_class_zone_name(class_key: str) -> str:
"""Derive firewall zone name for an access class."""
return f"vpn-{class_key}"
@staticmethod @staticmethod
def _sync_allowed_ips( def _sync_allowed_ips(
@@ -419,15 +434,9 @@ class WgToFirewallSync:
zones: dict[str, Any], zones: dict[str, Any],
changes: list[str], changes: list[str],
) -> None: ) -> None:
"""Ensure inter-zone rich rules exist for peer allowed_ips subnets. """Add inter-zone rich rules for peer allowed_ips subnets."""
For each peer's allowed_ips subnet that is not already covered
by a vpn-zone rich rule, adds a destination accept rule so
traffic from the VPN can reach those subnets.
"""
import re import re
# Collect all unique allowed_ips subnets across peers
all_subnets: set[str] = set() all_subnets: set[str] = set()
for _name, peer_info in wg_cfg.get("peers", {}).items(): for _name, peer_info in wg_cfg.get("peers", {}).items():
if not isinstance(peer_info, dict): if not isinstance(peer_info, dict):
@@ -436,7 +445,6 @@ class WgToFirewallSync:
if isinstance(item, str) and item.strip(): if isinstance(item, str) and item.strip():
all_subnets.add(item.strip()) all_subnets.add(item.strip())
# Parse existing rule strings to find which subnets are already covered
existing_rules = vpn_zone.get("rich_rules", []) existing_rules = vpn_zone.get("rich_rules", [])
covered_subnets: set[str] = set() covered_subnets: set[str] = set()
for rule_entry in existing_rules: for rule_entry in existing_rules:
@@ -445,19 +453,15 @@ class WgToFirewallSync:
if isinstance(rule_entry, dict) if isinstance(rule_entry, dict)
else str(rule_entry) else str(rule_entry)
) )
match = re.search( match = re.search(r'destination\s+address="([^"]+)"', str(rule_str))
r'destination\s+address="([^"]+)"',
str(rule_str),
)
if match: if match:
covered_subnets.add(match.group(1)) covered_subnets.add(match.group(1))
# Add rules for uncovered subnets
for subnet in sorted(all_subnets): for subnet in sorted(all_subnets):
if subnet in covered_subnets: if subnet in covered_subnets:
continue continue
rule_entry = { rule_entry = {
"rule": (f'rule family="ipv4" destination address="{subnet}" accept'), "rule": f'rule family="ipv4" destination address="{subnet}" accept',
"_source": "wg", "_source": "wg",
} }
vpn_zone.setdefault("rich_rules", []).append(rule_entry) vpn_zone.setdefault("rich_rules", []).append(rule_entry)
@@ -467,13 +471,17 @@ class WgToFirewallSync:
@classmethod @classmethod
def on_wireguard_config_saved(cls, event: SyncEvent) -> SyncResult | None: def on_wireguard_config_saved(cls, event: SyncEvent) -> SyncResult | None:
"""Sync subscriber: wireguard config_saved → update firewall config. """Sync subscriber: wireguard config_saved → update firewall zones per class.
When WireGuard is active (has peers and interface), this subscriber For each access class with peers, ensures a ``vpn-<key>`` zone exists
ensures the ``vpn`` zone exists with the WG interface assigned, with the WG interface assigned, masquerade enabled, and a UDP port
masquerade enabled, UDP 51820 accept rule, and inter-zone rich rules accept rule. Classes with ``lan_access=True`` also get inter-zone
for peer allowed_ips subnets. When WireGuard becomes inactive, accept rules for internal subnets.
cleans up WireGuard-created entries from the vpn zone.
Also handles legacy single-interface mode: when no classes have peers
but peers exist without access_class, manages a single ``vpn`` zone.
Cleans up zones/classes when empty.
Skips processing if event originated as a cascade from ``firewall``. Skips processing if event originated as a cascade from ``firewall``.
@@ -482,7 +490,7 @@ class WgToFirewallSync:
Returns: Returns:
SyncResult listing firewall as affected subsystem with change SyncResult listing firewall as affected subsystem with change
descriptions. ``None`` if skipped due to cascade guard. descriptions. ``None`` if skipped due cascade guard.
""" """
if event.payload.get("_cascade") == "firewall": if event.payload.get("_cascade") == "firewall":
return None return None
@@ -496,54 +504,148 @@ class WgToFirewallSync:
fw_cfg = _get_fw_cfg() fw_cfg = _get_fw_cfg()
zones = fw_cfg.get("zones", {}) zones = fw_cfg.get("zones", {})
wg_iface = wg_cfg.get("interface", {}).get("name", "")
peers = wg_cfg.get("peers", {})
is_active = bool(peers) and bool(wg_iface)
changes: list[str] = [] changes: list[str] = []
active_class_keys: set[str] = set()
if is_active: # --- Per-class zone management ---
classes = wg_cfg.get("access_classes", {})
for class_key, class_cfg in classes.items():
if not isinstance(class_cfg, dict):
continue
class_peers = {
n: p
for n, p in wg_cfg.get("peers", {}).items()
if isinstance(p, dict) and p.get("access_class") == class_key
}
if not class_peers:
continue
active_class_keys.add(class_key)
zone_name = cls._wg_class_zone_name(class_key)
iface_name = cls._wg_class_interface_name(class_key)
listen_port = class_cfg.get("listen_port", 51820)
lan_access = class_cfg.get("lan_access", False)
zone = zones.setdefault(zone_name, {})
if not isinstance(zone, dict):
zones[zone_name] = zone = {}
# Ensure interface assigned
current_ifaces = list(zone.get("interfaces", []))
if iface_name not in current_ifaces:
current_ifaces.append(iface_name)
zone["interfaces"] = current_ifaces
changes.append(
f"Assigned interface '{iface_name}' to zone '{zone_name}'"
)
# Ensure masquerade
if not zone.get("masquerade"):
zone["masquerade"] = True
changes.append(f"Enabled masquerade on zone '{zone_name}'")
# Ensure UDP port rule
rich_rules = list(zone.get("rich_rules", []))
udp_rule_str = f'rule family="ipv4" port protocol="udp" port="{listen_port}" accept'
udp_rule = {
"rule": udp_rule_str,
"_source": "wg",
}
rule_strings = {r.get("rule") for r in rich_rules}
if udp_rule_str not in rule_strings:
rich_rules.append(udp_rule)
zone["rich_rules"] = rich_rules
changes.append(
f"Added UDP {listen_port} accept rule to zone '{zone_name}'"
)
# LAN access rules: add inter-zone accept rules for internal
# subnets derived from firewall zones that have masquerade=false
if lan_access:
cls._add_lan_rules(zone, fw_cfg, changes, zone_name)
zones[zone_name] = zone
# --- Legacy single-interface zone (back compat) ---
# When peers exist without access_class, manage a "vpn" zone
unassigned_peers = {
n: p
for n, p in wg_cfg.get("peers", {}).items()
if isinstance(p, dict) and not p.get("access_class")
}
wg_iface = wg_cfg.get("interface", {}).get("name", "wg0")
if unassigned_peers:
vpn_zone = zones.get("vpn", {}) vpn_zone = zones.get("vpn", {})
if not isinstance(vpn_zone, dict): if not isinstance(vpn_zone, dict):
vpn_zone = {} vpn_zone = {}
zones["vpn"] = vpn_zone zones["vpn"] = vpn_zone
# Ensure interface is assigned
current_ifaces = list(vpn_zone.get("interfaces", [])) current_ifaces = list(vpn_zone.get("interfaces", []))
if wg_iface not in current_ifaces: if wg_iface not in current_ifaces:
current_ifaces.append(wg_iface) current_ifaces.append(wg_iface)
vpn_zone["interfaces"] = current_ifaces vpn_zone["interfaces"] = current_ifaces
changes.append(f"Assigned interface '{wg_iface}' to zone 'vpn'") changes.append(f"Assigned interface '{wg_iface}' to zone 'vpn'")
# Ensure masquerade
if not vpn_zone.get("masquerade"): if not vpn_zone.get("masquerade"):
vpn_zone["masquerade"] = True vpn_zone["masquerade"] = True
changes.append("Enabled masquerade on zone 'vpn'") changes.append("Enabled masquerade on zone 'vpn'")
# Ensure UDP 51820 rich rule exists
rich_rules = list(vpn_zone.get("rich_rules", [])) rich_rules = list(vpn_zone.get("rich_rules", []))
expected_rule = { udp_rule_str = (
"rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', 'rule family="ipv4" port protocol="udp" port="51820" accept'
"_source": "wg", )
} udp_rule = {"rule": udp_rule_str, "_source": "wg"}
rule_strings = {r.get("rule") for r in rich_rules} rule_strings = {r.get("rule") for r in rich_rules}
if expected_rule["rule"] not in rule_strings: if udp_rule_str not in rule_strings:
rich_rules.append(expected_rule) rich_rules.append(udp_rule)
vpn_zone["rich_rules"] = rich_rules vpn_zone["rich_rules"] = rich_rules
changes.append("Added UDP 51820 accept rich rule to zone 'vpn'") changes.append("Added UDP 51820 accept rule to zone 'vpn'")
zones["vpn"] = vpn_zone
# Add inter-zone rules for peer allowed_ips subnets # Add inter-zone rules for peer allowed_ips subnets
cls._sync_allowed_ips(wg_cfg, vpn_zone, zones, changes) cls._sync_allowed_ips(wg_cfg, vpn_zone, zones, changes)
zones["vpn"] = vpn_zone # --- Cleanup: remove empty class zones ---
else: has_any_peers = bool(wg_cfg.get("peers"))
# Not active — selectively clean up WireGuard-created entries if has_any_peers:
# from the vpn zone without removing the zone itself. for zone_name in list(zones.keys()):
if not zone_name.startswith("vpn-"):
continue
ckey = zone_name[4:]
if ckey and ckey not in active_class_keys:
zone = zones[zone_name]
if isinstance(zone, dict):
rules = [
r
for r in zone.get("rich_rules", [])
if not (
isinstance(r, dict) and r.get("_source") == "wg"
)
]
cleaned = False
if len(rules) < len(zone.get("rich_rules", [])):
zone["rich_rules"] = rules
cleaned = True
if zone.get("masquerade"):
zone["masquerade"] = False
cleaned = True
if zone.get("interfaces"):
zone["interfaces"] = []
cleaned = True
if cleaned:
changes.append(
f"Cleaned up stale rules from zone '{zone_name}'"
)
elif not unassigned_peers and not active_class_keys:
# WireGuard inactive — clean up legacy vpn zone
vpn_zone = zones.get("vpn") vpn_zone = zones.get("vpn")
if not isinstance(vpn_zone, dict): if not isinstance(vpn_zone, dict):
pass pass
else: else:
# Remove wg interface from vpn zone wg_iface = wg_cfg.get("interface", {}).get("name", "")
current_ifaces = list(vpn_zone.get("interfaces", [])) current_ifaces = list(vpn_zone.get("interfaces", []))
if wg_iface and wg_iface in current_ifaces: if wg_iface and wg_iface in current_ifaces:
current_ifaces.remove(wg_iface) current_ifaces.remove(wg_iface)
@@ -551,19 +653,15 @@ class WgToFirewallSync:
changes.append( changes.append(
f"Removed interface '{wg_iface}' from zone 'vpn'" f"Removed interface '{wg_iface}' from zone 'vpn'"
) )
# Also clean up any residual wg0 that was in the original vpn zone but if "wg0" in current_ifaces and wg_iface != "wg0":
# is no longer the configured WireGuard interface
if "wg0" in current_ifaces and (wg_iface or "") != "wg0":
current_ifaces.remove("wg0") current_ifaces.remove("wg0")
vpn_zone["interfaces"] = current_ifaces vpn_zone["interfaces"] = current_ifaces
changes.append("Removed interface 'wg0' from zone 'vpn'") changes.append("Removed interface 'wg0' from zone 'vpn'")
# Disable masquerade (only WireGuard relied on it)
if vpn_zone.get("masquerade"): if vpn_zone.get("masquerade"):
vpn_zone["masquerade"] = False vpn_zone["masquerade"] = False
changes.append("Disabled masquerade on zone 'vpn'") changes.append("Disabled masquerade on zone 'vpn'")
# Remove WireGuard-specific rich rules (only those with _source="wg")
rich_rules = list(vpn_zone.get("rich_rules", [])) rich_rules = list(vpn_zone.get("rich_rules", []))
wg_rule_ids: set[int] = set() wg_rule_ids: set[int] = set()
for idx, r in enumerate(rich_rules): for idx, r in enumerate(rich_rules):
@@ -575,11 +673,12 @@ class WgToFirewallSync:
] ]
vpn_zone["rich_rules"] = rich_rules vpn_zone["rich_rules"] = rich_rules
changes.append( changes.append(
f"Removed {len(wg_rule_ids)} WireGuard rich rule(s) from zone 'vpn'" f"Removed {len(wg_rule_ids)} WireGuard rich rule(s) "
f"from zone 'vpn'"
) )
fw_cfg["zones"] = zones
if changes: if changes:
fw_cfg["zones"] = zones
_save_fw_cfg(fw_cfg) _save_fw_cfg(fw_cfg)
return SyncResult( return SyncResult(
affected_subsystems=["firewall"], affected_subsystems=["firewall"],
@@ -590,6 +689,55 @@ class WgToFirewallSync:
logger.exception("WgToFirewallSync failed") logger.exception("WgToFirewallSync failed")
return SyncResult() return SyncResult()
@staticmethod
def _add_lan_rules(
zone: dict[str, Any],
fw_cfg: dict[str, Any],
changes: list[str],
zone_name: str,
) -> None:
"""Add inter-zone accept rules for internal LAN subnets."""
rich_rules = list(zone.get("rich_rules", []))
rule_strings = {r.get("rule") for r in rich_rules}
# Collect internal subnets from zones without masquerade (except VPN zones)
for zname, zdata in fw_cfg.get("zones", {}).items():
if not isinstance(zdata, dict):
continue
if zname.startswith("vpn"):
continue
if zdata.get("masquerade"):
continue
for iface_name in zdata.get("interfaces", []):
# Try to get the subnet from network state
net_state = _state_store.get("networkd")
if net_state:
for if_key, if_data in net_state.get("interfaces", {}).items():
if isinstance(if_data, dict) and if_key == iface_name:
for addr in if_data.get("addresses", []):
if isinstance(addr, dict):
addr_str = addr.get("address", "")
else:
addr_str = str(addr)
if "/" in addr_str:
rule_str = (
f'rule family="ipv4" destination '
f'address="{addr_str}" accept'
)
if rule_str not in rule_strings:
rule_entry = {
"rule": rule_str,
"_source": "wg",
}
rich_rules.append(rule_entry)
rule_strings.add(rule_str)
changes.append(
f"Added inter-zone rule for '{addr_str}' "
f"to zone '{zone_name}' (LAN access)"
)
zone["rich_rules"] = rich_rules
class FirewallToDhcpSync: class FirewallToDhcpSync:
"""Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges. """Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges.
@@ -878,7 +1026,7 @@ _bus.subscribe(
targets={"dnsmasq"}, targets={"dnsmasq"},
) )
_bus.subscribe( _bus.subscribe(
"network", "networkd",
"config_saved", "config_saved",
NetworkToAllSync.on_network_config_saved, NetworkToAllSync.on_network_config_saved,
targets={"firewall", "dnsmasq"}, targets={"firewall", "dnsmasq"},
+413 -86
View File
@@ -1,7 +1,8 @@
"""WireGuard Manager for Vacuum Wall SSL Proxy Firewall. """WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
Generates wg-quick configurations, manages peers, and controls Generates wg-quick configurations, manages peers, and controls
the WireGuard tunnel interface. the WireGuard tunnel interface. Supports multi-interface mode where
each access class gets its own WireGuard interface.
""" """
import logging import logging
@@ -19,7 +20,6 @@ logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json" CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
WG_QUICK_BIN = "wg-quick" WG_QUICK_BIN = "wg-quick"
WG_BIN = "wg" WG_BIN = "wg"
@@ -30,6 +30,25 @@ ENV = Environment(
trim_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] = { DEFAULT_CONFIG: dict[str, Any] = {
"interface": { "interface": {
"name": "wg0", "name": "wg0",
@@ -37,9 +56,31 @@ DEFAULT_CONFIG: dict[str, Any] = {
"private_key": "", "private_key": "",
"public_key": "", "public_key": "",
"addresses": ["10.137.0.1/24"], "addresses": ["10.137.0.1/24"],
"server_endpoint": "",
"description": "",
"post_up": None, "post_up": None,
"post_down": 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": {}, "peers": {},
} }
@@ -62,23 +103,114 @@ def save_config(cfg: dict[str, Any]) -> None:
def generate_keypair() -> tuple[str, str]: def generate_keypair() -> tuple[str, str]:
"""Generate a WireGuard private/public key pair using ``wg`` CLI.""" """Generate a WireGuard private/public key pair using ``wg`` CLI."""
res = run_proc([WG_BIN, "genkey"], sudo=True) res = run_proc([WG_BIN, "genkey"], sudo=False)
private_key = res.stdout.strip() private_key = res.stdout.strip()
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key) res2 = run_proc([WG_BIN, "pubkey"], sudo=False, input=private_key)
public_key = res2.stdout.strip() public_key = res2.stdout.strip()
return private_key, public_key return private_key, public_key
# --- wg0.conf generation --- # --- 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: def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a valid wg-quick config file from *cfg* using Jinja2.""" """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") tmpl = ENV.get_template("wireguard.conf")
return tmpl.render( return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interface=cfg["interface"], interface=cfg["interface"],
peers=cfg.get("peers", {}), peers=fallback_peers if fallback_peers else {},
) )
@@ -86,55 +218,187 @@ def generate_conf(cfg: dict[str, Any]) -> str:
def apply() -> None: def apply() -> None:
"""Write the current config to disk and bring the tunnel up with wg-quick.""" """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() 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) conf_text = generate_conf(cfg)
save_config(cfg) save_config(cfg)
ifname = cfg["interface"]["name"]
conf_path = _wg_conf_path(ifname)
local_dir = PROJECT_DIR / "data" / "wireguard" local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True) local_dir.mkdir(parents=True, exist_ok=True)
local_tmp = local_dir / "wg0.conf.tmp" local_tmp = local_dir / f"{ifname}.conf.tmp"
with open(local_tmp, "w") as f: with open(local_tmp, "w") as f:
f.write(conf_text) f.write(conf_text)
os.chmod(local_tmp, 0o600) os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True) run(["cp", "--", str(local_tmp), conf_path], sudo=True)
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False) run(["chown", "root:root", conf_path], sudo=True, check=False)
local_tmp.unlink(missing_ok=True) local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", ifname)
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"]) 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: def down() -> None:
"""Bring the WireGuard tunnel interface down.""" """Bring all WireGuard tunnel interfaces down.
In multi-interface mode, brings down each class interface with peers.
"""
cfg = get_config() cfg = get_config()
name = cfg["interface"]["name"] classes = cfg.get("access_classes", {})
run([WG_QUICK_BIN, "down", name], sudo=True) for class_key in classes:
logger.info("WireGuard tunnel '%s' brought down", name) 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 --- # --- Status ---
def status() -> dict[str, Any]: def status() -> dict[str, Any]:
"""Query the live tunnel state via ``wg show``.""" """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() cfg = get_config()
name = cfg["interface"]["name"] 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."""
result: dict[str, Any] = { result: dict[str, Any] = {
"up": False, "up": False,
"interface": {}, "interface": {},
"peers": [], "peers": [],
} }
try:
res = run_proc([WG_BIN, "show", name], sudo=True, check=False)
if res.returncode != 0:
return result
raw = res.stdout.strip()
except Exception:
return result
current_peer: dict[str, Any] | None = None current_peer: dict[str, Any] | None = None
peers: list[dict[str, Any]] = [] peers: list[dict[str, Any]] = []
@@ -169,8 +433,8 @@ def status() -> dict[str, Any]:
"endpoint": None, "endpoint": None,
"allowed_ips": [], "allowed_ips": [],
"latest_handshake": None, "latest_handshake": None,
"transfer_received": 0, "transfer_received": "0",
"transfer_sent": 0, "transfer_sent": "0",
"persistent_keepalive": None, "persistent_keepalive": None,
} }
peers.append(current_peer) peers.append(current_peer)
@@ -214,36 +478,50 @@ def status() -> dict[str, Any]:
# --- Peer management --- # --- Peer management ---
_UNSET = object()
def add_peer( def add_peer(
name: str, name: str,
endpoint: str | None = None, endpoint: str | None | object = _UNSET,
allowed_ips: list[str] | None = None, allowed_ips: list[str] | None | object = _UNSET,
persistent_keepalive: int | None = None, persistent_keepalive: int | None | object = _UNSET,
preshared_key: str | None = None, preshared_key: str | None = None,
description: str | None = None,
access_class: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Add (or update) a peer in the configuration.""" """Add (or update) a peer in the configuration."""
cfg = get_config() cfg = get_config()
peers = cfg.setdefault("peers", {}) peers = cfg.setdefault("peers", {})
allowed_ips = allowed_ips or []
if name in peers: if name in peers:
peer = peers[name] peer = peers[name]
peer["endpoint"] = endpoint if endpoint is not _UNSET:
peer["allowed_ips"] = allowed_ips peer["endpoint"] = endpoint
peer["persistent_keepalive"] = persistent_keepalive 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: if preshared_key is not None:
peer["preshared_key"] = preshared_key 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) logger.info("WireGuard peer '%s' updated", name)
else: else:
priv, pub = generate_keypair() priv, pub = generate_keypair()
peer = { peer = {
"public_key": pub, "public_key": pub,
"private_key": priv, "private_key": priv,
"endpoint": endpoint, "endpoint": endpoint if endpoint is not _UNSET else None,
"allowed_ips": allowed_ips, "allowed_ips": allowed_ips if allowed_ips is not _UNSET else [],
"persistent_keepalive": persistent_keepalive, "persistent_keepalive": persistent_keepalive
if persistent_keepalive is not _UNSET
else None,
"preshared_key": preshared_key, "preshared_key": preshared_key,
"description": description,
"access_class": access_class,
} }
peers[name] = peer peers[name] = peer
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16]) logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
@@ -275,9 +553,17 @@ def get_peers() -> list[dict[str, Any]]:
def get_peer_status() -> list[dict[str, Any]]: def get_peer_status() -> list[dict[str, Any]]:
"""Return live peer status from ``wg show``.""" """Return live peer status from ``wg show`` for all interfaces."""
st = status() st = status()
return st.get("peers", []) 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 --- # --- Client config generation ---
@@ -286,9 +572,13 @@ def get_peer_status() -> list[dict[str, Any]]:
def generate_client_conf( def generate_client_conf(
peer_name: str, peer_name: str,
server_endpoint: str, server_endpoint: str,
server_pubkey: str, server_pubkey: str | None = None,
) -> str: ) -> str:
"""Build a client-side wg-quick config snippet for *peer_name*.""" """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() cfg = get_config()
iface = cfg["interface"] iface = cfg["interface"]
peer = cfg["peers"].get(peer_name) peer = cfg["peers"].get(peer_name)
@@ -301,12 +591,30 @@ def generate_client_conf(
f"Peer '{peer_name}' has no private key — cannot generate client config." 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()) sorted_peers = sorted(cfg.get("peers", {}).keys())
peer_index = sorted_peers.index(peer_name) + 2 peer_index = sorted_peers.index(peer_name) + 2
srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24"
addr_part, prefix = srv_addr.rsplit("/", 1) if access_class and access_class in cfg.get("access_classes", {}):
prefix_base = addr_part.rsplit(".", 1)[0] class_cfg = cfg["access_classes"][access_class]
client_addr = f"{prefix_base}.{peer_index}/{prefix}" 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") tmpl = ENV.get_template("wireguard-client.conf")
conf = tmpl.render( conf = tmpl.render(
@@ -314,8 +622,8 @@ def generate_client_conf(
peer_name=peer_name, peer_name=peer_name,
client_priv=client_priv, client_priv=client_priv,
client_addr=client_addr, client_addr=client_addr,
server_pubkey=server_pubkey, server_pubkey=sk,
server_endpoint=server_endpoint, server_endpoint=ep,
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]), allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
preshared_key=peer.get("preshared_key"), preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"), persistent_keepalive=peer.get("persistent_keepalive"),
@@ -351,66 +659,85 @@ def set_post_down(cmd: str | None) -> None:
save_config(cfg) 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 --- # --- 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]: def initialize() -> dict[str, Any]:
"""Perform first-time WireGuard setup.""" """Perform first-time WireGuard setup."""
cfg = get_config() cfg = get_config()
if cfg["interface"].get("private_key"): if cfg["interface"].get("private_key"):
_ensure_access_classes(cfg)
save_config(cfg)
return cfg return cfg
priv, pub = generate_keypair() priv, pub = generate_keypair()
cfg["interface"]["private_key"] = priv cfg["interface"]["private_key"] = priv
cfg["interface"]["public_key"] = pub cfg["interface"]["public_key"] = pub
_ensure_access_classes(cfg)
save_config(cfg) save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16]) logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
return cfg return cfg
# --- Utility: parse wg show into structured peer map ---
def _parse_wg_show(output: str) -> dict[str, Any]:
"""Internal parser for ``wg show`` multiline output."""
peers: dict[str, dict[str, Any]] = {}
current: dict[str, Any] | None = None
for line in output.splitlines():
line = line.strip()
if line.startswith("peer:"):
key = line.split(":", 1)[1].strip()
current = {"_key": key}
peers[key] = current
continue
if current is None:
continue
if line.startswith("endpoint:"):
val = line.split(":", 1)[1].strip()
current["endpoint"] = val
elif line.startswith("allowed ips:"):
current["allowed_ips"] = line.split(":", 1)[1].strip()
elif line.startswith("latest handshake:"):
current["latest_handshake"] = line.split(":", 1)[1].strip()
elif line.startswith("transfer:"):
current["transfer_raw"] = line.split(":", 1)[1].strip()
elif line.startswith("persistent-keepalive:"):
current["persistent_keepalive"] = line.split(":", 1)[1].strip()
return peers
__all__ = [ __all__ = [
"DEFAULT_CONFIG", "DEFAULT_CONFIG",
"add_peer", "add_peer",
"apply", "apply",
"apply_class",
"down", "down",
"down_class",
"generate_class_conf",
"generate_class_keypair",
"generate_client_conf", "generate_client_conf",
"generate_conf", "generate_conf",
"generate_keypair", "generate_keypair",
"get_class_interface_name",
"get_class_zone_name",
"get_config", "get_config",
"get_peer_status", "get_peer_status",
"get_peers", "get_peers",
+16
View File
@@ -160,10 +160,14 @@ class TestApplyAll:
} }
) )
runtime_dir = tmp_network / "run" / "vacuum-wall"
runtime_dir.mkdir(parents=True)
with ( with (
patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run, patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.collect_upstream_dns", return_value=[]), patch("daemon.handlers.network.collect_upstream_dns", return_value=[]),
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
): ):
mock_gen.return_value = { mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"], "generated": [_net.DATA_DIR / "99-eth0.network"],
@@ -188,11 +192,15 @@ class TestApplyAll:
} }
) )
runtime_dir = tmp_network / "run" / "vacuum-wall"
runtime_dir.mkdir(parents=True)
with ( with (
patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run, patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.set_upstreams") as mock_set_upstreams, patch("daemon.handlers.network.set_upstreams") as mock_set_upstreams,
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect, patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
): ):
mock_gen.return_value = { mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"], "generated": [_net.DATA_DIR / "99-eth0.network"],
@@ -208,6 +216,9 @@ class TestApplyAll:
def test_apply_all_handles_dns_sync_failure(self, tmp_network): def test_apply_all_handles_dns_sync_failure(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"dns": ["8.8.8.8"]}}}) _net.save_config({"interfaces": {"eth0": {"dns": ["8.8.8.8"]}}})
runtime_dir = tmp_network / "run" / "vacuum-wall"
runtime_dir.mkdir(parents=True)
with ( with (
patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run, patch("daemon.handlers.network.run") as mock_run,
@@ -216,6 +227,7 @@ class TestApplyAll:
side_effect=RuntimeError("fail"), side_effect=RuntimeError("fail"),
), ),
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect, patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
): ):
mock_gen.return_value = { mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"], "generated": [_net.DATA_DIR / "99-eth0.network"],
@@ -233,10 +245,14 @@ class TestApplyAll:
sys_dir.mkdir(parents=True) sys_dir.mkdir(parents=True)
(sys_dir / "stale-file.network").write_text("[Match]\nName=old\n") (sys_dir / "stale-file.network").write_text("[Match]\nName=old\n")
runtime_dir = tmp_network / "run" / "vacuum-wall"
runtime_dir.mkdir(parents=True)
with ( with (
patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run, patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.collect_upstream_dns", return_value=[]), patch("daemon.handlers.network.collect_upstream_dns", return_value=[]),
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
): ):
mock_gen.return_value = { mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"], "generated": [_net.DATA_DIR / "99-eth0.network"],
+7 -3
View File
@@ -580,9 +580,13 @@ class TestImportFirewall:
class TestImportAll: class TestImportAll:
def test_all_missing(self, temp_project): def test_all_missing(self, temp_project, tmp_path):
result = system_import.import_all() from unittest.mock import MagicMock, patch
assert result == []
mock_run = MagicMock(side_effect=RuntimeError("command not found"))
with patch.object(system_import, "run", mock_run):
result = system_import.import_all()
assert result == []
def test_returns_updated_subsystems(self, temp_project, tmp_path): def test_returns_updated_subsystems(self, temp_project, tmp_path):
# Create dnsmasq conf # Create dnsmasq conf
+277 -6
View File
@@ -23,6 +23,13 @@ class TestDefaultConfig:
assert cfg["interface"]["private_key"] == "" assert cfg["interface"]["private_key"] == ""
assert cfg["peers"] == {} assert cfg["peers"] == {}
def test_classes_have_phase2_fields(self):
cfg = wireguard.DEFAULT_CONFIG
for ck, cv in cfg["access_classes"].items():
assert "subnet" in cv, f"Class {ck} missing subnet"
assert "listen_port" in cv, f"Class {ck} missing listen_port"
assert "lan_access" in cv, f"Class {ck} missing lan_access"
class TestGetConfig: class TestGetConfig:
def test_returns_default_when_no_file(self, temp_config): def test_returns_default_when_no_file(self, temp_config):
@@ -74,6 +81,92 @@ class TestGenerateKeyPair:
assert mock_run.call_args_list[1].kwargs.get("input") == "private-key" assert mock_run.call_args_list[1].kwargs.get("input") == "private-key"
class TestClassHelpers:
def test_class_interface_name(self):
assert wireguard.get_class_interface_name("full") == "wg-full"
assert wireguard.get_class_interface_name("internet") == "wg-internet"
assert wireguard.get_class_interface_name("custom") == "wg-custom"
def test_class_zone_name(self):
assert wireguard.get_class_zone_name("full") == "vpn-full"
assert wireguard.get_class_zone_name("internet") == "vpn-internet"
def test_class_peers(self):
cfg = {
"peers": {
"alice": {"access_class": "full", "public_key": "pk1"},
"bob": {"access_class": "internet", "public_key": "pk2"},
"carol": {"access_class": None, "public_key": "pk3"},
}
}
full_peers = wireguard._class_peers(cfg, "full")
assert len(full_peers) == 1
assert "alice" in full_peers
int_peers = wireguard._class_peers(cfg, "internet")
assert len(int_peers) == 1
assert "bob" in int_peers
class TestGenerateClassConf:
def test_returns_none_when_no_peers(self, temp_config):
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["access_classes"]["full"]["private_key"] = "test-key"
result = wireguard.generate_class_conf(cfg, "full")
assert result is None
@patch("lib.wireguard.ENV")
def test_renders_template_per_class(self, mock_env, temp_config):
mock_tmpl = MagicMock()
mock_tmpl.render.return_value = "[Interface]\nPrivateKey = x\n"
mock_env.get_template.return_value = mock_tmpl
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["access_classes"]["full"]["private_key"] = "test-key"
cfg["peers"]["alice"] = {
"access_class": "full",
"public_key": "pub1",
"private_key": "priv1",
"allowed_ips": ["0.0.0.0/0"],
}
result = wireguard.generate_class_conf(cfg, "full")
assert result is not None
assert mock_tmpl.render.call_count == 1
call_kwargs = mock_tmpl.render.call_args.kwargs
assert call_kwargs["interface"]["name"] == "wg-full"
assert call_kwargs["interface"]["private_key"] == "test-key"
assert "alice" in call_kwargs["peers"]
def test_raises_when_no_private_key(self, temp_config):
cfg = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"addresses": ["10.137.0.1/24"],
},
"access_classes": {
"full": {
"name": "Full LAN Access",
"description": "Full access",
"subnet": "10.137.0.0/24",
"listen_port": 51820,
"lan_access": True,
},
},
"peers": {
"alice": {
"access_class": "full",
"public_key": "pub1",
},
},
}
wireguard.save_config(cfg)
cfg = wireguard.get_config()
with pytest.raises(ValueError, match="no private key"):
wireguard.generate_class_conf(cfg, "full")
class TestGetPeers: class TestGetPeers:
def test_empty_peers(self, temp_config): def test_empty_peers(self, temp_config):
peers = wireguard.get_peers() peers = wireguard.get_peers()
@@ -238,12 +331,190 @@ class TestStatus:
class TestGenerateWgShowParser: class TestGenerateWgShowParser:
def test_parses_peer_output(self): def test_parses_peer_output(self):
output = ( output = (
"peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n" "interface: wg0\n"
" public key: IFACE-PUB\n"
" listening port: 51820\n"
" peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
) )
result = wireguard._parse_wg_show(output) result = wireguard._parse_wg_show_output(output)
assert "PUBKEY1" in result assert result["up"] is True
assert result["PUBKEY1"]["endpoint"] == "203.0.113.1:51820" assert result["interface"]["public_key"] == "IFACE-PUB"
assert result["interface"]["listen_port"] == 51820
assert len(result["peers"]) == 1
assert result["peers"][0]["public_key"] == "PUBKEY1"
assert result["peers"][0]["endpoint"] == "203.0.113.1:51820"
assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"]
def test_empty_output(self): def test_empty_output(self):
result = wireguard._parse_wg_show("") result = wireguard._parse_wg_show_output("")
assert result == {} assert result["up"] is False
assert result["peers"] == []
class TestAccessClasses:
def test_default_config_has_access_classes(self):
cfg = wireguard.DEFAULT_CONFIG
assert "access_classes" in cfg
assert "full" in cfg["access_classes"]
assert "internet" in cfg["access_classes"]
def test_ensure_access_classes_empty(self, temp_config):
cfg = {"interface": {}, "access_classes": {}, "peers": {}}
wireguard._ensure_access_classes(cfg)
assert "full" in cfg["access_classes"]
assert "internet" in cfg["access_classes"]
def test_ensure_access_classes_preserves_existing(self, temp_config):
cfg = {
"interface": {},
"access_classes": {"custom": {"name": "Custom"}},
"peers": {},
}
wireguard._ensure_access_classes(cfg)
assert "custom" in cfg["access_classes"]
assert "full" not in cfg["access_classes"]
def test_ensure_class_defaults_adds_phase2_fields(self, temp_config):
cfg = {"interface": {}, "access_classes": {"old": {"name": "Old"}}, "peers": {}}
wireguard._ensure_access_classes(cfg)
c = cfg["access_classes"]["old"]
assert "subnet" in c
assert "listen_port" in c
assert "lan_access" in c
assert "private_key" in c
assert "public_key" in c
class TestAddPeerWithNewFields:
@patch("lib.wireguard.generate_keypair")
def test_add_peer_with_description_and_access_class(self, mock_gen, temp_config):
mock_gen.return_value = ("priv", "pub")
result = wireguard.add_peer(
"test-peer",
description="Test peer",
access_class="full",
)
assert result["description"] == "Test peer"
assert result["access_class"] == "full"
cfg = wireguard.get_config()
assert cfg["peers"]["test-peer"]["description"] == "Test peer"
assert cfg["peers"]["test-peer"]["access_class"] == "full"
@patch("lib.wireguard.generate_keypair")
def test_update_peer_preserves_existing_fields(self, mock_gen, temp_config):
mock_gen.return_value = ("priv", "pub")
wireguard.add_peer("p1", description="original", access_class="full")
wireguard.add_peer("p1", endpoint="1.2.3.4:51820")
cfg = wireguard.get_config()
assert cfg["peers"]["p1"]["endpoint"] == "1.2.3.4:51820"
assert cfg["peers"]["p1"]["description"] == "original"
assert cfg["peers"]["p1"]["access_class"] == "full"
class TestInterfaceHasNewFields:
def test_default_has_server_endpoint(self):
assert wireguard.DEFAULT_CONFIG["interface"].get("server_endpoint") == ""
def test_default_has_description(self):
assert wireguard.DEFAULT_CONFIG["interface"].get("description") == ""
class TestClassKeyGeneration:
@patch("lib.wireguard.run_proc")
def test_generates_keypair_for_class(self, mock_run, temp_config):
mock_run.side_effect = [
MagicMock(returncode=0, stdout="class-priv\n"),
MagicMock(returncode=0, stdout="class-pub\n"),
]
# Create config with a fresh class (no prior keys, no auto-merge from file)
cfg = {
"interface": {
"name": "wg0",
"private_key": "existing",
"public_key": "pub",
},
"access_classes": {"fresh": {"name": "Fresh", "description": "New class"}},
"peers": {},
}
wireguard.save_config(cfg)
priv, pub = wireguard.generate_class_keypair("fresh")
assert priv == "class-priv"
assert pub == "class-pub"
loaded = wireguard.get_config()
assert loaded["access_classes"]["fresh"]["private_key"] == "class-priv"
assert loaded["access_classes"]["fresh"]["public_key"] == "class-pub"
@patch("lib.wireguard.run_proc")
def test_returns_existing_keys(self, mock_run, temp_config):
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["access_classes"]["full"]["private_key"] = "existing-priv"
cfg["access_classes"]["full"]["public_key"] = "existing-pub"
wireguard.save_config(cfg)
priv, pub = wireguard.generate_class_keypair("full")
assert priv == "existing-priv"
assert pub == "existing-pub"
mock_run.assert_not_called()
def test_raises_for_missing_class(self, temp_config):
with pytest.raises(ValueError, match="not found"):
wireguard.generate_class_keypair("nonexistent")
class TestGetPeerStatusMultiInterface:
@patch("lib.wireguard.run_proc")
def test_aggregates_peers_across_classes(self, mock_run, temp_config):
def side_effect(cmd, **kwargs):
iface = cmd[-1]
if iface == "wg-full":
return MagicMock(
returncode=0,
stdout="interface:\n public key: FULL-PUB\n\npeer: PUB1\n",
)
if iface == "wg-internet":
return MagicMock(
returncode=0,
stdout="interface:\n public key: INT-PUB\n\npeer: PUB2\n",
)
# Legacy interface
if iface == "wg0":
return MagicMock(returncode=1, stdout="")
return MagicMock(returncode=1, stdout="")
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["access_classes"]["full"]["private_key"] = "full-priv"
cfg["access_classes"]["internet"]["private_key"] = "int-priv"
wireguard.save_config(cfg)
mock_run.side_effect = side_effect
peers = wireguard.get_peer_status()
assert len(peers) == 2
assert peers[0]["public_key"] == "PUB1"
assert peers[0]["access_class"] == "full"
assert peers[1]["public_key"] == "PUB2"
assert peers[1]["access_class"] == "internet"
class TestApplyClass:
@patch("lib.wireguard.run")
@patch("lib.wireguard.run_proc")
@patch("lib.wireguard.ENV")
def test_apply_class_writes_and_up(
self, mock_env, mock_proc, mock_run, temp_config
):
mock_tmpl = MagicMock()
mock_tmpl.render.return_value = "[Interface]\nPrivateKey = x\n"
mock_env.get_template.return_value = mock_tmpl
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["access_classes"]["full"]["private_key"] = "test-key"
cfg["peers"]["alice"] = {
"access_class": "full",
"public_key": "pub1",
"private_key": "priv1",
"allowed_ips": ["0.0.0.0/0"],
}
wireguard.save_config(cfg)
wireguard.apply_class("full")
# Should call wg-quick up
mock_run.assert_any_call(["wg-quick", "up", "wg-full"], sudo=True)
+31
View File
@@ -19,6 +19,7 @@ from daemon.iface import (
POST_DNSMASQ_APPLY, POST_DNSMASQ_APPLY,
POST_DNSMASQ_CONFIG, POST_DNSMASQ_CONFIG,
POST_DNSMASQ_DNS_RECORD_ADD, POST_DNSMASQ_DNS_RECORD_ADD,
POST_DNSMASQ_DOMAIN,
POST_DNSMASQ_RANGES_ADD, POST_DNSMASQ_RANGES_ADD,
POST_DNSMASQ_STATIC_LEASE_ADD, POST_DNSMASQ_STATIC_LEASE_ADD,
) )
@@ -306,6 +307,36 @@ def add_dns_record_bp():
return _error(str(exc), 500) return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# DNS domain
# ---------------------------------------------------------------------------
@bp.route("/domain", methods=["POST"])
def set_domain_bp():
"""POST /api/dhcp/domain — Set or clear the DNS search domain.
Args:
request: JSON body with `domain` field (string or null to clear).
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post(POST_DNSMASQ_DOMAIN, {"domain": body.get("domain")})
logger.info("DNS domain updated via API: %s", body.get("domain"))
return _ok(None)
except BadRequest as exc:
logger.info("Set DNS domain rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set DNS domain: %s", exc)
return _error(str(exc), 500)
@bp.route("/dns-record/<name>", methods=["DELETE"]) @bp.route("/dns-record/<name>", methods=["DELETE"])
def remove_dns_record_bp(name): def remove_dns_record_bp(name):
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name. """DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name.
+164 -1
View File
@@ -7,15 +7,23 @@ import logging
from flask import Blueprint, request from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, patch, post from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
from daemon.iface import ( from daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
DELETE_WIREGUARD_PEERS_REMOVE, DELETE_WIREGUARD_PEERS_REMOVE,
GET_WIREGUARD_CLASS_STATUS,
GET_WIREGUARD_CLASSES,
GET_WIREGUARD_CONFIG, GET_WIREGUARD_CONFIG,
GET_WIREGUARD_PEER_STATUS, GET_WIREGUARD_PEER_STATUS,
GET_WIREGUARD_PEERS, GET_WIREGUARD_PEERS,
GET_WIREGUARD_STATUS, GET_WIREGUARD_STATUS,
PATCH_WIREGUARD_CLASSES,
PATCH_WIREGUARD_CONFIG, PATCH_WIREGUARD_CONFIG,
POST_WIREGUARD_APPLY, POST_WIREGUARD_APPLY,
POST_WIREGUARD_CLASS_INIT_KEYS,
POST_WIREGUARD_CLASSES,
POST_WIREGUARD_CLASSES_UP,
POST_WIREGUARD_CONFIG, POST_WIREGUARD_CONFIG,
POST_WIREGUARD_DOWN, POST_WIREGUARD_DOWN,
POST_WIREGUARD_GENERATE_CLIENT, POST_WIREGUARD_GENERATE_CLIENT,
@@ -229,6 +237,8 @@ def add_peer_bp():
"allowed_ips": body.get("allowed_ips", []), "allowed_ips": body.get("allowed_ips", []),
"persistent_keepalive": body.get("persistent_keepalive"), "persistent_keepalive": body.get("persistent_keepalive"),
"preshared_key": body.get("preshared_key"), "preshared_key": body.get("preshared_key"),
"description": body.get("description"),
"access_class": body.get("access_class"),
}, },
) )
logger.info("WireGuard peer '%s' added via API", name) logger.info("WireGuard peer '%s' added via API", name)
@@ -337,3 +347,156 @@ def generate_client_bp():
except RuntimeError as exc: except RuntimeError as exc:
logger.error("Failed to generate client config for '%s': %s", name, exc) logger.error("Failed to generate client config for '%s': %s", name, exc)
return _error(str(exc), 500) return _error(str(exc), 500)
@bp.route("/classes", methods=["GET"])
def list_classes_bp():
"""List all access classes.
Endpoint: GET /api/wireguard/classes
"""
try:
return _ok(get(GET_WIREGUARD_CLASSES))
except RuntimeError as exc:
logger.error("Failed to list access classes: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["POST"])
def create_class_bp():
"""Create a new access class.
Endpoint: POST /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = post(POST_WIREGUARD_CLASSES, body)
return _ok(result)
except BadRequest as exc:
logger.info("Create access class rejected: %s", exc)
return _error(str(exc), 400)
except Conflict as exc:
logger.info("Create access class conflict: %s", exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to create access class: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["PATCH"])
def update_class_bp():
"""Update an access class.
Endpoint: PATCH /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = patch(PATCH_WIREGUARD_CLASSES, body)
return _ok(result)
except BadRequest as exc:
logger.info("Update access class rejected: %s", exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Access class not found: %s", exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to update access class: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["DELETE"])
def delete_class_bp():
"""Delete an access class.
Endpoint: DELETE /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = delete(DELETE_WIREGUARD_CLASSES, {"key": key})
logger.info("Access class '%s' deleted via API", key)
return _ok(result)
except NotFound as exc:
logger.info("Access class not found: %s", exc)
return _error(str(exc), 404)
except Conflict as exc:
logger.info("Delete access class conflict: %s", exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to delete access class: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/up", methods=["POST"])
def class_up_bp(key):
"""Bring up a single access class's WireGuard tunnel.
Endpoint: POST /api/wireguard/classes/<key>/up
"""
try:
post(POST_WIREGUARD_CLASSES_UP, {"class_key": key})
logger.info("WireGuard class '%s' tunnel brought up via API", key)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring up class '%s': %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/down", methods=["POST"])
def class_down_bp(key):
"""Bring down a single access class's WireGuard tunnel.
Endpoint: POST /api/wireguard/classes/<key>/down
"""
try:
delete(DELETE_WIREGUARD_CLASSES_DOWN, {"class_key": key})
logger.info("WireGuard class '%s' tunnel brought down via API", key)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring down class '%s': %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/status", methods=["GET"])
def class_status_bp(key):
"""Get status for a single access class's tunnel.
Endpoint: GET /api/wireguard/classes/<key>/status
"""
try:
return _ok(get(GET_WIREGUARD_CLASS_STATUS, {"class_key": key}))
except RuntimeError as exc:
logger.error("Failed to get class '%s' status: %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/keys/<key>", methods=["POST"])
def class_init_keys_bp(key):
"""Generate key pair for a single access class.
Endpoint: POST /api/wireguard/classes/keys/<key>
"""
try:
post(POST_WIREGUARD_CLASS_INIT_KEYS, {"class_key": key})
logger.info("WireGuard class '%s' keys generated via API", key)
return _ok(None)
except NotFound as exc:
logger.info("Class '%s' not found for keys: %s", key, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to generate keys for class '%s': %s", key, exc)
return _error(str(exc), 500)
+154
View File
@@ -0,0 +1,154 @@
/**
* Hoover components/qr.js
*
* QR code SVG renderer with optional logo overlay.
* Uses qrcode-svg library for generation.
*/
import { h } from '../vdom.js?v=9';
import { esc } from '../helpers.js?v=9';
import QRCode from '../../../vendor/qrcode-svg-1.1.0.js';
/**
* Generate a QR code SVG string from text content.
*
* @param {object} props
* @param {string} props.text - Text to encode
* @param {number} [props.size=200] - QR code size in px
* @param {number} [props.margin=2] - Quiet zone margin
* @param {string} [props.ecLevel='Q'] - Error correction level (L/M/Q/H)
* @param {string} [props.logo] - Base64 data URL for center logo
* @param {number} [props.logoSize=40] - Logo size in px (when overlaying)
* @param {string} [props.color] - Foreground color (default: #000000)
* @param {string} [props.background] - Background color (default: #ffffff)
* @returns {string} SVG markup string
*/
export function qrSVG(props = {}) {
const {
text,
size = 200,
margin = 2,
ecLevel = 'Q',
logo,
logoSize = 40,
color = '#000000',
background = '#ffffff',
} = props;
if (!text) return '';
const qr = new QRCode({
content: text,
container: 'svg',
margin: margin,
padding: 0,
width: size,
height: size,
color: color,
background: background,
ecl: ecLevel,
creambo: false,
prettyprint: false,
});
let svg = qr.svg();
// Add center logo overlay if provided
if (logo) {
const halfSize = size / 2;
const halfLogo = logoSize / 2;
const ns = 'http://www.w3.org/2000/svg';
const parser = new DOMParser();
const doc = parser.parseFromString(svg, 'image/svg+xml');
const rootSvg = doc.documentElement;
const viewBox = rootSvg.getAttribute('viewBox') || `0 0 ${size} ${size}`;
const newSvg = doc.createElementNS(ns, 'svg');
newSvg.setAttribute('xmlns', ns);
newSvg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
newSvg.setAttribute('width', size);
newSvg.setAttribute('height', size);
newSvg.setAttribute('viewBox', viewBox);
// Clone original content
const clone = rootSvg.cloneNode(true);
while (clone.firstChild) {
newSvg.appendChild(clone.firstChild);
}
// White background behind logo
const bgRect = doc.createElementNS(ns, 'rect');
bgRect.setAttribute('x', halfSize - halfLogo - 4);
bgRect.setAttribute('y', halfSize - halfLogo - 4);
bgRect.setAttribute('width', logoSize + 8);
bgRect.setAttribute('height', logoSize + 8);
bgRect.setAttribute('fill', background);
newSvg.appendChild(bgRect);
// Logo image overlay
const img = doc.createElementNS(ns, 'image');
img.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', logo);
img.setAttribute('x', halfSize - halfLogo);
img.setAttribute('y', halfSize - halfLogo);
img.setAttribute('width', logoSize);
img.setAttribute('height', logoSize);
newSvg.appendChild(img);
const serializer = new XMLSerializer();
svg = serializer.serializeToString(newSvg);
}
return svg;
}
/**
* Render a QR code as a VNode with innerHTML for the SVG.
*
* @param {object} props
* @param {string} props.text - Text to encode
* @param {number} [props.size=200] - QR code size
* @param {string} [props.logo] - Base64 data URL for logo
* @param {number} [props.logoSize=40] - Logo overlay size
* @returns {object} VNode
*/
export function QRCodeVNode(props = {}) {
const svgString = qrSVG(props);
if (!svgString) {
return h('div', {}, h('span', { class: 'text-muted' }, 'No content'));
}
return h('div', {
class: 'qr-code-container text-center',
innerHTML: svgString,
});
}
/**
* Logo upload widget file input that produces base64 data URL.
*
* @param {object} props
* @param {string} props.id - Input element ID
* @param {function} props.onChange - Callback(logoBase64) on file select
*/
export function LogoUpload(props = {}) {
const inputId = props.id || 'qr-logo-input';
return h('div', { class: 'mb-2' }, [
h('label', { class: 'form-label' }, 'Logo (optional)'),
h('input', {
type: 'file',
id: inputId,
accept: 'image/*',
class: 'form-control',
onChange: function (e) {
const file = e.target.files?.[0];
if (!file || !props.onChange) return;
const reader = new FileReader();
reader.onload = function (ev) {
props.onChange(ev.target.result);
};
reader.readAsDataURL(file);
},
}),
]);
}
+3
View File
@@ -48,3 +48,6 @@ export { ApplyConfirm } from './components/applyconfirm.js?v=9';
/* ── UI Components: Toast ────────────────────────────────────── */ /* ── UI Components: Toast ────────────────────────────────────── */
export { ToastContainer } from './components/toast.js?v=9'; export { ToastContainer } from './components/toast.js?v=9';
/* ── UI Components: QR Code ──────────────────────────────────── */
export { qrSVG, QRCodeVNode, LogoUpload } from './components/qr.js?v=9';
+28 -1
View File
@@ -172,6 +172,30 @@ export default definePage({
</td> </td>
</tr>`); </tr>`);
const _setDomain = async (domain) => {
const res = await apiFetch('/api/dhcp/domain', {
method: 'POST',
body: { domain },
});
if (res.ok) {
toast('DNS domain updated', 'success');
modelFetch('dnsmasq');
} else {
toast(res.error || 'Failed to update', 'error');
}
};
const currentDomain = dnsCfg.domain || null;
const domainSection = html`<div class="domain-config" style="margin-bottom: 1rem;">
<label style="font-weight: 600;">Search Domain</label>
<p class="text-sm" style="margin: 0.25rem 0 0.5rem;">${currentDomain ? esc(currentDomain) : '<span class="text-muted">(not set)</span>'}</p>
<div class="form-inline" style="display: flex; gap: 0.5rem; align-items: center;">
<input id="domain-input" class="input" placeholder="example.local" />
<button class="btn btn-outline" onClick=${() => _setDomain(($val('domain-input') || '').trim())}>Set</button>
<button class="btn btn-outline" onClick=${() => _setDomain(null)}>Clear</button>
</div>
</div>`;
const tabNames = ['ranges', 'leases', 'dns', 'active']; const tabNames = ['ranges', 'leases', 'dns', 'active'];
const actions = ActionGroup( const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.zones?.active, state.firewall.data?.interfaces)(state)}>Add Range</button>`, html`<button class="btn btn-primary" onClick=${() => makeAddRange(state.firewall.data?.zones?.active, state.firewall.data?.interfaces)(state)}>Add Range</button>`,
@@ -222,7 +246,10 @@ export default definePage({
state.activeTab === 'leases' state.activeTab === 'leases'
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null, ? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null,
state.activeTab === 'dns' state.activeTab === 'dns'
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null, ? [
domainSection,
Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' })
] : null,
leaseTable, leaseTable,
]; ];
}, },
+40 -21
View File
@@ -38,13 +38,16 @@ export default definePage({
const zoneData = cfg.zones || {}; const zoneData = cfg.zones || {};
const sIface = (state.firewall.data?.state || {}).interfaces || []; const sIface = (state.firewall.data?.state || {}).interfaces || [];
const masqZones = new Set( // With nftables, masquerade is propagated to the public zone at runtime for
// POSTROUTING to work. The config-side masquerade flag indicates which
// zones source NAT traffic (LAN / internal), not where traffic exits (WAN).
const lanZones = new Set(
Object.entries(zoneData) Object.entries(zoneData)
.filter(([, zcfg]) => !!zcfg.masquerade) .filter(([, zcfg]) => !!zcfg.masquerade)
.map(([z]) => z) .map(([z]) => z)
); );
const wanIface = sIface.filter((i) => i.zone && masqZones.has(i.zone)); const wanIface = sIface.filter((i) => i.zone && !lanZones.has(i.zone));
const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone)); const lanIface = sIface.filter((i) => i.zone && lanZones.has(i.zone));
const ifaceRows = (ifaces) => const ifaceRows = (ifaces) =>
ifaces.map((iface) => html`<tr key=${'ii-' + iface.name}> ifaces.map((iface) => html`<tr key=${'ii-' + iface.name}>
@@ -60,24 +63,40 @@ export default definePage({
<td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td> <td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td>
</tr>`); </tr>`);
// Build set of non-public zones with masquerade — determines if public is auto-propagated
const anyNonPublicMasq = Object.entries(zoneData)
.filter(([zone]) => zone !== "public")
.some(([, zcfg]) => !!zcfg.masquerade);
const masqRows = Object.entries(zoneData) const masqRows = Object.entries(zoneData)
.filter(([zone]) => zone !== "public") .map(([zone, zcfg]) => {
.map(([zone, zcfg]) => { const masq = !!zcfg.masquerade;
const masq = !!zcfg.masquerade; const isPublic = zone === "public";
return html`<tr key=${'m-' + zone}> // Public zone masquerade is auto-propagated when any non-public zone
<td><strong>${zone}</strong></td> // has it enabled (nftables backend dispatches POSTROUTING to the
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td> // output interface's zone chain). Show it read-only with a note.
<td> if (isPublic) {
<${ActionButton} const effective = masq || anyNonPublicMasq;
url="/api/firewall/masquerade" return html`<tr key=${'m-' + zone}>
cls="btn btn-sm btn-outline" <td><strong>${zone}</strong> <span class="text-muted">(auto)</span></td>
labelOn="Disable" labelOff="Enable" condition=${masq} <td><${Badge} text=${effective ? 'Enabled' : 'Disabled'} variant=${effective ? 'success' : 'info'} /></td>
body=${() => ({ zone, enable: !masq })} <td><span class="text-muted">${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'}</span></td>
successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone} </tr>`;
refresh="firewall" /> }
</td> return html`<tr key=${'m-' + zone}>
</tr>`; <td><strong>${zone}</strong></td>
}); <td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
<td>
<${ActionButton}
url="/api/firewall/masquerade"
cls="btn btn-sm btn-outline"
labelOn="Disable" labelOff="Enable" condition=${masq}
body=${() => ({ zone, enable: !masq })}
successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone}
refresh="firewall" />
</td>
</tr>`;
});
const fwRows = []; const fwRows = [];
Object.entries(zoneData).forEach(([zone, zcfg]) => { Object.entries(zoneData).forEach(([zone, zcfg]) => {
@@ -109,7 +128,7 @@ export default definePage({
title: 'WAN / External', title: 'WAN / External',
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'], columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
rows: ifaceRows(wanIface), rows: ifaceRows(wanIface),
emptyText: 'No WAN interfaces with masquerade enabled', emptyText: 'No WAN interfaces',
}), }),
DataTableSection({ DataTableSection({
title: 'Internal / LAN', title: 'Internal / LAN',
+494 -39
View File
@@ -1,48 +1,219 @@
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob, formAction } from '/static/hoover/index.js?v=9'; /** WireGuard page — tunnel & peer management. */
import { html, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob, formAction, ApplyConfirm, qrSVG } from '/static/hoover/index.js?v=9';
/* ── LAN detection helper ────────────────────────────────────── */
function getLanSubnets() {
try {
const fw = getModel('firewall');
if (!fw?.data) return [];
const subnets = [];
for (const iface of (fw.data.interfaces || [])) {
if (!iface.zone) continue;
const zone = fw.data.zones?.[iface.zone];
if (!zone || zone.masquerade) continue;
for (const ip of (iface.ips || [])) {
if (!ip.includes('/')) continue;
if (!subnets.includes(ip)) subnets.push(ip);
}
}
return subnets;
} catch {
return [];
}
}
/* ── Allowed IPs helper ──────────────────────────────────────── */
function parseAllowedIps(value) {
if (!value || !value.trim()) return [];
return value.split(',').map(s => s.trim()).filter(Boolean);
}
/* ── Color helpers ────────────────────────────────────────────── */
function classColor(classKey) {
if (!classKey) return '';
const h = classKey.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
return '#' + ((h * 137) % 256).toString(16).padStart(2, '0')
+ '55' + ((h * 71) % 256).toString(16).padStart(2, '0');
}
/* ── Access classes helper to check keys initialized ──────── */
function classHasKeys(cls) {
return cls && cls.public_key && cls.public_key.length > 0;
}
/* ── Add Peer Modal ──────────────────────────────────────────── */
const addPeer = QuickModal({ const addPeer = QuickModal({
title: 'Add WireGuard Peer', title: 'Add WireGuard Peer',
fields: [ fields: [
{ label: 'Name', id: 'wg-name', placeholder: 'client-name' }, { label: 'Name', id: 'wg-name', placeholder: 'client-name' },
{ label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' }, { label: 'Endpoint (optional)', id: 'wg-endpoint', placeholder: '1.2.3.4:51820' },
{ label: 'Allowed IPs (optional)', id: 'wg-allowed', placeholder: '10.0.0.2/32' }, { label: 'Description (optional)', id: 'wg-description', placeholder: 'Peer label' },
{ label: 'Access Class *', id: 'wg-class', tag: 'select' },
{ label: 'Allowed IPs Preset', id: 'wg-allowed-preset', tag: 'select', value: 'all' },
{ label: 'Allowed IPs (custom)', id: 'wg-allowed', placeholder: '0.0.0.0/0' },
{ label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' }, { label: 'Persistent Keepalive (optional)', id: 'wg-keepalive', type: 'number', placeholder: '25' },
], ],
submit: { submit: {
url: '/api/wireguard/peers', url: '/api/wireguard/peers',
body: () => ({ body: (data) => {
name: ($val('wg-name') || '').trim(), const preset = ($val('wg-allowed-preset') || 'all');
endpoint: ($val('wg-endpoint') || '').trim() || undefined, let allowed_ips;
allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [], if (preset === 'lan') {
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined, allowed_ips = getLanSubnets();
}), } else if (preset === 'none') {
validate: (b) => !b.name ? 'Name is required' : null, allowed_ips = [];
} else if (preset === 'custom') {
allowed_ips = parseAllowedIps($val('wg-allowed'));
} else {
allowed_ips = ['0.0.0.0/0'];
}
return {
name: ($val('wg-name') || '').trim(),
endpoint: ($val('wg-endpoint') || '').trim() || undefined,
description: ($val('wg-description') || '').trim() || undefined,
access_class: ($val('wg-class') || '').trim() || undefined,
allowed_ips,
persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined,
};
},
validate: (b) => !b.name ? 'Name is required' :
!b.access_class ? 'Access Class is required' : null,
successMsg: 'Peer added', successMsg: 'Peer added',
}, },
refresh: 'wireguard', refresh: 'wireguard',
postRender: (inner, data) => {
const presetEl = document.getElementById('wg-allowed-preset');
if (presetEl) {
const customField = inner.querySelector('.form-group:has(#wg-allowed)');
const toggle = () => {
customField.style.display = presetEl.value === 'custom' ? '' : 'none';
};
presetEl.onchange = toggle;
toggle();
}
},
}); });
function updateAddPeerOptions(wireguardState) {
const classes = wireguardState?.access_classes || {};
updateClassDropdown(classes);
updateLanOptions();
}
function updateClassDropdown(classes) {
const selectEl = document.getElementById('wg-class');
if (!selectEl) return;
selectEl.innerHTML = Object.entries(classes).map(([k, v]) =>
`<option value="${esc(k)}">${esc(v.name || k)}</option>`
).join('');
}
function updateLanOptions() {
const selectEl = document.getElementById('wg-allowed-preset');
if (!selectEl) return;
const subnets = getLanSubnets();
selectEl.innerHTML =
'<option value="all">Route all traffic (default)</option>' +
(subnets.length
? `<option value="lan">Route LAN only (${esc(subnets.join(', '))})</option>`
: '<option value="lan">Route LAN only (detecting\u2026)</option>') +
'<option value="none">Route nothing (peer-initiated only)</option>' +
'<option value="custom">Custom</option>';
}
/* ── Download Config + QR Modal ──────────────────────────────── */
function downloadConfigModal(peerName, config, state) { function downloadConfigModal(peerName, config, state) {
const peer = (config?.peers || {})[peerName];
const ak = peer?.access_class;
let listenPort = 51820;
if (ak && config?.access_classes?.[ak]) {
listenPort = config.access_classes[ak].listen_port || 51820;
}
const baseHost = (config?.interface?.server_endpoint || '').split(':')[0];
const endpoint = baseHost ? baseHost + ':' + listenPort : '';
let generatedConfig = null;
let logoBase64 = null;
const renderQrView = (innerEl, modalIdx) => {
const svgStr = qrSVG({ text: generatedConfig, size: 256, logo: logoBase64, logoSize: 48 });
innerEl.innerHTML = `
<h4 class="mb-3">Peer Config: ${esc(peerName)}</h4>
<div class="text-center mb-3">${svgStr}</div>
<div class="mb-3">
<label class="form-label">Logo overlay (rescans QR)</label>
<input type="file" id="qr-logo-input" accept="image/*" class="form-control mb-1">
<small class="text-muted">Upload a logo to overlay on the QR code.</small>
</div>
<div class="mb-3">
<label class="form-label">Config Text</label>
<pre class="code" style="max-height:200px;overflow:auto;font-size:0.8rem;">${esc(generatedConfig)}</pre>
</div>
<div class="d-flex justify-content-end gap-2 mt-3">
<button class="btn btn-sm btn-outline" id="qr-restart">Regenerate</button>
<button class="btn btn-sm btn-outline" id="qr-download">Download .conf</button>
<button class="btn btn-sm btn-primary" id="qr-close">Close</button>
</div>
`;
innerEl.querySelector('#qr-logo-input').addEventListener('change', (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
logoBase64 = ev.target.result;
renderQrView(innerEl, modalIdx);
};
reader.readAsDataURL(file);
});
innerEl.querySelector('#qr-restart').addEventListener('click', () => {
generatedConfig = null;
logoBase64 = null;
openModal((inn, i) => downloadConfigModal(peerName, config, state), modalIdx);
});
innerEl.querySelector('#qr-download').addEventListener('click', () => {
downloadBlob(new Blob([generatedConfig], { type: 'text/plain' }), peerName + '.conf');
toast('Config downloaded', 'success');
closeModal(modalIdx);
});
innerEl.querySelector('#qr-close').addEventListener('click', () => closeModal(modalIdx));
};
openModal((inner, idx) => { openModal((inner, idx) => {
formModal(inner, 'Download Config for ' + peerName, if (generatedConfig) {
[{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820' }], renderQrView(inner, idx);
return;
}
formModal(inner, 'Peer Config: ' + peerName,
[
{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820', value: endpoint },
],
[ [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) }, { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{ {
label: 'Generate', cls: 'btn-primary', action: 's', label: 'Generate', cls: 'btn-primary', action: 's',
handler: formAction(async () => { handler: formAction(async () => {
const endpoint = ($val('wg-srv-endpoint') || '').trim(); const ep = ($val('wg-srv-endpoint') || '').trim();
if (!endpoint) throw 'Server endpoint is required'; if (!ep) throw 'Server endpoint is required';
const resp = await apiFetch('/api/wireguard/generate-client', { const resp = await apiFetch('/api/wireguard/generate-client', {
method: 'POST', method: 'POST',
body: { name: peerName, server_endpoint: endpoint }, body: { name: peerName, server_endpoint: ep },
}); });
if (!resp.ok) throw resp.error || 'Failed'; if (!resp.ok) throw resp.error || 'Failed';
const configContent = resp.data?.config; const configContent = resp.data?.config;
if (!configContent) throw 'No config returned'; if (!configContent) throw 'No config returned';
downloadBlob(new Blob([configContent], { type: 'text/plain' }), peerName + '.conf'); generatedConfig = configContent;
toast('Config downloaded', 'success');
closeModal(idx); // Re-render modal with QR view
openModal((inn, i) => downloadConfigModal(peerName, config, state), idx);
}), }),
}, },
], ],
@@ -50,37 +221,287 @@ function downloadConfigModal(peerName, config, state) {
}); });
} }
/* ── Interface Settings Modal ────────────────────────────────── */
function settingsModal(wireguardData, state) {
const iface = wireguardData?.config?.interface || {};
openModal((inner, idx) => {
formModal(inner, 'WireGuard Settings',
[
{ label: 'Listen Port', id: 'wg-port', type: 'number', value: iface.listen_port || 51820, placeholder: '51820' },
{ label: 'Addresses (comma-separated CIDR)', id: 'wg-addrs', value: (iface.addresses || []).join(', ') || '10.137.0.1/24' },
{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820', value: iface.server_endpoint || '' },
{ label: 'Description', id: 'wg-desc', value: iface.description || '', placeholder: 'Optional label' },
{ label: 'PostUp (advanced)', id: 'wg-post-up', tag: 'textarea', value: iface.post_up || '', placeholder: 'Shell command after interface up' },
{ label: 'PostDown (advanced)', id: 'wg-post-down', tag: 'textarea', value: iface.post_down || '', placeholder: 'Shell command after interface down' },
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Save', cls: 'btn-primary', action: 's',
handler: formAction(async () => {
const port = parseInt($val('wg-port'), 10);
if (isNaN(port) || port < 1 || port > 65535) throw 'Invalid port';
const addresses = ($val('wg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean);
if (!addresses.length) throw 'At least one address required';
const body = {
interface: {
listen_port: port,
addresses,
server_endpoint: ($val('wg-srv-endpoint') || '').trim() || undefined,
description: ($val('wg-desc') || '').trim() || undefined,
post_up: ($val('wg-post-up') || '').trim() || null,
post_down: ($val('wg-post-down') || '').trim() || null,
},
};
const resp = await apiFetch('/api/wireguard/config', {
method: 'PATCH',
body,
});
if (!resp.ok) throw resp.error || 'Failed to save';
toast('Settings saved', 'success');
closeModal(idx);
modelFetch('wireguard');
}),
},
],
);
});
}
/* ── Class Settings Modal ────────────────────────────────────── */
const addClass = QuickModal({
title: 'Add Access Class',
fields: [
{ label: 'Key', id: 'wc-key', placeholder: 'my-class' },
{ label: 'Name', id: 'wc-name', placeholder: 'Display Name' },
{ label: 'Description', id: 'wc-desc', placeholder: 'Optional' },
{ label: 'Subnet (CIDR)', id: 'wc-subnet', placeholder: '10.137.2.0/24' },
{ label: 'Listen Port', id: 'wc-port', type: 'number', placeholder: '51822' },
{ label: 'LAN Access', id: 'wc-lan', tag: 'select', value: '0' },
],
submit: {
url: '/api/wireguard/classes',
body: () => ({
key: ($val('wc-key') || '').trim(),
name: ($val('wc-name') || '').trim(),
description: ($val('wc-desc') || '').trim(),
subnet: ($val('wc-subnet') || '').trim() || undefined,
listen_port: parseInt($val('wc-port'), 10) || 0,
lan_access: $val('wc-lan') === '1',
}),
validate: (b) => !b.key ? 'Key is required' :
!/^[a-z0-9]+$/.test(b.key) ? 'Key must be lowercase alphanumeric' :
!b.subnet ? 'Subnet is required' :
!b.listen_port ? 'Listen port is required' : null,
successMsg: 'Class added',
},
refresh: 'wireguard',
postRender: (inner) => {
const sel = document.getElementById('wc-lan');
if (sel) {
sel.innerHTML = '<option value="1">Yes (Full LAN Access)</option><option value="0">No (Internet Only)</option>';
}
},
});
function editClassModal(key, cls, peerCount) {
openModal((inner, idx) => {
formModal(inner, 'Edit Access Class: ' + key,
[
{ label: 'Key', id: 'wc-key', value: key, disabled: true },
{ label: 'Name', id: 'wc-name', value: cls?.name || '' },
{ label: 'Description', id: 'wc-desc', value: cls?.description || '' },
{ label: 'Subnet (CIDR)', id: 'wc-subnet', value: cls?.subnet || '', placeholder: '10.137.2.0/24' },
{ label: 'Listen Port', id: 'wc-port', type: 'number', value: cls?.listen_port || '', placeholder: '51822' },
{ label: 'LAN Access', id: 'wc-lan', tag: 'select', value: cls?.lan_access ? '1' : '0' },
],
[
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
{
label: 'Save', cls: 'btn-primary', action: 's',
handler: formAction(async () => {
const resp = await apiFetch('/api/wireguard/classes', {
method: 'PATCH',
body: {
key,
name: ($val('wc-name') || '').trim() || key,
description: ($val('wc-desc') || '').trim(),
subnet: ($val('wc-subnet') || '').trim() || undefined,
listen_port: parseInt($val('wc-port'), 10) || undefined,
lan_access: $val('wc-lan') === '1',
},
});
if (!resp.ok) throw resp.error || 'Failed';
toast('Class updated', 'success');
closeModal(idx);
modelFetch('wireguard');
}),
},
],
);
});
}
async function initClassKeys(classKey) {
const resp = await apiFetch('/api/wireguard/classes/keys/' + enc(classKey), {
method: 'POST',
});
if (!resp.ok) {
toast(resp.error || 'Failed to generate keys', 'error');
return;
}
toast('Keys generated for class "' + classKey + '"', 'success');
modelFetch('wireguard');
}
async function deleteAccessClass(key) {
if (!confirm(`Delete access class '${key}'?`)) return;
const resp = await apiFetch('/api/wireguard/classes', {
method: 'DELETE',
body: { key },
});
if (!resp.ok) {
toast(resp.error || 'Failed to delete class', 'error');
return;
}
toast('Class deleted', 'success');
modelFetch('wireguard');
}
async function toggleClassTunnel(classKey, isUp) {
const url = '/api/wireguard/classes/' + enc(classKey) + '/' + (isUp ? 'down' : 'up');
const resp = await apiFetch(url, { method: 'POST' });
if (!resp.ok) {
toast(resp.error || 'Failed', 'error');
return;
}
toast('Tunnel ' + (isUp ? 'stopped' : 'started'), 'success');
modelFetch('wireguard');
}
/* ── Access Classes Section ──────────────────────────────────── */
function renderAccessClasses(config, status) {
const classes = config?.access_classes || {};
const entries = Object.entries(classes);
if (!entries.length) return null;
const peerCountMap = {};
for (const [pname, pinfo] of Object.entries(config?.peers || {})) {
const ac = pinfo?.access_class;
if (ac) {
peerCountMap[ac] = (peerCountMap[ac] || 0) + 1;
}
}
const classStatuses = status?.classes || {};
const rows = entries.map(([k, v]) => {
const pCount = peerCountMap[k] || 0;
const clsStatus = classStatuses[k] || { up: false };
const isUp = clsStatus.up;
const hasKeys = classHasKeys(v);
const color = classColor(k);
return html`<tr key=${k}>
<td style="border-left: 3px solid ${color}"><strong>${esc(k)}</strong></td>
<td>${esc(v.name || k)}</td>
<td class="text-sm">${esc(v.description || '-')}</td>
<td class="text-sm">${esc(v.subnet || '-')}</td>
<td class="text-sm">${v.listen_port || '-'}</td>
<td class="text-sm">${v.lan_access ? 'Yes' : 'No'}</td>
<td>${pCount}</td>
<td class="text-sm">
<${StatusDot} status=${isUp ? 'success' : 'danger'} />
</td>
<td>
${!hasKeys
? html`<button class="btn btn-sm btn-warning" onClick=${() => initClassKeys(k)} title="Generate keys">Keys</button>`
: ''}
<button class="btn btn-sm btn-outline" onClick=${() => editClassModal(k, v, pCount)}>Edit</button>
<button class="btn btn-sm btn-outline" onClick=${() => toggleClassTunnel(k, isUp)}>${isUp ? 'Stop' : 'Start'}</button>
${(pCount > 0)
? html`<button class="btn btn-sm btn-outline" disabled title="Peers reference this class">Delete</button>`
: html`<button class="btn btn-sm btn-outline" onClick=${() => deleteAccessClass(k)}>Delete</button>`}
</td>
</tr>`;
});
return html`<div class="mt-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h4 class="section-title m-0">Access Classes</h4>
<button class="btn btn-sm btn-primary" onClick=${() => addClass()}>Add Class</button>
</div>
<table class="table table-sm"><thead><tr>
<th>Key</th><th>Name</th><th>Description</th><th>Subnet</th><th>Port</th><th>LAN</th><th>Peers</th><th>Status</th><th>Actions</th>
</tr></thead><tbody>${rows}</tbody></table>
</div>`;
}
/* ── Main Page ───────────────────────────────────────────────── */
export default definePage({ export default definePage({
init() { init() {
return { return {
wireguard: getModel('wireguard'), wireguard: getModel('wireguard'),
}; };
}, },
subscribe(state) {
// Update add-peer modal options when state changes
if (state.wireguard?.data) {
updateAddPeerOptions(state.wireguard.data.config);
}
},
render(state) { render(state) {
const guard = renderGuard(state.wireguard, 'WireGuard', 'Tunnel & peer management', state.wireguard.data); const guard = renderGuard(state.wireguard, 'WireGuard', 'Tunnel & peer management', state.wireguard.data?.config);
if (guard) return guard; if (guard) return guard;
const st = state.wireguard.data?.status || {}; const wgData = state.wireguard.data;
const st = wgData?.status || {};
const config = wgData?.config || {};
const isUp = st.up || false; const isUp = st.up || false;
const listenPort = (state.wireguard.data?.config?.interface || {}).listen_port || '-'; const listenPort = config.interface?.listen_port || '-';
const serverEndpoint = config.interface?.server_endpoint || '';
const peerRows = (state.wireguard.data?.peers || []).map(p => { // Build merged peer rows: configured peers + live status
const hasHandshake = !!p.latest_handshake; const configuredPeers = wgData?.peers || [];
return html`<tr key=${p.name}> const statusPeersMap = {};
for (const [cKey, cSt] of Object.entries(st.classes || {})) {
for (const sp of (cSt.peers || [])) {
statusPeersMap[sp.public_key] = { ...sp, _class: cKey };
}
}
// Also check legacy status peers
for (const sp of (st.peers || [])) {
statusPeersMap[sp.public_key] = sp;
}
const peersByClass = config?.access_classes || {};
const peerRows = configuredPeers.map(p => {
const sp = statusPeersMap[p.public_key];
const isConnected = sp && !!sp.latest_handshake;
const accessClass = p.access_class;
const classInfo = accessClass ? (peersByClass[accessClass] || null) : null;
const borderColor = classInfo ? ' style="border-left: 3px solid ' + classColor(accessClass) + '"' : '';
return html`<tr key=${p.name}${borderColor}>
<td> <td>
<${StatusDot} status=${hasHandshake ? 'success' : 'danger'} /> <${StatusDot} status=${isConnected ? 'success' : 'danger'} />
<strong>${esc(p.name || 'unnamed')}</strong> <strong>${esc(p.name || 'unnamed')}</strong>
${p.description ? html`<br/><span class="text-muted text-sm">${esc(p.description)}</span>` : ''}
</td> </td>
<td><${MonoText} text=${p.public_key || 'N/A'} maxLength=20 /></td> <td><${MonoText} text=${p.public_key || 'N/A'} maxLength=20 /></td>
<td class="text-sm">${esc(p.allowed_ips || '-')}</td> <td class="text-sm">${esc((p.allowed_ips || []).join(', ') || '-')}</td>
<td class="text-sm">${esc(p.endpoint || '-')}</td> <td class="text-sm">${esc(p.endpoint || '-')}</td>
<td class="text-sm">${esc(p.latest_handshake || 'Never')}</td>
<td class="text-sm"> <td class="text-sm">
Recv: ${esc(p.transfer_recv || '0')}<br/> ${classInfo
Sent: ${esc(p.transfer_sent || '0')} ? html`<${Badge} text=${esc(classInfo.name)} cls="bg-info text-white" />`
: html`<${Badge} text="Unassigned" cls="bg-secondary text-white" />`}
</td>
<td class="text-sm">${esc(sp?.latest_handshake || 'Never')}</td>
<td class="text-sm">
Recv: ${esc(sp?.transfer_received || '0')}<br/>
Sent: ${esc(sp?.transfer_sent || '0')}
</td> </td>
<${ActionCell} <${ActionCell}
editLabel="Config" editClick=${() => downloadConfigModal(p.name, state.wireguard.data?.config, state)} editLabel="Config" editClick=${() => downloadConfigModal(p.name, config, state)}
removeUrl=${'/api/wireguard/peers/' + enc(p.name)} removeUrl=${'/api/wireguard/peers/' + enc(p.name)}
removeMessage=${'Remove peer ' + p.name + '?'} removeMessage=${'Remove peer ' + p.name + '?'}
removeSuccess="Peer removed" removeSuccess="Peer removed"
@@ -89,35 +510,69 @@ export default definePage({
</tr>`; </tr>`;
}); });
// Per-class summary
const classEntries = Object.entries(config?.access_classes || {});
let classSummaryCards = null;
if (classEntries.length) {
const cards = classEntries.map(([k, v]) => {
const cSt = (st.classes || {})[k] || { up: false, peers: [] };
const isUp = cSt.up;
const pCount = (wgData?.peers || []).filter(p => p.access_class === k).length;
const color = classColor(k);
return html`<div key=${k} class="card" style="border-left: 3px solid ${color}">
<div class="card-header d-flex justify-content-between align-items-center">
<span><${StatusDot} status=${isUp ? 'success' : 'danger'} /> <strong>${esc(v.name || k)}</strong> <span class="text-muted">(${esc(k)})</span></span>
<span class="text-sm">${pCount} peer(s), port ${v.listen_port || '-'}</span>
</div>
<div class="card-body text-sm">
<div class="d-flex justify-content-between">
<span>Subnet: ${esc(v.subnet || '-')}</span>
<span>LAN: ${v.lan_access ? 'Yes' : 'No'}</span>
<span>Keys: ${classHasKeys(v) ? 'Ready' : html`<button class="btn btn-xs btn-warning" onClick=${() => initClassKeys(k)}>Generate</button>`}</span>
</div>
<div style="margin-top: 4px;">
<button class="btn btn-xs btn-outline" onClick=${() => toggleClassTunnel(k, isUp)}>${isUp ? 'Stop' : 'Start'}</button>
</div>
</div>
</div>`;
});
classSummaryCards = html`<div class="mt-2 mb-2 d-flex gap-2 flex-wrap">${cards}</div>`;
}
const actions = ActionGroup( const actions = ActionGroup(
html`<button class="btn btn-primary" onClick=${() => addPeer(state)}>Add Peer</button>`, html`<button class="btn btn-primary" onClick=${() => addPeer()}>Add Peer</button>`,
html`<button class="btn btn-outline" onClick=${() => settingsModal(wgData, state)} title="Interface Settings">\u{1F527}</button>`,
ActionButton({ ActionButton({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'), url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop', labelOff: 'Start', condition: isUp, labelOn: 'Stop All', labelOff: 'Start All', condition: isUp,
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'), successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
refresh: 'wireguard', refresh: 'wireguard',
}), }),
ActionButton({ ApplyConfirm({
url: '/api/wireguard/apply', pending: st.pending_changes || false,
successMsg: 'Config applied', successMsg: 'WireGuard applied',
label: 'Apply', refresh: ['wireguard', 'firewall'],
refresh: 'wireguard',
}), }),
); );
const subtitleParts = ['Tunnel: ' + (isUp ? 'up' : 'down'), 'Listen: ' + listenPort];
if (serverEndpoint) subtitleParts.push('Endpoint: ' + serverEndpoint);
return [ return [
PageHeader({ PageHeader({
title: 'WireGuard', title: 'WireGuard',
subtitle: 'Tunnel: ' + (st.up ? 'up' : 'down') + ', Listen: ' + listenPort, subtitle: subtitleParts.join(' | '),
actions, actions,
}), }),
ServiceStatus({ state: st.up ? 'up' : 'down' }), ServiceStatus({ state: isUp ? 'up' : 'down' }),
classSummaryCards,
peerRows.length peerRows.length
? Table({ ? Table({
columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Handshake', 'Transfer', 'Actions'], columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'],
rows: peerRows, rows: peerRows,
}) })
: Empty({ text: 'No peers configured. Add a peer above.' }), : Empty({ text: 'No peers configured. Add a peer above.' }),
renderAccessClasses(config, st),
]; ];
}, },
}); });