furtka-gateway/control_plane/wgeasy.py
Robert Syrnicki 73c96cab56 Fix WireGuard peer routing and control-plane reachability
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.
2026-08-24 12:51:22 +02:00

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