diff --git a/daemon/handlers/firewall.py b/daemon/handlers/firewall.py index 1c93fdc..a637ebd 100644 --- a/daemon/handlers/firewall.py +++ b/daemon/handlers/firewall.py @@ -136,16 +136,22 @@ def _config_apply() -> dict[str, Any]: need_create = zone_name not in available if need_create: - target = _normalize_target(zone_cfg.get("target", "DEFAULT")) + # Create new zone first (--new-zone is required before --set-target) run( - [ - "firewall-cmd", - f"--zone={zone_name}", - f"--set-target={target}", - "--permanent", - ], + ["firewall-cmd", f"--new-zone={zone_name}", "--permanent"], 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() else: 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"). - mq = zone_cfg.get("masquerade", False) - if mq is not None: - action = "--add-masquerade" if mq else "--remove-masquerade" - run( - ["firewall-cmd", f"--zone={zone_name}", action, "--permanent"], - sudo=True, - ) + # Skip 'public' — Step 7 handles masquerade propagation for nftables. + if zone_name != "public": + mq = zone_cfg.get("masquerade", False) + if mq is not None: + action = "--add-masquerade" if mq else "--remove-masquerade" + run( + ["firewall-cmd", f"--zone={zone_name}", action, "--permanent"], + sudo=True, + ) # Step 5: Reconcile rich rules — remove all current, add desired. # 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) + # 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() full_state = { "active_zones": {}, diff --git a/daemon/handlers/network.py b/daemon/handlers/network.py index 1f725eb..68f464b 100644 --- a/daemon/handlers/network.py +++ b/daemon/handlers/network.py @@ -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) - if deployed: - cfg_after = get_config() - cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) - save_config(cfg_after) + # Always stamp the hash so pending-changes detection stays current + # even when deployment fails (e.g. in containerized environments). + # The hash represents the JSON config state, not the system state. + cfg_after = get_config() + cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) + save_config(cfg_after) sync_result = bus.emit( 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 { "name": name, "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) save_config(cfg_after) 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( "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) 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} diff --git a/daemon/handlers/wireguard.py b/daemon/handlers/wireguard.py index c376af1..0969636 100644 --- a/daemon/handlers/wireguard.py +++ b/daemon/handlers/wireguard.py @@ -2,101 +2,73 @@ import logging import os -from copy import deepcopy -from datetime import UTC, datetime from pathlib import Path from typing import Any -from jinja2 import Environment, FileSystemLoader - from daemon.iface import ( + DELETE_WIREGUARD_CLASSES, + DELETE_WIREGUARD_CLASSES_DOWN, DELETE_WIREGUARD_PEERS_REMOVE, + GET_WIREGUARD_CLASS_STATUS, + GET_WIREGUARD_CLASSES, GET_WIREGUARD_CONFIG, GET_WIREGUARD_PEER_STATUS, GET_WIREGUARD_PEERS, GET_WIREGUARD_STATUS, + PATCH_WIREGUARD_CLASSES, PATCH_WIREGUARD_CONFIG, POST_WIREGUARD_APPLY, + POST_WIREGUARD_CLASS_INIT_KEYS, + POST_WIREGUARD_CLASSES, + POST_WIREGUARD_CLASSES_UP, POST_WIREGUARD_CONFIG, POST_WIREGUARD_DOWN, POST_WIREGUARD_GENERATE_CLIENT, POST_WIREGUARD_INITIALIZE, POST_WIREGUARD_PEERS_ADD, ) -from daemon.server import NotFoundError, refresh_state, registry +from daemon.server import ConflictError, NotFoundError, refresh_state, registry from lib.common import ( _APPLY_HASH_KEY, config_hash, deep_merge, - load_json, run, - run_proc, - save_json, ) from lib.sync import SyncEvent, bus +from lib.wireguard import ( + _class_interface_name, + _class_peers, + _ensure_access_classes, + _wg_conf_path, + generate_class_conf, + generate_class_keypair, + generate_conf, + generate_keypair, +) +from lib.wireguard import ( + generate_client_conf as _gen_client_conf, +) +from lib.wireguard import ( + get_config as _get_wireguard_config, +) +from lib.wireguard import ( + get_peers as _get_wireguard_peers, +) +from lib.wireguard import ( + save_config as _save_wireguard_config, +) logger = logging.getLogger(__name__) -PROJECT_DIR = Path(__file__).resolve().parent.parent.parent -CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json" -WG_CONF_PATH = "/etc/wireguard/wg0.conf" WG_QUICK_BIN = "wg-quick" -WG_BIN = "wg" - -ENV = Environment( - loader=FileSystemLoader(str(PROJECT_DIR / "system")), - autoescape=False, - lstrip_blocks=True, - trim_blocks=True, -) - -DEFAULT_CONFIG: dict[str, Any] = { - "interface": { - "name": "wg0", - "listen_port": 51820, - "private_key": "", - "public_key": "", - "addresses": ["10.137.0.1/24"], - "post_up": None, - "post_down": None, - }, - "peers": {}, -} - - -def _get_state() -> dict[str, Any] | None: - """Retrieve cached WireGuard state from the global state store.""" - from lib.state import state as state_store - - return state_store.get("wireguard") - - -def _get_config() -> dict[str, Any]: - """Load and merge the WireGuard config with defaults.""" - return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH)) - - -def _save_config(cfg: dict[str, Any]) -> None: - """Persist the WireGuard config to disk.""" - save_json(CONFIG_PATH, cfg) - - -def _generate_conf(cfg: dict[str, Any]) -> str: - """Render the WireGuard server config file from Jinja template.""" - tmpl = ENV.get_template("wireguard.conf") - return tmpl.render( - timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), - interface=cfg["interface"], - peers=cfg.get("peers", {}), - ) def _get_wg_state() -> dict[str, Any]: """Return cached WireGuard state, or empty dict if not yet loaded.""" - wg = _get_state() - if wg is None: - return {} - return wg + from lib.state import state as state_store + + wg = state_store.get("wireguard") + return {} if wg is None else wg # --------------------------------------------------------------------------- @@ -109,32 +81,52 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]: wg = _get_wg_state() if wg: return wg.get("config", {}) - cfg = _get_config() + cfg = _get_wireguard_config() safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY} if "interface" in safe: safe["interface"] = dict(safe["interface"]) safe["interface"].pop("private_key", None) + if "access_classes" in safe: + for ck, cv in safe["access_classes"].items(): + if isinstance(cv, dict): + safe["access_classes"][ck] = dict(cv) + safe["access_classes"][ck].pop("private_key", None) return safe @registry.register(POST_WIREGUARD_CONFIG) def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: - """POST /wireguard/config — replace config, preserving existing private key. + """POST /wireguard/config — replace config, preserving existing private keys. Raises: ValueError: When request body is missing. """ if not body: raise ValueError("Request body required") - current = _get_config() + + current = _get_wireguard_config() current_key = current.get("interface", {}).get("private_key", "") + if "interface" in body: body = dict(body) body["interface"] = dict(body["interface"]) body["interface"].pop("private_key", None) + if current_key: body.setdefault("interface", {})["private_key"] = current_key - _save_config(body) + + # Preserve class private keys + if "access_classes" in body: + current_classes = current.get("access_classes", {}) + for ck, cv in body.get("access_classes", {}).items(): + if isinstance(cv, dict) and ck in current_classes: + cur_class_pk = current_classes[ck].get("private_key", "") + if cur_class_pk and ck in body["access_classes"]: + body["access_classes"][ck]["private_key"] = cur_class_pk + elif "access_classes" not in body: + body["access_classes"] = current.get("access_classes", {}) + + _save_wireguard_config(body) sync_result = bus.emit( SyncEvent("wireguard", "config_saved", {"action": "config_saved"}) ) @@ -151,13 +143,21 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: """ if not body: raise ValueError("Request body required") + if "interface" in body: body = dict(body) body["interface"] = dict(body["interface"]) body["interface"].pop("private_key", None) - current = _get_config() + + # Strip class private keys from patch body + if "access_classes" in body: + for _ck, cv in body["access_classes"].items(): + if isinstance(cv, dict): + cv.pop("private_key", None) + + current = _get_wireguard_config() merged = deep_merge(current, body) - _save_config(merged) + _save_wireguard_config(merged) sync_result = bus.emit( SyncEvent("wireguard", "config_saved", {"action": "config_patched"}) ) @@ -167,36 +167,99 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: @registry.register(POST_WIREGUARD_APPLY) def apply(_request: Any, _body: Any) -> dict[str, Any]: - """POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo.""" - cfg = _get_config() - conf_text = _generate_conf(cfg) - _save_config(cfg) - local_tmp = Path("/run/vacuum-wall/wg0.conf.tmp") - local_tmp.parent.mkdir(exist_ok=True) - local_tmp.write_text(conf_text) - os.chmod(local_tmp, 0o600) - run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True) - run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False) - local_tmp.unlink(missing_ok=True) - run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True) - logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"]) - cfg_after = _get_config() + """POST /wireguard/apply — render config, write to disk, bring up tunnels via sudo. + + In multi-interface mode, applies each class with assigned peers + independently. Falls back to legacy single-interface mode. + """ + cfg = _get_wireguard_config() + classes = cfg.get("access_classes", {}) + affected: list[str] = [] + + for class_key in classes: + class_cfg = classes.get(class_key) + if not class_cfg or not isinstance(class_cfg, dict): + continue + if not _class_peers(cfg, class_key): + continue + + try: + conf_text = generate_class_conf(cfg, class_key) + if not conf_text: + continue + except ValueError as e: + logger.warning("Skipping class '%s' during apply: %s", class_key, e) + continue + + ifname = _class_interface_name(class_key) + conf_path = _wg_conf_path(ifname) + local_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp") + local_tmp.parent.mkdir(exist_ok=True) + local_tmp.write_text(conf_text) + os.chmod(local_tmp, 0o600) + run(["cp", "--", str(local_tmp), conf_path], sudo=True) + run(["chown", "root:root", conf_path], sudo=True, check=False) + local_tmp.unlink(missing_ok=True) + run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False) + logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key) + affected.append(f"{ifname}.conf") + + if not affected: + # Legacy single-interface mode + conf_text = generate_conf(cfg) + ifname = cfg["interface"]["name"] + conf_path = _wg_conf_path(ifname) + local_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp") + local_tmp.parent.mkdir(exist_ok=True) + local_tmp.write_text(conf_text) + os.chmod(local_tmp, 0o600) + run(["cp", "--", str(local_tmp), conf_path], sudo=True) + run(["chown", "root:root", conf_path], sudo=True, check=False) + local_tmp.unlink(missing_ok=True) + run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False) + logger.info("WireGuard tunnel '%s' brought up", ifname) + + cfg_after = _get_wireguard_config() cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) - _save_config(cfg_after) + _save_wireguard_config(cfg_after) sync_result = bus.emit( SyncEvent("wireguard", "config_saved", {"action": "config_applied"}) ) refresh_state(["wireguard", *sync_result.affected_subsystems]) - return {"applied": True, "synced": sync_result.affected_subsystems} + return { + "applied": True, + "synced": sync_result.affected_subsystems, + "interfaces": affected, + } @registry.register(POST_WIREGUARD_DOWN) def down(_request: Any, _body: Any) -> dict[str, Any]: - """POST /wireguard/down — bring down the WireGuard tunnel via sudo.""" - cfg = _get_config() - name = cfg["interface"]["name"] - run([WG_QUICK_BIN, "down", name], sudo=True) - logger.info("WireGuard tunnel '%s' brought down", name) + """POST /wireguard/down — bring down all WireGuard tunnel interfaces.""" + cfg = _get_wireguard_config() + classes = cfg.get("access_classes", {}) + for class_key in classes: + class_cfg = classes.get(class_key) + if not class_cfg or not isinstance(class_cfg, dict): + continue + ifname = _class_interface_name(class_key) + try: + run([WG_QUICK_BIN, "down", ifname], sudo=True) + logger.info("WireGuard tunnel '%s' brought down", ifname) + except Exception: + pass + + # Legacy interface (skip if name matches any class interface) + ifname = cfg["interface"].get("name", "") + if ifname: + class_names = {_class_interface_name(k) for k in classes} + if ifname not in class_names: + try: + run([WG_QUICK_BIN, "down", ifname], sudo=True) + logger.info("WireGuard tunnel '%s' brought down", ifname) + except Exception: + pass + sync_result = bus.emit( SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"}) ) @@ -204,39 +267,160 @@ def down(_request: Any, _body: Any) -> dict[str, Any]: return {"down": True} +@registry.register(POST_WIREGUARD_CLASSES_UP) +def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """POST /wireguard/classes//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//down — bring down a single class's tunnel. + + Raises: + ValueError: When body or key is missing. + """ + if not body: + raise ValueError("Request body required") + class_key = body.get("class_key", "").strip() + if not class_key: + raise ValueError("'class_key' is required") + + cfg = _get_wireguard_config() + if class_key not in cfg.get("access_classes", {}): + raise NotFoundError(f"Access class '{class_key}' not found") + + ifname = _class_interface_name(class_key) + try: + run([WG_QUICK_BIN, "down", ifname], sudo=True) + logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname) + except Exception: + pass + sync_result = bus.emit( + SyncEvent("wireguard", "config_saved", {"action": "class_down", "class_key": class_key}) + ) + refresh_state(["wireguard", *sync_result.affected_subsystems]) + return {"down": True, "interface": ifname} + + @registry.register(GET_WIREGUARD_STATUS) def status(_request: Any, _body: Any) -> dict[str, Any]: """GET /wireguard/status — return current WireGuard status from cache.""" wg = _get_wg_state() if wg: - return wg.get("status", {"up": False, "interface": {}, "peers": []}) - return {"up": False, "interface": {}, "peers": []} + return wg.get( + "status", {"up": False, "interface": {}, "peers": [], "classes": {}} + ) + return {"up": False, "interface": {}, "peers": [], "classes": {}} @registry.register(POST_WIREGUARD_INITIALIZE) def initialize(_request: Any, _body: Any) -> dict[str, Any]: - """POST /wireguard/initialize — generate keypair and store in config (idempotent).""" - cfg = _get_config() + """POST /wireguard/initialize — generate keypair and store in config (idempotent). + + Also generates key pairs for each access class interface. + """ + cfg = _get_wireguard_config() if cfg["interface"].get("private_key"): + _ensure_access_classes(cfg) + + # Generate keys for classes that need them (idempotent, saves internally) + for class_key in cfg.get("access_classes", {}): + generate_class_keypair(class_key) return {"initialized": False, "reason": "already initialized"} - res = run_proc([WG_BIN, "genkey"], sudo=False) - private_key = res.stdout.strip() - res2 = run_proc([WG_BIN, "pubkey"], sudo=False, input=private_key) - public_key = res2.stdout.strip() - cfg["interface"]["private_key"] = private_key - cfg["interface"]["public_key"] = public_key - _save_config(cfg) - logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16]) + + # Fresh init + priv, pub = generate_keypair() + cfg["interface"]["private_key"] = priv + cfg["interface"]["public_key"] = pub + _ensure_access_classes(cfg) + + # Generate class keys + for class_key in cfg.get("access_classes", {}): + generate_class_keypair(class_key) + + _save_wireguard_config(cfg) + logger.info("WireGuard initialised (pubkey=%s...)", pub[:16]) sync_result = bus.emit( SyncEvent("wireguard", "config_saved", {"action": "initialized"}) ) refresh_state(["wireguard", *sync_result.affected_subsystems]) + safe = dict(cfg) safe["interface"] = dict(safe["interface"]) safe["interface"].pop("private_key", None) + for ck, cv in safe.get("access_classes", {}).items(): + if isinstance(cv, dict): + safe["access_classes"][ck] = dict(cv) + safe["access_classes"][ck].pop("private_key", None) return {"initialized": True, "config": safe} +@registry.register(POST_WIREGUARD_CLASS_INIT_KEYS) +def init_class_keys(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """POST /wireguard/classes/keys/ — generate keypair for a class. + + Raises: + ValueError: When body or key is missing. + NotFoundError: When class does not exist. + """ + if not body: + raise ValueError("Request body required") + class_key = body.get("class_key", "").strip() + if not class_key: + raise ValueError("'class_key' is required") + + cfg = _get_wireguard_config() + if class_key not in cfg.get("access_classes", {}): + raise NotFoundError(f"Access class '{class_key}' not found") + + class_cfg = cfg["access_classes"][class_key] + if class_cfg.get("private_key"): + return { + "generated": False, + "class_key": class_key, + "reason": "already has keys", + } + + _, pub = generate_class_keypair(class_key) + return {"generated": True, "class_key": class_key, "public_key": pub} + + @registry.register(POST_WIREGUARD_PEERS_ADD) def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: """POST /wireguard/peers/add — add new peer or update existing one. @@ -249,34 +433,39 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: name = body.get("name", "").strip() if not name: raise ValueError("'name' is required") - cfg = _get_config() + cfg = _get_wireguard_config() peers = cfg.setdefault("peers", {}) - allowed_ips = body.get("allowed_ips", []) if name in peers: peer = peers[name] - peer["endpoint"] = body.get("endpoint") - peer["allowed_ips"] = allowed_ips - peer["persistent_keepalive"] = body.get("persistent_keepalive") - if body.get("preshared_key") is not None: + if "endpoint" in body: + peer["endpoint"] = body["endpoint"] + if "allowed_ips" in body: + peer["allowed_ips"] = body["allowed_ips"] + if "persistent_keepalive" in body: + peer["persistent_keepalive"] = body["persistent_keepalive"] + if "preshared_key" in body: peer["preshared_key"] = body["preshared_key"] + if "description" in body: + peer["description"] = body["description"] + if "access_class" in body: + peer["access_class"] = body["access_class"] logger.info("WireGuard peer '%s' updated", name) _peer_action = "peer_updated" else: - res = run_proc([WG_BIN, "genkey"], sudo=True) - priv = res.stdout.strip() - res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=priv) - pub = res2.stdout.strip() + priv, pub = generate_keypair() peers[name] = { "public_key": pub, "private_key": priv, "endpoint": body.get("endpoint"), - "allowed_ips": allowed_ips, + "allowed_ips": body.get("allowed_ips", []), "persistent_keepalive": body.get("persistent_keepalive"), "preshared_key": body.get("preshared_key"), + "description": body.get("description", ""), + "access_class": body.get("access_class"), } logger.info("WireGuard peer '%s' added", name) _peer_action = "peer_added" - _save_config(cfg) + _save_wireguard_config(cfg) sync_result = bus.emit( SyncEvent( "wireguard", "config_saved", {"action": _peer_action, "peer_name": name} @@ -301,12 +490,12 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: name = body.get("name", "").strip() if not name: raise ValueError("'name' is required") - cfg = _get_config() + cfg = _get_wireguard_config() peers = cfg.setdefault("peers", {}) if name not in peers: raise NotFoundError(f"Peer '{name}' not found") del peers[name] - _save_config(cfg) + _save_wireguard_config(cfg) logger.info("WireGuard peer '%s' removed", name) sync_result = bus.emit( SyncEvent( @@ -323,14 +512,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]: wg = _get_wg_state() if wg: return wg.get("peers", []) - cfg = _get_config() - result: list[dict[str, Any]] = [] - for name, info in cfg.get("peers", {}).items(): - entry = dict(info) - entry["name"] = name - entry.pop("private_key", None) - result.append(entry) - return result + return _get_wireguard_peers() @registry.register(GET_WIREGUARD_PEER_STATUS) @@ -342,10 +524,32 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]: return [] +@registry.register(GET_WIREGUARD_CLASS_STATUS) +def get_class_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """GET /wireguard/classes//status — return status for a specific class interface. + + Raises: + NotFoundError: When class does not exist. + """ + wg = _get_wg_state() + if wg: + classes = wg.get("status", {}).get("classes", {}) + if body: + key = body.get("class_key", "") + if key and key not in classes: + raise NotFoundError(f"Access class '{key}' not found") + return classes.get(key, {"up": False, "interface": {}, "peers": []}) + return classes + return {"up": False, "interface": {}, "peers": []} + + @registry.register(POST_WIREGUARD_GENERATE_CLIENT) def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: """POST /wireguard/generate-client — render client-side WireGuard config for a peer. + Uses the peer's access class subnet and port to derive the correct + server address and endpoint port. + Raises: ValueError: When body, name, or server_endpoint is missing. NotFoundError: When peer does not exist or has no private key. @@ -358,30 +562,144 @@ def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str server_endpoint = body.get("server_endpoint", "") if not server_endpoint: raise ValueError("'server_endpoint' is required") - cfg = _get_config() + cfg = _get_wireguard_config() if name not in cfg.get("peers", {}): raise NotFoundError(f"Peer '{name}' not found") peer = cfg["peers"][name] - client_priv = peer.get("private_key", "") - if not client_priv: + if not peer.get("private_key"): raise NotFoundError(f"Peer '{name}' has no private key") - iface = cfg["interface"] - sorted_peers = sorted(cfg.get("peers", {}).keys()) - peer_index = sorted_peers.index(name) + 2 - srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24" - addr_part, prefix = srv_addr.rsplit("/", 1) - prefix_base = addr_part.rsplit(".", 1)[0] - client_addr = f"{prefix_base}.{peer_index}/{prefix}" - tmpl = ENV.get_template("wireguard-client.conf") - conf = tmpl.render( - timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + + conf = _gen_client_conf( peer_name=name, - client_priv=client_priv, - client_addr=client_addr, - server_pubkey=iface.get("public_key", ""), server_endpoint=server_endpoint, - allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]), - preshared_key=peer.get("preshared_key"), - persistent_keepalive=peer.get("persistent_keepalive"), + server_pubkey=cfg["interface"].get("public_key"), ) return {"config": conf} + + +# --------------------------------------------------------------------------- +# Access Classes + + +@registry.register(GET_WIREGUARD_CLASSES) +def list_classes(_request: Any, _body: Any) -> dict[str, Any]: + """GET /wireguard/classes — return access classes (with private keys stripped).""" + cfg = _get_wireguard_config() + classes = cfg.get("access_classes", {}) + safe: dict[str, Any] = {} + for k, v in classes.items(): + if isinstance(v, dict): + entry = dict(v) + entry.pop("private_key", None) + safe[k] = entry + else: + safe[k] = v + return safe + + +@registry.register(POST_WIREGUARD_CLASSES) +def create_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """POST /wireguard/classes — create a new access class with phase-2 fields. + + Raises: + ValueError: When body is missing or key conflicts. + """ + if not body: + raise ValueError("Request body required") + key = body.get("key", "").strip() + if not key: + raise ValueError("'key' is required") + if not key.isalnum() or not key.islower(): + raise ValueError("'key' must be lowercase alphanumeric") + name = body.get("name", key) + description = body.get("description", "") + cfg = _get_wireguard_config() + classes = cfg.setdefault("access_classes", {}) + if key in classes: + raise ConflictError(f"Access class '{key}' already exists") + + classes[key] = { + "name": name, + "description": description, + "subnet": body.get("subnet"), + "listen_port": body.get("listen_port"), + "lan_access": body.get("lan_access", False), + "private_key": "", + "public_key": "", + } + _save_wireguard_config(cfg) + sync_result = bus.emit( + SyncEvent("wireguard", "config_saved", {"action": "class_created"}) + ) + refresh_state(["wireguard", *sync_result.affected_subsystems]) + out = dict(classes[key]) + out.pop("private_key", None) + return out + + +@registry.register(PATCH_WIREGUARD_CLASSES) +def update_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: + """PATCH /wireguard/classes/ — 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/ — 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} diff --git a/daemon/iface.py b/daemon/iface.py index e5df741..0a52292 100644 --- a/daemon/iface.py +++ b/daemon/iface.py @@ -99,6 +99,20 @@ DELETE_WIREGUARD_PEERS_REMOVE: Endpoint = _ep("DELETE", "/wireguard/peers/remove GET_WIREGUARD_PEERS: Endpoint = _ep("GET", "/wireguard/peers") GET_WIREGUARD_PEER_STATUS: Endpoint = _ep("GET", "/wireguard/peer-status") 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/") +DELETE_WIREGUARD_CLASSES: Endpoint = _ep("DELETE", "/wireguard/classes/") +POST_WIREGUARD_CLASSES_UP: Endpoint = _ep("POST", "/wireguard/classes//up") +DELETE_WIREGUARD_CLASSES_DOWN: Endpoint = _ep( + "DELETE", "/wireguard/classes//down" +) +GET_WIREGUARD_CLASS_STATUS: Endpoint = _ep( + "GET", "/wireguard/classes//status" +) +POST_WIREGUARD_CLASS_INIT_KEYS: Endpoint = _ep( + "POST", "/wireguard/classes/keys/" +) # ---- ACME / Certs ---- GET_ACME_LIST: Endpoint = _ep("GET", "/acme/list") diff --git a/docs/api.md b/docs/api.md index 1297a1d..d8a88a4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. +### 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": "" }` + +Returns HTTP `404` if the class is not found. Returns HTTP `409` if peers reference the class. + --- ## Network API diff --git a/docs/config.md b/docs/config.md index 79ed30a..47a776a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -268,7 +268,7 @@ The application reads `.account.conf` to determine registration status. If the f **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 { @@ -278,9 +278,31 @@ This file defines the WireGuard server interface and all connected peers. The ap "private_key": "", "public_key": "", "addresses": ["10.137.0.1/24"], + "server_endpoint": "vpn.example.com:51820", + "description": "Main WireGuard server", "post_up": 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": "", + "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": { "alice": { "public_key": "", @@ -288,7 +310,9 @@ This file defines the WireGuard server interface and all connected peers. The ap "endpoint": "203.0.113.1:51820", "allowed_ips": ["0.0.0.0/0"], "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`. | | `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"]`. | +| `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_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-``), firewall zone (``vpn-``), 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//up`, `POST /api/wireguard/classes//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 ``.1/``. | +| `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/``. | +| `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-.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-`` 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 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. | | `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`. | +| `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 @@ -488,7 +544,7 @@ are updated automatically through the event bus. | 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. | -| 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-` zones are created with `wg-` 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. | | 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. | diff --git a/lib/state.py b/lib/state.py index 103d9c1..c47d94e 100644 --- a/lib/state.py +++ b/lib/state.py @@ -279,64 +279,72 @@ def _strip_volatile( for k in pop_keys: stripped.pop(k, None) for vpath in volatile: - # Determine if this path uses list-of-dicts pattern (e.g. "peers[].transfer"). - # The [] marker signals that the parent key holds a list of dicts, and we - # 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: - # Split into prefix (path before []), item keys (path after []). - # e.g. "status.peers[].transfer_received" → prefix=["status","peers"], - # item_keys=["transfer_received"] - prefix = vpath[:list_marker].split(".") - item_keys = ( - vpath[list_marker + 3 :].split(".") - if list_marker + 3 < len(vpath) - else [] - ) - # Navigate to the list container via the prefix path - parent = stripped - for seg in prefix: + _strip_volatile_path(stripped, vpath) + return stripped + + +def _strip_volatile_item(item: dict[str, Any], keys: list[str]) -> None: + """Recursively strip volatile keys from *item*, handling nested ``[]`` markers.""" + for i, k in enumerate(keys): + if "[]" in k: + base_key = k.replace("[]", "") + rest = keys[i + 1 :] + target = item.get(base_key, []) + if isinstance(target, list): + for t in target: + if isinstance(t, dict): + _strip_volatile_item(t, rest) + elif isinstance(target, dict): + 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: parent = parent[seg] else: - break - if isinstance(parent, list): - items = parent - elif isinstance(parent, dict): - logger.debug( - "_strip_volatile: %s resolved to dict, falling back to .values()", - vpath, - ) - items = parent.values() - else: - continue - - 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 + return + return + # Split into prefix and item keys. + prefix = vpath[:list_marker].split(".") + item_keys = ( + vpath[list_marker + 3 :].split(".") if list_marker + 3 < len(vpath) else [] + ) + parent = stripped + for seg in prefix: + if isinstance(parent, dict) and seg in parent: + parent = parent[seg] else: - # Scalar/dict path: navigate via segments and set final key to None - 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: - parent = parent[seg] - else: - break - return stripped + return + if isinstance(parent, list): + items = parent + elif isinstance(parent, dict): + items = list(parent.values()) + else: + return + for item in items: + if isinstance(item, dict) and item_keys: + _strip_volatile_item(item, item_keys) def _diff_layers( @@ -872,10 +880,11 @@ register_collector("acme", _collect_acme) def _collect_wireguard() -> dict[str, Any]: - """Collect WireGuard config, status, and peers. + """Collect WireGuard config, per-class status, and peers. 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" @@ -886,9 +895,12 @@ def _collect_wireguard() -> dict[str, Any]: "private_key": "", "public_key": "", "addresses": ["10.137.0.1/24"], + "server_endpoint": "", + "description": "", "post_up": None, "post_down": None, }, + "access_classes": {}, "peers": {}, } @@ -907,11 +919,18 @@ def _collect_wireguard() -> dict[str, Any]: 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} if "interface" in safe: safe["interface"] = dict(safe["interface"]) 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[dict[str, Any]] = [] @@ -921,35 +940,49 @@ def _collect_wireguard() -> dict[str, Any]: entry.pop("private_key", None) peers.append(entry) - # Runtime status - status: dict[str, Any] = {"up": False, "interface": {}, "peers": []} - name = cfg["interface"]["name"] - peer_name = name if isinstance(name, str) else "wg0" - try: - res = run_proc(["wg", "show", peer_name], sudo=True, check=False) - if res.returncode == 0: + # Runtime status — per-class interfaces + status: dict[str, Any] = { + "up": False, + "interface": {}, + "peers": [], + "classes": {}, + } + 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() 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(): line = line.strip() if not line: continue if line.startswith("interface:"): - status["up"] = True - status["interface"] = {} + cls_up = True + cls_iface = {} current_peer = None continue if line.startswith("public key:"): - status["interface"]["public_key"] = line.split(":", 1)[1].strip() + cls_iface["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("fwmark:"): - status["interface"]["fwmark"] = line.split(":", 1)[1].strip() + cls_iface["listen_port"] = int(line.split(":", 1)[1].strip()) continue if line.startswith("peer:"): cur_key = line.split(":", 1)[1].strip() @@ -962,7 +995,7 @@ def _collect_wireguard() -> dict[str, Any]: "transfer_sent": "0", "persistent_keepalive": None, } - status_peers.append(current_peer) + class_peers.append(current_peer) continue if current_peer is None: continue @@ -985,10 +1018,84 @@ def _collect_wireguard() -> dict[str, Any]: current_peer["persistent_keepalive"] = int( 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: pass + if any_up: + status["up"] = True + status["pending_changes"] = pending_changes return { "config": safe, @@ -1006,6 +1113,9 @@ register_volatile( "status.peers[].transfer_received", "status.peers[].transfer_sent", "status.peers[].latest_handshake", + "status.classes[].peers[].transfer_received", + "status.classes[].peers[].transfer_sent", + "status.classes[].peers[].latest_handshake", } ), ) diff --git a/lib/sync.py b/lib/sync.py index 93a9238..ee1b527 100644 --- a/lib/sync.py +++ b/lib/sync.py @@ -11,6 +11,7 @@ from dataclasses import dataclass, field from typing import Any from lib.common import get_interface_ip +from lib.state import state as _state_store logger = logging.getLogger(__name__) @@ -392,8 +393,6 @@ class DnsToFirewallSync: changes: list[str], ) -> None: """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", []) for r in ranges: iface = r.get("interface", "") @@ -410,7 +409,23 @@ class DnsToFirewallSync: 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-``). + 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 def _sync_allowed_ips( @@ -419,15 +434,9 @@ class WgToFirewallSync: zones: dict[str, Any], changes: list[str], ) -> None: - """Ensure inter-zone rich rules exist 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. - """ + """Add inter-zone rich rules for peer allowed_ips subnets.""" import re - # Collect all unique allowed_ips subnets across peers all_subnets: set[str] = set() for _name, peer_info in wg_cfg.get("peers", {}).items(): if not isinstance(peer_info, dict): @@ -436,7 +445,6 @@ class WgToFirewallSync: if isinstance(item, str) and 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", []) covered_subnets: set[str] = set() for rule_entry in existing_rules: @@ -445,19 +453,15 @@ class WgToFirewallSync: if isinstance(rule_entry, dict) else str(rule_entry) ) - match = re.search( - r'destination\s+address="([^"]+)"', - str(rule_str), - ) + match = re.search(r'destination\s+address="([^"]+)"', str(rule_str)) if match: covered_subnets.add(match.group(1)) - # Add rules for uncovered subnets for subnet in sorted(all_subnets): if subnet in covered_subnets: continue rule_entry = { - "rule": (f'rule family="ipv4" destination address="{subnet}" accept'), + "rule": f'rule family="ipv4" destination address="{subnet}" accept', "_source": "wg", } vpn_zone.setdefault("rich_rules", []).append(rule_entry) @@ -467,13 +471,17 @@ class WgToFirewallSync: @classmethod 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 - ensures the ``vpn`` zone exists with the WG interface assigned, - masquerade enabled, UDP 51820 accept rule, and inter-zone rich rules - for peer allowed_ips subnets. When WireGuard becomes inactive, - cleans up WireGuard-created entries from the vpn zone. + For each access class with peers, ensures a ``vpn-`` zone exists + with the WG interface assigned, masquerade enabled, and a UDP port + accept rule. Classes with ``lan_access=True`` also get inter-zone + accept rules for internal subnets. + + 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``. @@ -482,7 +490,7 @@ class WgToFirewallSync: Returns: 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": return None @@ -496,54 +504,148 @@ class WgToFirewallSync: fw_cfg = _get_fw_cfg() 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] = [] + 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", {}) if not isinstance(vpn_zone, dict): vpn_zone = {} zones["vpn"] = vpn_zone - # Ensure interface is assigned current_ifaces = list(vpn_zone.get("interfaces", [])) if wg_iface not in current_ifaces: current_ifaces.append(wg_iface) vpn_zone["interfaces"] = current_ifaces changes.append(f"Assigned interface '{wg_iface}' to zone 'vpn'") - # Ensure masquerade if not vpn_zone.get("masquerade"): vpn_zone["masquerade"] = True changes.append("Enabled masquerade on zone 'vpn'") - # Ensure UDP 51820 rich rule exists rich_rules = list(vpn_zone.get("rich_rules", [])) - expected_rule = { - "rule": 'rule family="ipv4" port protocol="udp" port="51820" accept', - "_source": "wg", - } + udp_rule_str = ( + 'rule family="ipv4" port protocol="udp" port="51820" accept' + ) + udp_rule = {"rule": udp_rule_str, "_source": "wg"} rule_strings = {r.get("rule") for r in rich_rules} - if expected_rule["rule"] not in rule_strings: - rich_rules.append(expected_rule) + if udp_rule_str not in rule_strings: + rich_rules.append(udp_rule) 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 cls._sync_allowed_ips(wg_cfg, vpn_zone, zones, changes) - zones["vpn"] = vpn_zone - else: - # Not active — selectively clean up WireGuard-created entries - # from the vpn zone without removing the zone itself. + # --- Cleanup: remove empty class zones --- + has_any_peers = bool(wg_cfg.get("peers")) + if has_any_peers: + 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") if not isinstance(vpn_zone, dict): pass else: - # Remove wg interface from vpn zone + wg_iface = wg_cfg.get("interface", {}).get("name", "") current_ifaces = list(vpn_zone.get("interfaces", [])) if wg_iface and wg_iface in current_ifaces: current_ifaces.remove(wg_iface) @@ -551,19 +653,15 @@ class WgToFirewallSync: changes.append( f"Removed interface '{wg_iface}' from zone 'vpn'" ) - # Also clean up any residual wg0 that was in the original vpn zone but - # is no longer the configured WireGuard interface - if "wg0" in current_ifaces and (wg_iface or "") != "wg0": + if "wg0" in current_ifaces and wg_iface != "wg0": current_ifaces.remove("wg0") vpn_zone["interfaces"] = current_ifaces changes.append("Removed interface 'wg0' from zone 'vpn'") - # Disable masquerade (only WireGuard relied on it) if vpn_zone.get("masquerade"): vpn_zone["masquerade"] = False 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", [])) wg_rule_ids: set[int] = set() for idx, r in enumerate(rich_rules): @@ -575,11 +673,12 @@ class WgToFirewallSync: ] vpn_zone["rich_rules"] = rich_rules 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: - fw_cfg["zones"] = zones _save_fw_cfg(fw_cfg) return SyncResult( affected_subsystems=["firewall"], @@ -590,6 +689,55 @@ class WgToFirewallSync: logger.exception("WgToFirewallSync failed") 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: """Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges. @@ -878,7 +1026,7 @@ _bus.subscribe( targets={"dnsmasq"}, ) _bus.subscribe( - "network", + "networkd", "config_saved", NetworkToAllSync.on_network_config_saved, targets={"firewall", "dnsmasq"}, diff --git a/lib/wireguard.py b/lib/wireguard.py index 416770e..2372628 100644 --- a/lib/wireguard.py +++ b/lib/wireguard.py @@ -1,7 +1,8 @@ """WireGuard Manager for Vacuum Wall SSL Proxy Firewall. 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 @@ -19,7 +20,6 @@ logger = logging.getLogger(__name__) PROJECT_DIR = Path(__file__).resolve().parent.parent CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json" -WG_CONF_PATH = "/etc/wireguard/wg0.conf" WG_QUICK_BIN = "wg-quick" WG_BIN = "wg" @@ -30,6 +30,25 @@ ENV = Environment( trim_blocks=True, ) + +def _wg_conf_path(ifname: str) -> str: + """Return the system WG conf path for an interface name.""" + return f"/etc/wireguard/{ifname}.conf" + + +def _default_class_fields() -> dict[str, Any]: + """Return the default set of fields for an access class entry.""" + return { + "name": "", + "description": "", + "subnet": None, + "listen_port": None, + "lan_access": False, + "private_key": "", + "public_key": "", + } + + DEFAULT_CONFIG: dict[str, Any] = { "interface": { "name": "wg0", @@ -37,9 +56,31 @@ DEFAULT_CONFIG: dict[str, Any] = { "private_key": "", "public_key": "", "addresses": ["10.137.0.1/24"], + "server_endpoint": "", + "description": "", "post_up": None, "post_down": None, }, + "access_classes": { + "full": { + "name": "Full LAN Access", + "description": "Peers get full access to internal networks", + "subnet": "10.137.0.0/24", + "listen_port": 51820, + "lan_access": True, + "private_key": "", + "public_key": "", + }, + "internet": { + "name": "Internet Only", + "description": "Peers can only reach the internet", + "subnet": "10.137.1.0/24", + "listen_port": 51821, + "lan_access": False, + "private_key": "", + "public_key": "", + }, + }, "peers": {}, } @@ -62,23 +103,114 @@ def save_config(cfg: dict[str, Any]) -> None: def generate_keypair() -> tuple[str, str]: """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() - 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() 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-.conf generation --- + + +def generate_class_conf(cfg: dict[str, Any], class_key: str) -> str | None: + """Render a wg-quick config file for a single access class. + + Returns ``None`` when the class has no peers assigned. + """ + classes = cfg.get("access_classes", {}) + class_cfg = classes.get(class_key) + if not class_cfg or not isinstance(class_cfg, dict): + return None + + peers = _class_peers(cfg, class_key) + if not peers: + return None + + ifname = _class_interface_name(class_key) + subnet = class_cfg.get("subnet") + if not subnet: + subnet = "10.137.0.0/24" + + _, prefix = subnet.rsplit("/", 1) + base = subnet.rsplit(".", 1)[0] + addr = f"{base}.1/{prefix}" + + listen_port = class_cfg.get("listen_port") + if not listen_port: + listen_port = 51820 + + private_key = class_cfg.get("private_key", "") + if not private_key: + raise ValueError( + f"Access class '{class_key}' has no private key — generate one first." + ) + + class_iface = { + "name": ifname, + "listen_port": listen_port, + "private_key": private_key, + "addresses": [addr], + "post_up": cfg.get("interface", {}).get("post_up"), + "post_down": cfg.get("interface", {}).get("post_down"), + } + + tmpl = ENV.get_template("wireguard.conf") + return tmpl.render( + timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + interface=class_iface, + peers=peers, + ) def generate_conf(cfg: dict[str, Any]) -> str: - """Render a valid wg-quick config file from *cfg* using Jinja2.""" + """Render a valid wg-quick config file from *cfg* using Jinja2. + + Legacy single-interface mode — uses the top-level ``interface`` block + and peers without an ``access_class`` assigned. + """ + fallback_peers = { + n: p + for n, p in cfg.get("peers", {}).items() + if isinstance(p, dict) and not p.get("access_class") + } tmpl = ENV.get_template("wireguard.conf") return tmpl.render( timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), interface=cfg["interface"], - peers=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: - """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() + classes = cfg.get("access_classes", {}) + applied = False + + for class_key in classes: + class_cfg = classes.get(class_key) + if not class_cfg or not isinstance(class_cfg, dict): + continue + if not _class_peers(cfg, class_key): + continue + + try: + conf_text = generate_class_conf(cfg, class_key) + if not conf_text: + continue + except ValueError: + continue + + ifname = _class_interface_name(class_key) + conf_path = _wg_conf_path(ifname) + local_dir = PROJECT_DIR / "data" / "wireguard" + local_dir.mkdir(parents=True, exist_ok=True) + local_tmp = local_dir / f"{ifname}.conf.tmp" + with open(local_tmp, "w") as f: + f.write(conf_text) + os.chmod(local_tmp, 0o600) + run(["cp", "--", str(local_tmp), conf_path], sudo=True) + run(["chown", "root:root", conf_path], sudo=True, check=False) + local_tmp.unlink(missing_ok=True) + run([WG_QUICK_BIN, "up", ifname], sudo=True) + logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key) + applied = True + + if applied: + save_config(cfg) + elif applied is False: + # Legacy single-interface fallback + legacy_apply(cfg) + + +def legacy_apply(cfg: dict[str, Any]) -> None: + """Legacy single-interface apply.""" conf_text = generate_conf(cfg) save_config(cfg) - + ifname = cfg["interface"]["name"] + conf_path = _wg_conf_path(ifname) local_dir = PROJECT_DIR / "data" / "wireguard" local_dir.mkdir(parents=True, exist_ok=True) - local_tmp = local_dir / "wg0.conf.tmp" + 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), WG_CONF_PATH], sudo=True) - run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False) + run(["cp", "--", str(local_tmp), conf_path], sudo=True) + run(["chown", "root:root", conf_path], sudo=True, check=False) local_tmp.unlink(missing_ok=True) + run([WG_QUICK_BIN, "up", ifname], sudo=True) + logger.info("WireGuard tunnel '%s' brought up", ifname) - 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: - """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() - name = cfg["interface"]["name"] - run([WG_QUICK_BIN, "down", name], sudo=True) - logger.info("WireGuard tunnel '%s' brought down", name) + classes = cfg.get("access_classes", {}) + for class_key in classes: + class_cfg = classes.get(class_key) + if not class_cfg or not isinstance(class_cfg, dict): + continue + if ifname := _class_interface_name(class_key): + try: + run([WG_QUICK_BIN, "down", ifname], sudo=True) + logger.info("WireGuard tunnel '%s' brought down", ifname) + except Exception: + pass + + # Also try legacy interface (skip if name matches any class interface) + ifname = cfg["interface"].get("name", "") + if ifname: + class_names = {_class_interface_name(k) for k in classes} + if ifname not in class_names: + try: + run([WG_QUICK_BIN, "down", ifname], sudo=True) + logger.info("WireGuard tunnel '%s' brought down", ifname) + except Exception: + pass + + +def down_class(class_key: str) -> None: + """Bring down a single access class interface.""" + ifname = _class_interface_name(class_key) + run([WG_QUICK_BIN, "down", ifname], sudo=True) + logger.info("WireGuard tunnel '%s' brought down", ifname) # --- Status --- def status() -> dict[str, Any]: - """Query the live tunnel state via ``wg show``.""" + """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() - 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] = { "up": False, "interface": {}, "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 peers: list[dict[str, Any]] = [] @@ -169,8 +433,8 @@ def status() -> dict[str, Any]: "endpoint": None, "allowed_ips": [], "latest_handshake": None, - "transfer_received": 0, - "transfer_sent": 0, + "transfer_received": "0", + "transfer_sent": "0", "persistent_keepalive": None, } peers.append(current_peer) @@ -214,36 +478,50 @@ def status() -> dict[str, Any]: # --- Peer management --- +_UNSET = object() + def add_peer( name: str, - endpoint: str | None = None, - allowed_ips: list[str] | None = None, - persistent_keepalive: int | None = None, + endpoint: str | None | object = _UNSET, + allowed_ips: list[str] | None | object = _UNSET, + persistent_keepalive: int | None | object = _UNSET, preshared_key: str | None = None, + description: str | None = None, + access_class: str | None = None, ) -> dict[str, Any]: """Add (or update) a peer in the configuration.""" cfg = get_config() peers = cfg.setdefault("peers", {}) - allowed_ips = allowed_ips or [] if name in peers: peer = peers[name] - peer["endpoint"] = endpoint - peer["allowed_ips"] = allowed_ips - peer["persistent_keepalive"] = persistent_keepalive + if endpoint is not _UNSET: + peer["endpoint"] = endpoint + if allowed_ips is not _UNSET: + peer["allowed_ips"] = allowed_ips if allowed_ips is not None else [] + if persistent_keepalive is not _UNSET: + peer["persistent_keepalive"] = persistent_keepalive if preshared_key is not None: peer["preshared_key"] = preshared_key + if description is not None: + peer["description"] = description + if access_class is not None: + peer["access_class"] = access_class logger.info("WireGuard peer '%s' updated", name) else: priv, pub = generate_keypair() peer = { "public_key": pub, "private_key": priv, - "endpoint": endpoint, - "allowed_ips": allowed_ips, - "persistent_keepalive": persistent_keepalive, + "endpoint": endpoint if endpoint is not _UNSET else None, + "allowed_ips": allowed_ips if allowed_ips is not _UNSET else [], + "persistent_keepalive": persistent_keepalive + if persistent_keepalive is not _UNSET + else None, "preshared_key": preshared_key, + "description": description, + "access_class": access_class, } peers[name] = peer logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16]) @@ -275,9 +553,17 @@ def get_peers() -> 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() - 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 --- @@ -286,9 +572,13 @@ def get_peer_status() -> list[dict[str, Any]]: def generate_client_conf( peer_name: str, server_endpoint: str, - server_pubkey: str, + server_pubkey: str | None = None, ) -> 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() iface = cfg["interface"] 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." ) + # Determine class info for address/port + access_class = peer.get("access_class") sorted_peers = sorted(cfg.get("peers", {}).keys()) peer_index = sorted_peers.index(peer_name) + 2 - 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}" + + if access_class and access_class in cfg.get("access_classes", {}): + class_cfg = cfg["access_classes"][access_class] + subnet = class_cfg.get("subnet", "10.137.0.0/24") + listen_port = class_cfg.get("listen_port", 51820) + else: + subnet = iface.get("addresses", ["10.137.0.1/24"])[0] + listen_port = iface.get("listen_port", 51820) + + if "/" not in subnet: + subnet = f"{subnet}/24" + addr = subnet.rsplit(".", 1)[0] + prefix = subnet.rsplit("/", 1)[1] + client_addr = f"{addr}.{peer_index}/{prefix}" + + sk = server_pubkey or iface.get("public_key", "") + ep = server_endpoint or iface.get("server_endpoint", "") + if ep and listen_port: + host = ep.split(":")[0] + ep = f"{host}:{listen_port}" tmpl = ENV.get_template("wireguard-client.conf") conf = tmpl.render( @@ -314,8 +622,8 @@ def generate_client_conf( peer_name=peer_name, client_priv=client_priv, client_addr=client_addr, - server_pubkey=server_pubkey, - server_endpoint=server_endpoint, + server_pubkey=sk, + server_endpoint=ep, allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]), preshared_key=peer.get("preshared_key"), persistent_keepalive=peer.get("persistent_keepalive"), @@ -351,66 +659,85 @@ def set_post_down(cmd: str | None) -> None: save_config(cfg) +# --- Class key generation --- + + +def generate_class_keypair(class_key: str) -> tuple[str, str]: + """Generate a key pair for an access class interface.""" + cfg = get_config() + classes = cfg.setdefault("access_classes", {}) + if class_key not in classes: + raise ValueError(f"Access class '{class_key}' not found") + class_cfg = classes[class_key] + if class_cfg.get("private_key"): + return class_cfg["private_key"], class_cfg["public_key"] + priv, pub = generate_keypair() + class_cfg["private_key"] = priv + class_cfg["public_key"] = pub + save_config(cfg) + logger.info("Key pair generated for class '%s'", class_key) + return priv, pub + + # --- Initialise --- +def _ensure_class_defaults(cfg: dict[str, Any]) -> None: + """Ensure access classes have required Phase-2 fields.""" + classes = cfg.setdefault("access_classes", {}) + for _key, c in classes.items(): + if not isinstance(c, dict): + continue + for field, default in _default_class_fields().items(): + if field not in c: + c[field] = default + + +def _ensure_access_classes(cfg: dict[str, Any]) -> None: + """Pre-seed default access classes if missing or empty (idempotent). + + Also upgrades existing classes with Phase-2 fields. + """ + classes = cfg.setdefault("access_classes", {}) + if not classes: + full_defaults = deepcopy(DEFAULT_CONFIG["access_classes"]) + classes.update(full_defaults) + return + _ensure_class_defaults(cfg) + + def initialize() -> dict[str, Any]: """Perform first-time WireGuard setup.""" cfg = get_config() if cfg["interface"].get("private_key"): + _ensure_access_classes(cfg) + save_config(cfg) return cfg priv, pub = generate_keypair() cfg["interface"]["private_key"] = priv cfg["interface"]["public_key"] = pub + _ensure_access_classes(cfg) save_config(cfg) logger.info("WireGuard initialised (pubkey=%s...)", pub[:16]) return cfg -# --- 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__ = [ "DEFAULT_CONFIG", "add_peer", "apply", + "apply_class", "down", + "down_class", + "generate_class_conf", + "generate_class_keypair", "generate_client_conf", "generate_conf", "generate_keypair", + "get_class_interface_name", + "get_class_zone_name", "get_config", "get_peer_status", "get_peers", diff --git a/tests/test_handler_network.py b/tests/test_handler_network.py index 07e9b11..7e151bb 100644 --- a/tests/test_handler_network.py +++ b/tests/test_handler_network.py @@ -160,10 +160,14 @@ class TestApplyAll: } ) + runtime_dir = tmp_network / "run" / "vacuum-wall" + runtime_dir.mkdir(parents=True) + with ( patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.run") as mock_run, patch("daemon.handlers.network.collect_upstream_dns", return_value=[]), + patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir), ): mock_gen.return_value = { "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 ( patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.run") as mock_run, patch("daemon.handlers.network.set_upstreams") as mock_set_upstreams, patch("daemon.handlers.network.collect_upstream_dns") as mock_collect, + patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir), ): mock_gen.return_value = { "generated": [_net.DATA_DIR / "99-eth0.network"], @@ -208,6 +216,9 @@ class TestApplyAll: def test_apply_all_handles_dns_sync_failure(self, tmp_network): _net.save_config({"interfaces": {"eth0": {"dns": ["8.8.8.8"]}}}) + runtime_dir = tmp_network / "run" / "vacuum-wall" + runtime_dir.mkdir(parents=True) + with ( patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.run") as mock_run, @@ -216,6 +227,7 @@ class TestApplyAll: side_effect=RuntimeError("fail"), ), patch("daemon.handlers.network.collect_upstream_dns") as mock_collect, + patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir), ): mock_gen.return_value = { "generated": [_net.DATA_DIR / "99-eth0.network"], @@ -233,10 +245,14 @@ class TestApplyAll: sys_dir.mkdir(parents=True) (sys_dir / "stale-file.network").write_text("[Match]\nName=old\n") + runtime_dir = tmp_network / "run" / "vacuum-wall" + runtime_dir.mkdir(parents=True) + with ( patch("daemon.handlers.network.generate_network_files") as mock_gen, patch("daemon.handlers.network.run") as mock_run, patch("daemon.handlers.network.collect_upstream_dns", return_value=[]), + patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir), ): mock_gen.return_value = { "generated": [_net.DATA_DIR / "99-eth0.network"], diff --git a/tests/test_system_import.py b/tests/test_system_import.py index 940248c..5be6e6f 100644 --- a/tests/test_system_import.py +++ b/tests/test_system_import.py @@ -580,9 +580,13 @@ class TestImportFirewall: class TestImportAll: - def test_all_missing(self, temp_project): - result = system_import.import_all() - assert result == [] + def test_all_missing(self, temp_project, tmp_path): + from unittest.mock import MagicMock, patch + + 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): # Create dnsmasq conf diff --git a/tests/test_wireguard.py b/tests/test_wireguard.py index b42d53b..834bde0 100644 --- a/tests/test_wireguard.py +++ b/tests/test_wireguard.py @@ -23,6 +23,13 @@ class TestDefaultConfig: assert cfg["interface"]["private_key"] == "" 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: 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" +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: def test_empty_peers(self, temp_config): peers = wireguard.get_peers() @@ -238,12 +331,190 @@ class TestStatus: class TestGenerateWgShowParser: def test_parses_peer_output(self): 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) - assert "PUBKEY1" in result - assert result["PUBKEY1"]["endpoint"] == "203.0.113.1:51820" + result = wireguard._parse_wg_show_output(output) + assert result["up"] is True + 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): - result = wireguard._parse_wg_show("") - assert result == {} + result = wireguard._parse_wg_show_output("") + 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) diff --git a/webui/api/dhcp.py b/webui/api/dhcp.py index f72f548..c72e026 100644 --- a/webui/api/dhcp.py +++ b/webui/api/dhcp.py @@ -19,6 +19,7 @@ from daemon.iface import ( POST_DNSMASQ_APPLY, POST_DNSMASQ_CONFIG, POST_DNSMASQ_DNS_RECORD_ADD, + POST_DNSMASQ_DOMAIN, POST_DNSMASQ_RANGES_ADD, POST_DNSMASQ_STATIC_LEASE_ADD, ) @@ -306,6 +307,36 @@ def add_dns_record_bp(): 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/", methods=["DELETE"]) def remove_dns_record_bp(name): """DELETE /api/dhcp/dns-record/ — Remove a DNS record by name. diff --git a/webui/api/wireguard.py b/webui/api/wireguard.py index c616190..abe0b10 100644 --- a/webui/api/wireguard.py +++ b/webui/api/wireguard.py @@ -7,15 +7,23 @@ import logging 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 ( + DELETE_WIREGUARD_CLASSES, + DELETE_WIREGUARD_CLASSES_DOWN, DELETE_WIREGUARD_PEERS_REMOVE, + GET_WIREGUARD_CLASS_STATUS, + GET_WIREGUARD_CLASSES, GET_WIREGUARD_CONFIG, GET_WIREGUARD_PEER_STATUS, GET_WIREGUARD_PEERS, GET_WIREGUARD_STATUS, + PATCH_WIREGUARD_CLASSES, PATCH_WIREGUARD_CONFIG, POST_WIREGUARD_APPLY, + POST_WIREGUARD_CLASS_INIT_KEYS, + POST_WIREGUARD_CLASSES, + POST_WIREGUARD_CLASSES_UP, POST_WIREGUARD_CONFIG, POST_WIREGUARD_DOWN, POST_WIREGUARD_GENERATE_CLIENT, @@ -229,6 +237,8 @@ def add_peer_bp(): "allowed_ips": body.get("allowed_ips", []), "persistent_keepalive": body.get("persistent_keepalive"), "preshared_key": body.get("preshared_key"), + "description": body.get("description"), + "access_class": body.get("access_class"), }, ) logger.info("WireGuard peer '%s' added via API", name) @@ -337,3 +347,156 @@ def generate_client_bp(): except RuntimeError as exc: logger.error("Failed to generate client config for '%s': %s", name, exc) 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//up", methods=["POST"]) +def class_up_bp(key): + """Bring up a single access class's WireGuard tunnel. + + Endpoint: POST /api/wireguard/classes//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//down", methods=["POST"]) +def class_down_bp(key): + """Bring down a single access class's WireGuard tunnel. + + Endpoint: POST /api/wireguard/classes//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//status", methods=["GET"]) +def class_status_bp(key): + """Get status for a single access class's tunnel. + + Endpoint: GET /api/wireguard/classes//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/", methods=["POST"]) +def class_init_keys_bp(key): + """Generate key pair for a single access class. + + Endpoint: POST /api/wireguard/classes/keys/ + """ + 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) diff --git a/webui/static/hoover/components/qr.js b/webui/static/hoover/components/qr.js new file mode 100644 index 0000000..6160cf6 --- /dev/null +++ b/webui/static/hoover/components/qr.js @@ -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); + }, + }), + ]); +} \ No newline at end of file diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js index 4c5a8ad..daf8dca 100644 --- a/webui/static/hoover/index.js +++ b/webui/static/hoover/index.js @@ -48,3 +48,6 @@ export { ApplyConfirm } from './components/applyconfirm.js?v=9'; /* ── UI Components: Toast ────────────────────────────────────── */ export { ToastContainer } from './components/toast.js?v=9'; + +/* ── UI Components: QR Code ──────────────────────────────────── */ +export { qrSVG, QRCodeVNode, LogoUpload } from './components/qr.js?v=9'; diff --git a/webui/static/pages/dhcp.js b/webui/static/pages/dhcp.js index 880eed6..abe9249 100644 --- a/webui/static/pages/dhcp.js +++ b/webui/static/pages/dhcp.js @@ -172,6 +172,30 @@ export default definePage({ `); + 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`
+ +

