Two gaps found while walking through a real single-tenant deployment scenario, both fixed and verified against the actual running stack (not just unit tests): - wg-easy moves to network_mode: host so Traefik can actually route to WireGuard peer addresses (wg-quick's route only existed inside wg-easy's own isolated network namespace before). This forced host-level sysctls (ops/host-sysctls.sh — Docker/runc rejects namespaced sysctls under host networking) and a host firewall rule (ops/firewall.sh) to keep wg-easy's now-unisolated admin API off the public interface. - control-plane gets a real Traefik route (traefik/dynamic/control-plane.yml.example) instead of being reachable only at 127.0.0.1:8090 — box registration carries a WireGuard private key and needs TLS, not bare loopback HTTP. Along the way, running the actual pinned wg-easy image (ghcr.io/wg-easy/wg-easy:14) surfaced that it's a different, older, Express-based codebase than what wg-easy's current docs/master branch describe: auth is a plain Authorization header per request (no session cookie), routes live under /api/wireguard/client, and its default WG_ALLOWED_IPS is full-tunnel (0.0.0.0/0) rather than the split-tunnel this design assumed. wgeasy.py and docker-compose.yaml are corrected accordingly, verified end-to-end against the real container. Also fixed: Docker Compose silently truncates a bcrypt hash's `$` characters when read from .env — documented the required $$ escaping.
128 lines
5.4 KiB
Python
128 lines
5.4 KiB
Python
"""Thin REST client for wg-easy's admin API.
|
|
|
|
Verified directly against the actual running container for the pinned
|
|
image (`ghcr.io/wg-easy/wg-easy:14`) — `docker exec furtka-gateway-wg-easy
|
|
cat /app/lib/Server.js` — rather than trusted from wg-easy's GitHub
|
|
`master` branch, which turned out to describe a different, newer,
|
|
Nitro/h3-based rewrite this tag does not actually run. Two things that
|
|
draft got wrong, corrected here: this build's auth is a single shared
|
|
password sent as a plain `Authorization` header on every request (no
|
|
login call, no session cookie, no username), and its routes live under
|
|
`/api/wireguard/client`, not `/api/client`. Re-verify against the
|
|
container directly (not against docs) if this image tag is ever bumped.
|
|
|
|
Real routes (confirmed by reading the container's own source):
|
|
|
|
GET /api/wireguard/client -> [{id, name, enabled,
|
|
address, publicKey,
|
|
createdAt, ...}]
|
|
POST /api/wireguard/client {name} -> {success: true}
|
|
(no id in the
|
|
response — list and
|
|
match by name)
|
|
GET /api/wireguard/client/{id}/configuration -> plain-text wg-quick
|
|
.conf (the only place
|
|
the private key ever
|
|
appears)
|
|
DELETE /api/wireguard/client/{id} -> {success: true}
|
|
|
|
wg-easy always generates the keypair itself; there is no supported way to
|
|
hand it 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
|
|
|
|
|
|
class WgEasyError(Exception):
|
|
pass
|
|
|
|
|
|
class WgEasyClient:
|
|
def __init__(self, base_url: str, admin_password: str, timeout: float = 10) -> None:
|
|
self._base_url = base_url.rstrip("/")
|
|
self._admin_password = admin_password
|
|
self._timeout = timeout
|
|
|
|
def create_client(self, name: str) -> dict:
|
|
"""Create a new WireGuard peer and return its full connection material.
|
|
|
|
The create call itself never returns the new client's id, so we
|
|
immediately list clients and match by name (most recently created,
|
|
in case an old client happens to share the name) to find it, then
|
|
fetch its private key via /configuration — the only endpoint that
|
|
ever exposes it.
|
|
"""
|
|
self._request("POST", "/api/wireguard/client", {"name": name})
|
|
|
|
clients = json.loads(self._request("GET", "/api/wireguard/client"))
|
|
matches = [c for c in clients if c["name"] == name]
|
|
if not matches:
|
|
raise WgEasyError(
|
|
f"created client {name!r} but it did not appear in the client list"
|
|
)
|
|
client = max(matches, key=lambda c: c["createdAt"])
|
|
client_id = client["id"]
|
|
|
|
conf_text = self._request(
|
|
"GET", f"/api/wireguard/client/{client_id}/configuration"
|
|
).decode()
|
|
conf = _parse_wg_conf(conf_text)
|
|
|
|
return {
|
|
"id": client_id,
|
|
"public_key": client["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._request("DELETE", f"/api/wireguard/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
|
|
|
|
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 = {"Authorization": self._admin_password}
|
|
if data is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
req = urllib.request.Request(
|
|
f"{self._base_url}{path}", data=data, method=method, headers=headers
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(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 _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*(.+)$"),
|
|
}
|