furtka-gateway/control_plane/wgeasy.py

137 lines
5.4 KiB
Python
Raw Normal View History

"""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*(.+)$"),
}