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.
219 lines
6.4 KiB
Python
219 lines
6.4 KiB
Python
import json
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
from http.server import ThreadingHTTPServer
|
|
|
|
import pytest
|
|
|
|
from control_plane.api import Context, Handler
|
|
from control_plane.db import Database
|
|
|
|
|
|
class FakeWgEasy:
|
|
def __init__(self):
|
|
self._counter = 0
|
|
self.deleted = []
|
|
|
|
def create_client(self, name):
|
|
self._counter += 1
|
|
return {
|
|
"id": f"peer-{self._counter}",
|
|
"public_key": f"pubkey-{self._counter}==",
|
|
"private_key": f"privkey-{self._counter}==",
|
|
"address": f"10.8.0.{self._counter}/32",
|
|
"server_public_key": "server-pubkey==",
|
|
"endpoint": "gateway.example.com:51820",
|
|
"allowed_ips": "10.8.0.0/24",
|
|
}
|
|
|
|
def delete_client(self, client_id):
|
|
self.deleted.append(client_id)
|
|
|
|
|
|
@pytest.fixture
|
|
def server(tmp_path, gateway_paths, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_BOX_TOKEN", "test-box-token")
|
|
db = Database(db_path=tmp_path / "gateway.db")
|
|
ctx = Context(
|
|
db=db,
|
|
wgeasy=FakeWgEasy(),
|
|
mode="single",
|
|
cert_resolver="le",
|
|
single_account_id="default",
|
|
box_limit=5,
|
|
route_limit_per_box=5,
|
|
)
|
|
db.execute(
|
|
"INSERT INTO accounts (id, email, created_at, registration_token_hash, "
|
|
"account_token_hash, box_limit, route_limit_per_box) "
|
|
"VALUES ('default', NULL, '2026-01-01T00:00:00', '', '', 5, 5)"
|
|
)
|
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
httpd.ctx = ctx
|
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield httpd
|
|
finally:
|
|
httpd.shutdown()
|
|
thread.join()
|
|
|
|
|
|
def _url(server, path):
|
|
port = server.server_address[1]
|
|
return f"http://127.0.0.1:{port}{path}"
|
|
|
|
|
|
def _request(server, method, path, body=None, token=None):
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
headers = {"Content-Type": "application/json"} if data else {}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
req = urllib.request.Request(_url(server, path), data=data, method=method, headers=headers)
|
|
try:
|
|
with urllib.request.urlopen(req) as resp:
|
|
return resp.status, json.loads(resp.read())
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, json.loads(e.read())
|
|
|
|
|
|
def test_healthz(server):
|
|
status, body = _request(server, "GET", "/healthz")
|
|
assert status == 200
|
|
assert body == {"status": "ok"}
|
|
|
|
|
|
def _register(server, box_name="my-box"):
|
|
return _request(
|
|
server,
|
|
"POST",
|
|
"/v1/boxes/register",
|
|
{"registration_token": "test-box-token", "box_name": box_name},
|
|
)
|
|
|
|
|
|
def test_register_rejects_wrong_token(server):
|
|
status, _ = _request(
|
|
server,
|
|
"POST",
|
|
"/v1/boxes/register",
|
|
{"registration_token": "wrong", "box_name": "my-box"},
|
|
)
|
|
assert status == 401
|
|
|
|
|
|
def test_register_rejects_missing_fields(server):
|
|
status, _ = _request(server, "POST", "/v1/boxes/register", {"box_name": "my-box"})
|
|
assert status == 400
|
|
|
|
|
|
def test_full_box_and_route_lifecycle(server):
|
|
status, body = _register(server)
|
|
assert status == 201
|
|
box_token = body["box_token"]
|
|
assert body["wg"]["private_key"] == "privkey-1=="
|
|
|
|
# Unauthenticated calls are rejected.
|
|
status, _ = _request(server, "GET", "/v1/routes")
|
|
assert status == 401
|
|
|
|
status, _ = _request(server, "GET", "/v1/routes", token=box_token)
|
|
assert status == 200
|
|
|
|
status, body = _request(
|
|
server,
|
|
"POST",
|
|
"/v1/routes",
|
|
{"app_name": "vaultwarden", "subdomain": "vault.example.com", "port": 8081},
|
|
token=box_token,
|
|
)
|
|
assert status == 201
|
|
route_id = body["route_id"]
|
|
assert body["public_url"] == "https://vault.example.com/"
|
|
|
|
status, body = _request(server, "GET", "/v1/routes", token=box_token)
|
|
assert status == 200
|
|
assert len(body["routes"]) == 1
|
|
assert body["routes"][0]["subdomain"] == "vault.example.com"
|
|
|
|
status, _ = _request(server, "POST", f"/v1/boxes/{_box_id(box_token)}/heartbeat", token=box_token)
|
|
assert status == 200
|
|
|
|
status, _ = _request(server, "DELETE", f"/v1/routes/{route_id}", token=box_token)
|
|
assert status == 200
|
|
|
|
status, body = _request(server, "GET", "/v1/routes", token=box_token)
|
|
assert body["routes"] == []
|
|
|
|
|
|
def test_route_ownership_is_enforced_across_boxes(server):
|
|
_, box_a = _register(server, "box-a")
|
|
_, box_b = _register(server, "box-b")
|
|
|
|
_, route = _request(
|
|
server,
|
|
"POST",
|
|
"/v1/routes",
|
|
{"app_name": "vaultwarden", "subdomain": "vault.example.com", "port": 8081},
|
|
token=box_a["box_token"],
|
|
)
|
|
|
|
# box-b's token must not be able to delete box-a's route.
|
|
status, _ = _request(
|
|
server, "DELETE", f"/v1/routes/{route['route_id']}", token=box_b["box_token"]
|
|
)
|
|
assert status == 404
|
|
|
|
|
|
def test_box_action_rejects_mismatched_box_id(server):
|
|
_, box_a = _register(server, "box-a")
|
|
_, box_b = _register(server, "box-b")
|
|
|
|
status, _ = _request(
|
|
server,
|
|
"POST",
|
|
f"/v1/boxes/{_box_id(box_b['box_token'])}/heartbeat",
|
|
token=box_a["box_token"],
|
|
)
|
|
assert status == 403
|
|
|
|
|
|
def test_rotate_token_invalidates_old_token(server):
|
|
_, box = _register(server)
|
|
old_token = box["box_token"]
|
|
box_id = _box_id(old_token)
|
|
|
|
status, body = _request(server, "POST", f"/v1/boxes/{box_id}/rotate-token", token=old_token)
|
|
assert status == 200
|
|
new_token = body["box_token"]
|
|
|
|
status, _ = _request(server, "GET", "/v1/routes", token=old_token)
|
|
assert status == 401
|
|
status, _ = _request(server, "GET", "/v1/routes", token=new_token)
|
|
assert status == 200
|
|
|
|
|
|
def test_deregister_removes_routes_and_wgeasy_peer(server):
|
|
_, box = _register(server)
|
|
box_token = box["box_token"]
|
|
box_id = _box_id(box_token)
|
|
_request(
|
|
server,
|
|
"POST",
|
|
"/v1/routes",
|
|
{"app_name": "vaultwarden", "subdomain": "vault.example.com", "port": 8081},
|
|
token=box_token,
|
|
)
|
|
|
|
status, _ = _request(server, "POST", f"/v1/boxes/{box_id}/deregister", token=box_token)
|
|
assert status == 200
|
|
|
|
# The box token no longer authenticates anything after deregistration.
|
|
status, _ = _request(server, "GET", "/v1/routes", token=box_token)
|
|
assert status == 401
|
|
assert server.ctx.wgeasy.deleted == ["peer-1"]
|
|
|
|
|
|
def _box_id(box_token: str) -> str:
|
|
return box_token.split(".", 1)[0]
|