wg-easy + Traefik docker-compose stack (Phase 1) plus a stdlib-only control-plane API for box registration, WireGuard peer provisioning via wg-easy, and per-box route publish/unpublish backed by Traefik's file provider (Phase 2, single-tenant mode). SQLite holds accounts/boxes/routes so a later multi-tenant shared instance is the same schema with more rows, not a reshape. wg-easy's actual REST API was verified against its source rather than assumed: it has no bearer-token auth (session-cookie login via POST /api/auth/password) and no way to accept an externally-generated public key (it always mints the keypair itself, private key included) — both corrected from the original plan during implementation.
121 lines
3.9 KiB
Python
121 lines
3.9 KiB
Python
import json
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
import pytest
|
|
|
|
from control_plane.wgeasy import WgEasyClient, WgEasyError
|
|
|
|
FAKE_CONF = """[Interface]
|
|
PrivateKey = client-private-key==
|
|
Address = 10.8.0.5/32
|
|
DNS = 1.1.1.1
|
|
|
|
[Peer]
|
|
PublicKey = server-public-key==
|
|
Endpoint = gateway.example.com:51820
|
|
AllowedIPs = 10.8.0.0/24
|
|
PersistentKeepalive = 25
|
|
"""
|
|
|
|
|
|
class FakeWgEasyHandler(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 do_POST(self):
|
|
self.server.requests.append(("POST", self.path))
|
|
if self.path == "/api/auth/password":
|
|
self.send_response(200)
|
|
self.send_header("Set-Cookie", "wg-easy-session=abc123; Path=/; HttpOnly")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
return
|
|
if self.path == "/api/client":
|
|
self._json(200, {"success": True, "clientId": "client-1"})
|
|
return
|
|
self._json(404, {"error": "not found"})
|
|
|
|
def do_GET(self):
|
|
self.server.requests.append(("GET", self.path))
|
|
if self.path == "/api/client/client-1":
|
|
self._json(200, {"id": "client-1", "publicKey": "client-public-key=="})
|
|
return
|
|
if self.path == "/api/client/client-1/configuration":
|
|
body = FAKE_CONF.encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/octet-stream")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
return
|
|
self._json(404, {"error": "not found"})
|
|
|
|
def do_DELETE(self):
|
|
self.server.requests.append(("DELETE", self.path))
|
|
if self.path == "/api/client/client-1":
|
|
self._json(200, {"success": True})
|
|
return
|
|
self._json(404, {"error": "not found"})
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_wgeasy():
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), FakeWgEasyHandler)
|
|
server.requests = []
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield server
|
|
finally:
|
|
server.shutdown()
|
|
thread.join()
|
|
|
|
|
|
def _client(server):
|
|
port = server.server_address[1]
|
|
return WgEasyClient(f"http://127.0.0.1:{port}", "admin", "hunter2")
|
|
|
|
|
|
def test_create_client_logs_in_and_parses_configuration(fake_wgeasy):
|
|
client = _client(fake_wgeasy)
|
|
|
|
peer = client.create_client("box-1")
|
|
|
|
assert peer["id"] == "client-1"
|
|
assert peer["public_key"] == "client-public-key=="
|
|
assert peer["private_key"] == "client-private-key=="
|
|
assert peer["address"] == "10.8.0.5/32"
|
|
assert peer["server_public_key"] == "server-public-key=="
|
|
assert peer["endpoint"] == "gateway.example.com:51820"
|
|
assert peer["allowed_ips"] == "10.8.0.0/24"
|
|
|
|
methods_and_paths = [(m, p) for m, p in fake_wgeasy.requests]
|
|
assert ("POST", "/api/auth/password") in methods_and_paths
|
|
assert ("POST", "/api/client") in methods_and_paths
|
|
|
|
|
|
def test_delete_client_swallows_not_found(fake_wgeasy):
|
|
client = _client(fake_wgeasy)
|
|
client.delete_client("does-not-exist") # 404 from fake server — must not raise
|
|
|
|
|
|
def test_delete_client_calls_endpoint(fake_wgeasy):
|
|
client = _client(fake_wgeasy)
|
|
client.delete_client("client-1")
|
|
assert ("DELETE", "/api/client/client-1") in [(m, p) for m, p in fake_wgeasy.requests]
|
|
|
|
|
|
def test_bad_endpoint_raises_wgeasy_error(fake_wgeasy):
|
|
port = fake_wgeasy.server_address[1]
|
|
client = WgEasyClient(f"http://127.0.0.1:{port}", "admin", "hunter2")
|
|
with pytest.raises(WgEasyError):
|
|
client._authed_request("GET", "/api/does-not-exist")
|