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:
+49
-14
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user