wg-easy + Traefik docker-compose stack (Phase 1) plus a stdlib-only control-plane API for box registration, WireGuard peer provisioning via wg-easy, and per-box route publish/unpublish backed by Traefik's file provider (Phase 2, single-tenant mode). SQLite holds accounts/boxes/routes so a later multi-tenant shared instance is the same schema with more rows, not a reshape. wg-easy's actual REST API was verified against its source rather than assumed: it has no bearer-token auth (session-cookie login via POST /api/auth/password) and no way to accept an externally-generated public key (it always mints the keypair itself, private key included) — both corrected from the original plan during implementation.
136 lines
5.4 KiB
Python
136 lines
5.4 KiB
Python
"""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*(.+)$"),
|
|
}
|