furtka-gateway/control_plane/wgeasy.py

141 lines
5.9 KiB
Python
Raw Permalink Normal View History

"""Thin REST client for wg-easy's admin API.
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
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
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
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: ...
class WgEasyClient:
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
def __init__(self, base_url: str, admin_password: str, timeout: float = 10) -> None:
self._base_url = base_url.rstrip("/")
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
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.
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
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.
"""
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
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,
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
"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:
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
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
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
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:
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
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*(.+)$"),
}