2026-08-24 11:50:25 +02:00
|
|
|
"""Thin REST client for wg-easy's admin API.
|
|
|
|
|
|
2026-08-24 12:51:22 +02:00
|
|
|
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).
|
2026-08-24 11:50:25 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import re
|
|
|
|
|
import urllib.error
|
|
|
|
|
import urllib.request
|
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
|
|
|
from typing import Protocol
|
2026-08-24 11:50:25 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class WgEasyError(Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
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: ...
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 11:50:25 +02:00
|
|
|
class WgEasyClient:
|
2026-08-24 12:51:22 +02:00
|
|
|
def __init__(self, base_url: str, admin_password: str, timeout: float = 10) -> None:
|
2026-08-24 11:50:25 +02:00
|
|
|
self._base_url = base_url.rstrip("/")
|
2026-08-24 12:51:22 +02:00
|
|
|
self._admin_password = admin_password
|
2026-08-24 11:50:25 +02:00
|
|
|
self._timeout = timeout
|
|
|
|
|
|
|
|
|
|
def create_client(self, name: str) -> dict:
|
|
|
|
|
"""Create a new WireGuard peer and return its full connection material.
|
|
|
|
|
|
2026-08-24 12:51:22 +02:00
|
|
|
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.
|
2026-08-24 11:50:25 +02:00
|
|
|
"""
|
2026-08-24 12:51:22 +02:00
|
|
|
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"
|
2026-08-24 11:50:25 +02:00
|
|
|
).decode()
|
|
|
|
|
conf = _parse_wg_conf(conf_text)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"id": client_id,
|
2026-08-24 12:51:22 +02:00
|
|
|
"public_key": client["publicKey"],
|
2026-08-24 11:50:25 +02:00
|
|
|
"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:
|
2026-08-24 12:51:22 +02:00
|
|
|
self._request("DELETE", f"/api/wireguard/client/{client_id}")
|
2026-08-24 11:50:25 +02:00
|
|
|
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
|
2026-08-24 12:51:22 +02:00
|
|
|
headers = {"Authorization": self._admin_password}
|
|
|
|
|
if data is not None:
|
|
|
|
|
headers["Content-Type"] = "application/json"
|
2026-08-24 11:50:25 +02:00
|
|
|
req = urllib.request.Request(
|
|
|
|
|
f"{self._base_url}{path}", data=data, method=method, headers=headers
|
|
|
|
|
)
|
|
|
|
|
try:
|
2026-08-24 12:51:22 +02:00
|
|
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
2026-08-24 11:50:25 +02:00
|
|
|
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*(.+)$"),
|
|
|
|
|
}
|