furtka-gateway/control_plane/wgeasy.py
Robert Syrnicki e240f071d5
Some checks failed
Build control-plane image / build-image (push) Has been cancelled
feat: ship control-plane standalone, bring your own Traefik/wg-easy
Restructure the deployment model: instead of bundling and owning its own
Traefik + wg-easy, the control-plane now plugs into a Traefik and wg-easy
the operator already runs (as sibling containers, or otherwise reachable).
This avoids duplicating infra self-hosters commonly already have, and
sidesteps having to run a second ACME/DNS setup entirely — the operator's
own Traefik already has a working cert resolver.

- New control_plane/wgeasy_v15.py: a wg-easy v15 REST client, verified live
  against a real running container (auth model, route shapes, and the new
  per-client PresharedKey all differ materially from v14's API). Threaded
  the preshared key through to the box side (furtka/wireguard.py in the
  furtka repo) so v15's handshake actually completes.
- GATEWAY_CERT_RESOLVER is now a real env var instead of a GATEWAY_MODE-
  derived hardcoded "le", so it can mirror whatever resolver name (if any)
  the operator's own Traefik already has configured.
- Root docker-compose.yaml/ops/deploy.sh: control-plane only now. deploy.sh
  asks how the operator's Traefik reaches it (same docker network, joined
  via `docker network connect`, or a separate host) instead of prompting
  for DNS-01 providers and port remaps single-parallel mode needed.
- single-parallel mode is retired outright — nothing needs it once
  furtka-gateway doesn't own a second Traefik/wg-easy to remap ports for.
- The old bundled stack (own Traefik+wg-easy, HTTP-01) moves to
  examples/starter-stack/ as the documented zero-infra on-ramp.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R3jVwAqESRjPdW9P9nvfem
2026-09-19 13:34:29 +02:00

140 lines
5.9 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
from typing import Protocol
class WgEasyError(Exception):
pass
class WgEasyProtocol(Protocol):
"""Shape both WgEasyClient (v14) and WgEasyV15Client (v15, see
wgeasy_v15.py) satisfy structurally — the two images' actual REST APIs
are different enough (auth model, route shapes) to need separate
clients, but callers (boxes.py, api.py's Context) only ever need these
two operations."""
def create_client(self, name: str) -> dict: ...
def delete_client(self, client_id: str) -> None: ...
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*(.+)$"),
}