"""Thin REST client for wg-easy's admin API. Verified against wg-easy's actual source (github.com/wg-easy/wg-easy, src/server/api/{auth,client}/*) rather than assumed — an earlier draft of this plan guessed at a base path of /api/wireguard/client and assumed a bearer-token auth scheme with bring-your-own-public-key support. Neither is true. The real shape: POST /api/auth/password {username, password, remember} -> sets a session cookie (h3's own session mechanism; there is no separate bearer-token auth) POST /api/client {name} -> {success, clientId} GET /api/client/{id} -> client record, includes .publicKey GET /api/client/{id}/configuration -> raw wg-quick .conf text (Content-Type: application/octet-stream) DELETE /api/client/{id} -> remove the peer wg-easy always generates the WireGuard keypair itself; the private key is only ever obtainable via the .conf download, never returned by the create call. There is no supported way to hand wg-easy an externally-generated public key. See the plan's "Correction found during implementation" note for the resulting threat-model consequence (the gateway sees every box's private key at registration time). """ from __future__ import annotations import json import re import urllib.error import urllib.request from http.cookiejar import CookieJar class WgEasyError(Exception): pass class WgEasyClient: def __init__(self, base_url: str, username: str, password: str, timeout: float = 10) -> None: self._base_url = base_url.rstrip("/") self._username = username self._password = password self._timeout = timeout self._opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(CookieJar()) ) self._logged_in = False def create_client(self, name: str) -> dict: """Create a new WireGuard peer and return its full connection material. wg-easy generates the keypair; we fetch the private key immediately via the /configuration endpoint since create_client's own response never includes it. """ created = json.loads(self._authed_request("POST", "/api/client", {"name": name})) client_id = created["clientId"] info = json.loads(self._authed_request("GET", f"/api/client/{client_id}")) conf_text = self._authed_request( "GET", f"/api/client/{client_id}/configuration" ).decode() conf = _parse_wg_conf(conf_text) return { "id": client_id, "public_key": info["publicKey"], "private_key": conf["private_key"], "address": conf["address"], "server_public_key": conf["peer_public_key"], "endpoint": conf["endpoint"], "allowed_ips": conf["allowed_ips"], } def delete_client(self, client_id: str) -> None: try: self._authed_request("DELETE", f"/api/client/{client_id}") except WgEasyError: # Already gone on wg-easy's side shouldn't block us cleaning up # our own box/route rows — deregistration must still succeed. pass # -- transport ------------------------------------------------------ def _login(self) -> None: self._request( "POST", "/api/auth/password", {"username": self._username, "password": self._password, "remember": True}, ) self._logged_in = True def _request(self, method: str, path: str, body: dict | None = None) -> bytes: data = json.dumps(body).encode() if body is not None else None headers = {"Content-Type": "application/json"} if data is not None else {} req = urllib.request.Request( f"{self._base_url}{path}", data=data, method=method, headers=headers ) try: with self._opener.open(req, timeout=self._timeout) as resp: return resp.read() except urllib.error.HTTPError as e: detail = e.read().decode(errors="replace") raise WgEasyError(f"{method} {path} -> HTTP {e.code}: {detail}") from e except urllib.error.URLError as e: raise WgEasyError(f"{method} {path} -> {e}") from e def _authed_request(self, method: str, path: str, body: dict | None = None) -> bytes: if not self._logged_in: self._login() try: return self._request(method, path, body) except WgEasyError: # Session cookie may have expired between calls — retry once # after a fresh login before giving up. self._login() return self._request(method, path, body) def _parse_wg_conf(text: str) -> dict: def find(pattern: str) -> str: m = re.search(pattern, text, re.MULTILINE) if not m: raise WgEasyError(f"could not find {pattern!r} in wg-easy client configuration") return m.group(1).strip() return { "private_key": find(r"^PrivateKey\s*=\s*(.+)$"), "address": find(r"^Address\s*=\s*(.+)$"), "peer_public_key": find(r"^PublicKey\s*=\s*(.+)$"), "endpoint": find(r"^Endpoint\s*=\s*(.+)$"), "allowed_ips": find(r"^AllowedIPs\s*=\s*(.+)$"), }