furtka-gateway/tests/test_wgeasy_v15.py

192 lines
6.1 KiB
Python
Raw Normal View History

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
import base64
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from control_plane.wgeasy_v15 import WgEasyV15Client, WgEasyV15Error
USERNAME = "admin"
PASSWORD = "hunter2"
_AUTH_HEADER = "Basic " + base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode()
FAKE_CONF = """[Interface]
PrivateKey = client-private-key==
Address = 10.8.0.2/32
MTU = 1420
DNS = 1.1.1.1
[Peer]
PublicKey = server-public-key==
PresharedKey = preshared-key==
AllowedIPs = 10.8.0.0/24
PersistentKeepalive = 0
Endpoint = gateway.example.com:51820
"""
CLIENT_RECORD = {
"id": 1,
"name": "box-1",
"enabled": True,
"publicKey": "client-public-key==",
}
USERCONFIG = {
"id": "wg0",
"defaultMtu": 1420,
"defaultAllowedIps": ["0.0.0.0/0", "::/0"],
"host": "gateway.example.com",
"port": 51820,
"createdAt": "2026-01-01 00:00:00",
"updatedAt": "2026-01-01 00:00:00",
}
class FakeWgEasyV15Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass
def _json(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _check_auth(self) -> bool:
ok = self.headers.get("Authorization") == _AUTH_HEADER
if not ok:
self._json(401, {"error": True, "statusMessage": "Not authenticated"})
return ok
def _body(self) -> dict:
length = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(length)) if length else {}
def do_POST(self):
self.server.requests.append(("POST", self.path))
if not self._check_auth():
return
if self.path == "/api/client":
body = self._body()
assert body["expiresAt"] is None
self._json(200, {"success": True, "clientId": CLIENT_RECORD["id"]})
return
if self.path == "/api/admin/userconfig":
self.server.last_userconfig_post = self._body()
self._json(200, {"success": True})
return
self._json(404, {"error": True, "statusMessage": "not found"})
def do_GET(self):
self.server.requests.append(("GET", self.path))
if not self._check_auth():
return
if self.path == "/api/client":
self._json(200, [CLIENT_RECORD])
return
if self.path == f"/api/client/{CLIENT_RECORD['id']}/configuration":
body = FAKE_CONF.encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if self.path == "/api/admin/userconfig":
self._json(200, USERCONFIG)
return
self._json(404, {"error": True, "statusMessage": "not found"})
def do_DELETE(self):
self.server.requests.append(("DELETE", self.path))
if not self._check_auth():
return
if self.path == f"/api/client/{CLIENT_RECORD['id']}":
self._json(200, {"success": True})
return
self._json(404, {"error": True, "statusMessage": "not found"})
@pytest.fixture
def fake_wgeasy():
server = ThreadingHTTPServer(("127.0.0.1", 0), FakeWgEasyV15Handler)
server.requests = []
server.last_userconfig_post = None
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
thread.join()
def _client(server, username=USERNAME, password=PASSWORD):
port = server.server_address[1]
return WgEasyV15Client(f"http://127.0.0.1:{port}", username, password)
def test_create_client_returns_full_connection_material(fake_wgeasy):
client = _client(fake_wgeasy)
peer = client.create_client("box-1")
assert peer["id"] == CLIENT_RECORD["id"]
assert peer["public_key"] == "client-public-key=="
assert peer["private_key"] == "client-private-key=="
assert peer["address"] == "10.8.0.2/32"
assert peer["server_public_key"] == "server-public-key=="
assert peer["preshared_key"] == "preshared-key=="
assert peer["endpoint"] == "gateway.example.com:51820"
assert peer["allowed_ips"] == "10.8.0.0/24"
paths_hit = [(m, p) for m, p in fake_wgeasy.requests]
assert ("POST", "/api/client") in paths_hit
assert ("GET", "/api/client") in paths_hit
assert ("GET", f"/api/client/{CLIENT_RECORD['id']}/configuration") in paths_hit
def test_every_request_uses_basic_auth_and_wrong_password_fails(fake_wgeasy):
client = _client(fake_wgeasy, password="wrong-password")
with pytest.raises(WgEasyV15Error):
client.create_client("box-1")
def test_delete_client_calls_endpoint(fake_wgeasy):
client = _client(fake_wgeasy)
client.delete_client(str(CLIENT_RECORD["id"]))
assert ("DELETE", f"/api/client/{CLIENT_RECORD['id']}") in [
(m, p) for m, p in fake_wgeasy.requests
]
def test_delete_client_swallows_not_found(fake_wgeasy):
client = _client(fake_wgeasy)
client.delete_client("999") # 404 from fake server — must not raise
def test_restrict_default_allowed_ips_sends_full_config_back(fake_wgeasy):
client = _client(fake_wgeasy)
client.restrict_default_allowed_ips(["10.8.0.0/24"])
sent = fake_wgeasy.last_userconfig_post
assert sent["defaultAllowedIps"] == ["10.8.0.0/24"]
# The full current config must be echoed back, not a partial patch —
# confirmed live against v15 that a partial body 400s.
assert sent["defaultMtu"] == USERCONFIG["defaultMtu"]
assert sent["host"] == USERCONFIG["host"]
# Server-managed fields must not be sent back.
assert "id" not in sent
assert "createdAt" not in sent
assert "updatedAt" not in sent
def test_bad_endpoint_raises_wgeasy_v15_error(fake_wgeasy):
client = _client(fake_wgeasy)
with pytest.raises(WgEasyV15Error):
client._request("GET", "/api/does-not-exist")