"""WireGuard daemon handler.""" 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_PEERS_REMOVE, GET_WIREGUARD_CONFIG, GET_WIREGUARD_PEER_STATUS, GET_WIREGUARD_PEERS, GET_WIREGUARD_STATUS, PATCH_WIREGUARD_CONFIG, POST_WIREGUARD_APPLY, 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 lib.common import ( _APPLY_HASH_KEY, config_hash, deep_merge, load_json, run, run_proc, save_json, ) from lib.sync import SyncEvent, bus 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 # --------------------------------------------------------------------------- # Routes @registry.register(GET_WIREGUARD_CONFIG) def get_config(_request: Any, _body: Any) -> dict[str, Any]: """GET /wireguard/config — return WireGuard config with private key stripped.""" wg = _get_wg_state() if wg: return wg.get("config", {}) cfg = _get_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) 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. Raises: ValueError: When request body is missing. """ if not body: raise ValueError("Request body required") current = _get_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) sync_result = bus.emit( SyncEvent("wireguard", "config_saved", {"action": "config_saved"}) ) refresh_state(["wireguard", *sync_result.affected_subsystems]) return {"config_saved": True} @registry.register(PATCH_WIREGUARD_CONFIG) def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: """PATCH /wireguard/config — deep-merge patch into existing config. Raises: ValueError: When request body is missing. """ 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() merged = deep_merge(current, body) _save_config(merged) sync_result = bus.emit( SyncEvent("wireguard", "config_saved", {"action": "config_patched"}) ) refresh_state(["wireguard", *sync_result.affected_subsystems]) return {"config_saved": True} @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() cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after) _save_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} @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) sync_result = bus.emit( SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"}) ) refresh_state(["wireguard", *sync_result.affected_subsystems]) return {"down": True} @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": []} @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() if cfg["interface"].get("private_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]) 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) return {"initialized": True, "config": safe} @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. Raises: ValueError: When body is missing or name is empty. """ if not body: raise ValueError("Request body required") name = body.get("name", "").strip() if not name: raise ValueError("'name' is required") cfg = _get_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: peer["preshared_key"] = body["preshared_key"] 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() peers[name] = { "public_key": pub, "private_key": priv, "endpoint": body.get("endpoint"), "allowed_ips": allowed_ips, "persistent_keepalive": body.get("persistent_keepalive"), "preshared_key": body.get("preshared_key"), } logger.info("WireGuard peer '%s' added", name) _peer_action = "peer_added" _save_config(cfg) sync_result = bus.emit( SyncEvent( "wireguard", "config_saved", {"action": _peer_action, "peer_name": name} ) ) refresh_state(["wireguard", *sync_result.affected_subsystems]) peer_out = dict(peers[name]) peer_out.pop("private_key", None) return peer_out @registry.register(DELETE_WIREGUARD_PEERS_REMOVE) def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: """DELETE /wireguard/peers/remove — remove a peer by name. Raises: ValueError: When body is missing or name is empty. NotFoundError: When peer does not exist. """ if not body: raise ValueError("Request body required") name = body.get("name", "").strip() if not name: raise ValueError("'name' is required") cfg = _get_config() peers = cfg.setdefault("peers", {}) if name not in peers: raise NotFoundError(f"Peer '{name}' not found") del peers[name] _save_config(cfg) logger.info("WireGuard peer '%s' removed", name) sync_result = bus.emit( SyncEvent( "wireguard", "config_saved", {"action": "peer_removed", "peer_name": name} ) ) refresh_state(["wireguard", *sync_result.affected_subsystems]) return {"name": name} @registry.register(GET_WIREGUARD_PEERS) def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]: """GET /wireguard/peers — return configured peers with private keys stripped.""" 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 @registry.register(GET_WIREGUARD_PEER_STATUS) def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]: """GET /wireguard/peer-status — return runtime peer status from cache.""" wg = _get_wg_state() if wg: return wg.get("status", {}).get("peers", []) return [] @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. Raises: ValueError: When body, name, or server_endpoint is missing. NotFoundError: When peer does not exist or has no private key. """ if not body: raise ValueError("Request body required") name = body.get("name", "").strip() if not name: raise ValueError("'name' is required") server_endpoint = body.get("server_endpoint", "") if not server_endpoint: raise ValueError("'server_endpoint' is required") cfg = _get_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: 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"), 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"), ) return {"config": conf}