furtka/tests/test_gateway.py
Robert Syrnicki 557d410eca Add gateway registration client and WireGuard tunnel management
furtka.gateway: register/rotate-token/deregister against a furtka-gateway's
/v1/boxes/* API (stdlib urllib.request, same convention as
_release_common.py/updater.py). No local keypair generation — the
gateway's registration response carries the full WireGuard interface
config including the private key, since stock wg-easy always generates
the keypair itself (see furtka-gateway's wgeasy.py). Config persists to
paths.gateway_config_file() (0600, atomic write, same pattern as
auth.save_users).

furtka.wireguard: direct structural mirror of https.py's
snippet-write-plus-reload pattern for the wg-quick@furtka-gw systemd unit
— write/remove the .conf, flip the unit, revert the conf on failure so a
bad config can't leave the tunnel wedged.

paths.py gains gateway_config_file() and internet_routes_file()
(FURTKA_GATEWAY_CONFIG / FURTKA_INTERNET_ROUTES_FILE), following the
existing env-var-override convention.
2026-08-24 13:13:25 +02:00

168 lines
5 KiB
Python

"""Tests for furtka.gateway — the box-side register/rotate/deregister client.
Same shape as test_updater.py's network tests: monkeypatch
urllib.request.urlopen directly rather than standing up a real server.
"""
from __future__ import annotations
import json
import urllib.error
import pytest
from furtka import gateway
class _FakeResp:
def __init__(self, body: dict):
self._body = json.dumps(body).encode()
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._body
WG = {
"private_key": "cHJpdmF0ZS1rZXk=",
"address": "10.8.0.2/24",
"server_public_key": "c2VydmVyLXB1YmxpYy1rZXk=",
"endpoint": "gateway.example.com:51820",
"allowed_ips": "10.8.0.0/24",
}
@pytest.fixture
def gw(tmp_path, monkeypatch):
monkeypatch.setenv("FURTKA_GATEWAY_CONFIG", str(tmp_path / "gateway.json"))
import importlib
from furtka import paths as p
importlib.reload(p)
importlib.reload(gateway)
return gateway
def test_load_config_returns_none_when_never_registered(gw):
assert gw.load_config() is None
def test_register_saves_config(gw, monkeypatch):
calls = []
def fake_urlopen(req, timeout):
calls.append((req.full_url, json.loads(req.data)))
return _FakeResp({"box_id": "abc123", "box_token": "abc123.secret", "wg": WG})
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
config = gw.register("https://gw.example.com/", "reg-token", "my-box")
assert config["gateway_url"] == "https://gw.example.com" # trailing slash stripped
assert config["box_id"] == "abc123"
assert config["box_token"] == "abc123.secret"
assert config["wg"] == WG
assert gw.load_config() == config
url, body = calls[0]
assert url == "https://gw.example.com/v1/boxes/register"
assert body == {"registration_token": "reg-token", "box_name": "my-box"}
def test_register_config_file_is_0600(gw, monkeypatch, tmp_path):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
from furtka.paths import gateway_config_file
assert oct(gateway_config_file().stat().st_mode)[-3:] == "600"
def test_register_raises_gateway_error_on_http_error(gw, monkeypatch):
def raising(req, timeout):
raise urllib.error.HTTPError(req.full_url, 401, "invalid registration token", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising)
with pytest.raises(gateway.GatewayError, match="HTTP 401"):
gw.register("https://gw.example.com", "bad-token", "my-box")
assert gw.load_config() is None
def test_rotate_token_updates_stored_config(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.old", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def fake_rotate(req, timeout):
assert req.headers.get("Authorization") == "Bearer abc.old"
assert req.full_url == "https://gw.example.com/v1/boxes/abc/rotate-token"
return _FakeResp({"box_token": "abc.new"})
monkeypatch.setattr("urllib.request.urlopen", fake_rotate)
new_token = gw.rotate_token()
assert new_token == "abc.new"
assert gw.load_config()["box_token"] == "abc.new"
def test_rotate_token_without_prior_registration_raises(gw):
with pytest.raises(gateway.GatewayError, match="not connected"):
gw.rotate_token()
def test_deregister_calls_gateway_and_clears_config(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
calls = []
def fake_deregister(req, timeout):
calls.append(req.full_url)
return _FakeResp({"status": "ok"})
monkeypatch.setattr("urllib.request.urlopen", fake_deregister)
gw.deregister()
assert calls == ["https://gw.example.com/v1/boxes/abc/deregister"]
assert gw.load_config() is None
def test_deregister_when_never_registered_is_a_noop(gw, monkeypatch):
calls = []
monkeypatch.setattr("urllib.request.urlopen", lambda req, timeout: calls.append(1))
gw.deregister()
assert calls == []
def test_deregister_failure_keeps_local_config_for_retry(gw, monkeypatch):
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout: _FakeResp({"box_id": "abc", "box_token": "abc.x", "wg": WG}),
)
gw.register("https://gw.example.com", "tok", "box")
def raising(req, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr("urllib.request.urlopen", raising)
with pytest.raises(gateway.GatewayError):
gw.deregister()
# Local config must survive so the caller can retry later.
assert gw.load_config() is not None