${currentDomain ? esc(currentDomain) : '(not set)'}

+
+ + + +
+
`; + const tabNames = ['ranges', 'leases', 'dns', 'active']; const actions = ActionGroup( html``, @@ -222,7 +246,10 @@ export default definePage({ state.activeTab === 'leases' ? Table({ columns: ['MAC', 'IP', 'Hostname', 'Action'], rows: leaseRows, emptyText: 'No static leases' }) : null, 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, ]; }, diff --git a/webui/static/pages/nat.js b/webui/static/pages/nat.js index c1ddc89..caf37ab 100644 --- a/webui/static/pages/nat.js +++ b/webui/static/pages/nat.js @@ -38,13 +38,16 @@ export default definePage({ const zoneData = cfg.zones || {}; 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) .filter(([, zcfg]) => !!zcfg.masquerade) .map(([z]) => z) ); - const wanIface = sIface.filter((i) => i.zone && masqZones.has(i.zone)); - const lanIface = 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 && lanZones.has(i.zone)); const ifaceRows = (ifaces) => ifaces.map((iface) => html` @@ -60,24 +63,40 @@ export default definePage({ <${Badge} text=${iface.zone || '—'} variant="secondary" /> `); + // 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) - .filter(([zone]) => zone !== "public") - .map(([zone, zcfg]) => { - const masq = !!zcfg.masquerade; - return html` - ${zone} - <${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /> - - <${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" /> - - `; - }); + .map(([zone, zcfg]) => { + const masq = !!zcfg.masquerade; + const isPublic = zone === "public"; + // Public zone masquerade is auto-propagated when any non-public zone + // has it enabled (nftables backend dispatches POSTROUTING to the + // output interface's zone chain). Show it read-only with a note. + if (isPublic) { + const effective = masq || anyNonPublicMasq; + return html` + ${zone} (auto) + <${Badge} text=${effective ? 'Enabled' : 'Disabled'} variant=${effective ? 'success' : 'info'} /> + ${anyNonPublicMasq ? 'Propagated from other zones' : 'Not needed'} + `; + } + return html` + ${zone} + <${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /> + + <${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" /> + + `; + }); const fwRows = []; Object.entries(zoneData).forEach(([zone, zcfg]) => { @@ -109,7 +128,7 @@ export default definePage({ title: 'WAN / External', columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'], rows: ifaceRows(wanIface), - emptyText: 'No WAN interfaces with masquerade enabled', + emptyText: 'No WAN interfaces', }), DataTableSection({ title: 'Internal / LAN', diff --git a/webui/static/pages/wireguard.js b/webui/static/pages/wireguard.js index d2e0867..636d6fc 100644 --- a/webui/static/pages/wireguard.js +++ b/webui/static/pages/wireguard.js @@ -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({ title: 'Add WireGuard Peer', fields: [ { label: 'Name', id: 'wg-name', placeholder: 'client-name' }, { 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' }, ], submit: { url: '/api/wireguard/peers', - body: () => ({ - name: ($val('wg-name') || '').trim(), - endpoint: ($val('wg-endpoint') || '').trim() || undefined, - allowed_ips: ($val('wg-allowed') || '').trim() ? [($val('wg-allowed') || '').trim()] : [], - persistent_keepalive: $val('wg-keepalive') ? parseInt($val('wg-keepalive')) : undefined, - }), - validate: (b) => !b.name ? 'Name is required' : null, + body: (data) => { + const preset = ($val('wg-allowed-preset') || 'all'); + let allowed_ips; + if (preset === 'lan') { + allowed_ips = getLanSubnets(); + } else if (preset === 'none') { + 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', }, 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]) => + `` + ).join(''); +} + +function updateLanOptions() { + const selectEl = document.getElementById('wg-allowed-preset'); + if (!selectEl) return; + const subnets = getLanSubnets(); + selectEl.innerHTML = + '' + + (subnets.length + ? `` + : '') + + '' + + ''; +} + +/* ── Download Config + QR Modal ──────────────────────────────── */ 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 = ` +

Peer Config: ${esc(peerName)}

+ +
${svgStr}
+ +
+ + + Upload a logo to overlay on the QR code. +
+ +
+ +
${esc(generatedConfig)}
+
+ +
+ + + +
+ `; + + 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) => { - formModal(inner, 'Download Config for ' + peerName, - [{ label: 'Server Endpoint', id: 'wg-srv-endpoint', placeholder: 'vpn.example.com:51820' }], + if (generatedConfig) { + 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: 'Generate', cls: 'btn-primary', action: 's', handler: formAction(async () => { - const endpoint = ($val('wg-srv-endpoint') || '').trim(); - if (!endpoint) throw 'Server endpoint is required'; + const ep = ($val('wg-srv-endpoint') || '').trim(); + if (!ep) throw 'Server endpoint is required'; const resp = await apiFetch('/api/wireguard/generate-client', { method: 'POST', - body: { name: peerName, server_endpoint: endpoint }, + body: { name: peerName, server_endpoint: ep }, }); if (!resp.ok) throw resp.error || 'Failed'; const configContent = resp.data?.config; if (!configContent) throw 'No config returned'; - downloadBlob(new Blob([configContent], { type: 'text/plain' }), peerName + '.conf'); - toast('Config downloaded', 'success'); - closeModal(idx); + generatedConfig = configContent; + + // 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 = ''; + } + }, +}); + +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` + ${esc(k)} + ${esc(v.name || k)} + ${esc(v.description || '-')} + ${esc(v.subnet || '-')} + ${v.listen_port || '-'} + ${v.lan_access ? 'Yes' : 'No'} + ${pCount} + + <${StatusDot} status=${isUp ? 'success' : 'danger'} /> + + + ${!hasKeys + ? html`` + : ''} + + + ${(pCount > 0) + ? html`` + : html``} + + `; + }); + + return html`
+
+

Access Classes

+ +
+ + + ${rows}
KeyNameDescriptionSubnetPortLANPeersStatusActions
+
`; +} + +/* ── Main Page ───────────────────────────────────────────────── */ export default definePage({ init() { return { wireguard: getModel('wireguard'), }; }, + subscribe(state) { + // Update add-peer modal options when state changes + if (state.wireguard?.data) { + updateAddPeerOptions(state.wireguard.data.config); + } + }, 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; - 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 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 => { - const hasHandshake = !!p.latest_handshake; - return html` + // Build merged peer rows: configured peers + live status + const configuredPeers = wgData?.peers || []; + 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` - <${StatusDot} status=${hasHandshake ? 'success' : 'danger'} /> + <${StatusDot} status=${isConnected ? 'success' : 'danger'} /> ${esc(p.name || 'unnamed')} + ${p.description ? html`
${esc(p.description)}` : ''} <${MonoText} text=${p.public_key || 'N/A'} maxLength=20 /> - ${esc(p.allowed_ips || '-')} + ${esc((p.allowed_ips || []).join(', ') || '-')} ${esc(p.endpoint || '-')} - ${esc(p.latest_handshake || 'Never')} - Recv: ${esc(p.transfer_recv || '0')}
- Sent: ${esc(p.transfer_sent || '0')} + ${classInfo + ? html`<${Badge} text=${esc(classInfo.name)} cls="bg-info text-white" />` + : html`<${Badge} text="Unassigned" cls="bg-secondary text-white" />`} + + ${esc(sp?.latest_handshake || 'Never')} + + Recv: ${esc(sp?.transfer_received || '0')}
+ Sent: ${esc(sp?.transfer_sent || '0')} <${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)} removeMessage=${'Remove peer ' + p.name + '?'} removeSuccess="Peer removed" @@ -89,35 +510,69 @@ export default definePage({ `; }); + // 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`
+
+ <${StatusDot} status=${isUp ? 'success' : 'danger'} /> ${esc(v.name || k)} (${esc(k)}) + ${pCount} peer(s), port ${v.listen_port || '-'} +
+
+
+ Subnet: ${esc(v.subnet || '-')} + LAN: ${v.lan_access ? 'Yes' : 'No'} + Keys: ${classHasKeys(v) ? 'Ready' : html``} +
+
+ +
+
+
`; + }); + classSummaryCards = html`
${cards}
`; + } + const actions = ActionGroup( - html``, + html``, + html``, ActionButton({ 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'), refresh: 'wireguard', }), - ActionButton({ - url: '/api/wireguard/apply', - successMsg: 'Config applied', - label: 'Apply', - refresh: 'wireguard', + ApplyConfirm({ + pending: st.pending_changes || false, + successMsg: 'WireGuard applied', + refresh: ['wireguard', 'firewall'], }), ); + const subtitleParts = ['Tunnel: ' + (isUp ? 'up' : 'down'), 'Listen: ' + listenPort]; + if (serverEndpoint) subtitleParts.push('Endpoint: ' + serverEndpoint); + return [ PageHeader({ title: 'WireGuard', - subtitle: 'Tunnel: ' + (st.up ? 'up' : 'down') + ', Listen: ' + listenPort, + subtitle: subtitleParts.join(' | '), actions, }), - ServiceStatus({ state: st.up ? 'up' : 'down' }), + ServiceStatus({ state: isUp ? 'up' : 'down' }), + classSummaryCards, peerRows.length ? Table({ - columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Handshake', 'Transfer', 'Actions'], + columns: ['Peer', 'Public Key', 'Allowed IPs', 'Endpoint', 'Class', 'Handshake', 'Transfer', 'Actions'], rows: peerRows, }) : Empty({ text: 'No peers configured. Add a peer above.' }), + renderAccessClasses(config, st), ]; }, });