feat: add selectable allocation-based privacy relay

This commit is contained in:
Alexander Sellite 2026-09-01 10:21:34 -04:00
parent e979da9e76
commit 46de16db00
14 changed files with 1067 additions and 43 deletions

View file

@ -6,6 +6,7 @@ import json
import socketserver
import threading
import unittest
from unittest.mock import patch
from straywild_discovery.registry import RoomRegistry
from straywild_discovery.server import (
@ -20,7 +21,7 @@ VALID_ROOM = {
"port": 7777,
"current_players": 1,
"max_players": 8,
"game_version": "0.20.2-alpha",
"game_version": "0.20.3-alpha",
"protocol_version": 12,
}
@ -91,6 +92,12 @@ class DiscoveryHTTPTests(unittest.TestCase):
assert listing is not None
listed_rooms = listing["rooms"]
self.assertTrue(any(candidate["room_id"] == room_id for candidate in listed_rooms))
listed_room = next(
candidate for candidate in listed_rooms if candidate["room_id"] == room_id
)
self.assertNotIn("address", listed_room)
self.assertNotIn("port", listed_room)
self.assertEqual(listed_room["connection_mode"], "direct")
status, updated = self.request(
"PUT",
@ -102,7 +109,7 @@ class DiscoveryHTTPTests(unittest.TestCase):
assert updated is not None
self.assertEqual(updated["error"]["code"], "game_version_mismatch")
self.assertEqual(
updated["error"]["required_game_version"], "0.20.2-alpha"
updated["error"]["required_game_version"], "0.20.3-alpha"
)
status, updated = self.request(
@ -135,7 +142,7 @@ class DiscoveryHTTPTests(unittest.TestCase):
error = body["error"]
assert isinstance(error, dict)
self.assertEqual(error["code"], "game_version_mismatch")
self.assertEqual(error["required_game_version"], "0.20.2-alpha")
self.assertEqual(error["required_game_version"], "0.20.3-alpha")
self.assertIn("will not be listed", str(error["message"]))
def test_empty_dedicated_room_can_be_listed(self) -> None:
@ -181,6 +188,15 @@ class DiscoveryHTTPTests(unittest.TestCase):
)
self.assertEqual(status, 201)
assert join is not None
self.assertEqual(
join["route"],
{
"transport": "direct",
"address": "127.0.0.1",
"port": 7777,
"ip_privacy": "peer_visible",
},
)
self.server.registry.register_join_endpoint(
str(join["join_token"]), "198.51.100.7", 49152
)
@ -201,7 +217,7 @@ class DiscoveryHTTPTests(unittest.TestCase):
self.assertEqual(status, 200)
assert body is not None
self.assertEqual(body["status"], "ok")
self.assertEqual(body["version"], "0.20.2-alpha")
self.assertEqual(body["version"], "0.20.3-alpha")
self.assertEqual(body["build_revision"], "test-build")
def test_live_friend_presence_and_invitation_endpoints(self) -> None:
@ -296,6 +312,178 @@ class DiscoveryHTTPTests(unittest.TestCase):
def test_udp_rendezvous_does_not_spawn_per_packet_threads(self) -> None:
self.assertFalse(issubclass(TraversalUDPServer, socketserver.ThreadingMixIn))
def test_http_access_logging_is_completely_disabled(self) -> None:
with patch("straywild_discovery.server.LOGGER.info") as log_info:
status, _health = self.request("GET", "/health")
self.assertEqual(status, 200)
log_info.assert_not_called()
def test_unexpected_http_error_log_contains_no_private_metadata(self) -> None:
private_address = "198.51.100.77"
with patch("straywild_discovery.server.LOGGER.error") as log_error:
self.server.handle_error(object(), (private_address, 49152))
log_error.assert_called_once_with(
"discovery HTTP request failed without logging request metadata"
)
self.assertNotIn(private_address, str(log_error.call_args))
def test_unexpected_udp_error_log_contains_no_private_metadata(self) -> None:
private_address = "198.51.100.78"
traversal = TraversalUDPServer(
ServerConfig(
traversal_bind_host="127.0.0.1",
traversal_port=0,
),
self.server.registry,
)
try:
with patch("straywild_discovery.server.LOGGER.error") as log_error:
traversal.handle_error(object(), (private_address, 49153))
log_error.assert_called_once_with(
"discovery traversal request failed without logging metadata"
)
self.assertNotIn(private_address, str(log_error.call_args))
finally:
traversal.server_close()
class RelayDiscoveryHTTPTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
config = ServerConfig(
bind_host="127.0.0.1",
bind_port=0,
room_ttl_seconds=30.0,
relay_enabled=True,
relay_bind_host="127.0.0.1",
relay_public_host="127.0.0.1",
relay_port_start=0,
relay_port_end=0,
relay_max_allocations=32,
build_revision="relay-test-build",
)
cls.server = DiscoveryHTTPServer(config)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.port = cls.server.server_address[1]
@classmethod
def tearDownClass(cls) -> None:
cls.server.shutdown()
cls.server.server_close()
cls.thread.join(timeout=2.0)
def request(
self,
method: str,
path: str,
payload: dict[str, object] | None = None,
) -> tuple[int, dict[str, object] | None]:
connection = HTTPConnection("127.0.0.1", self.port, timeout=2.0)
headers: dict[str, str] = {}
body = None
if payload is not None:
body = json.dumps(payload)
headers["Content-Type"] = "application/json"
connection.request(method, path, body=body, headers=headers)
response = connection.getresponse()
response_body = response.read()
connection.close()
decoded = json.loads(response_body) if response_body else None
return response.status, decoded
def test_relay_join_never_returns_host_endpoint(self) -> None:
status, created = self.request(
"POST",
"/v1/rooms",
dict(VALID_ROOM, connection_mode="relay"),
)
self.assertEqual(status, 201)
assert created is not None
room = created["room"]
traversal = created["traversal"]
assert isinstance(room, dict)
assert isinstance(traversal, dict)
room_id = str(room["room_id"])
self.server.registry.verify_endpoint(
room_id,
str(traversal["verification_token"]),
"198.51.100.42",
45678,
)
status, listing = self.request("GET", "/v1/rooms?protocol_version=12")
self.assertEqual(status, 200)
assert listing is not None
listed_room = next(
candidate
for candidate in listing["rooms"]
if candidate["room_id"] == room_id
)
self.assertEqual(listed_room["connection_mode"], "relay")
self.assertEqual(listed_room["ip_privacy"], "relayed")
self.assertNotIn("address", listed_room)
self.assertNotIn("port", listed_room)
status, join = self.request(
"POST", f"/v1/rooms/{room_id}/join-attempts"
)
self.assertEqual(status, 201)
assert join is not None
route = join["route"]
assert isinstance(route, dict)
self.assertEqual(route["transport"], "relay")
self.assertEqual(route["ip_privacy"], "relayed")
self.assertEqual(route["address"], "127.0.0.1")
self.assertNotEqual(route["address"], "198.51.100.42")
self.assertNotEqual(route["port"], 45678)
def test_direct_room_does_not_consume_relay_allocation(self) -> None:
status, created = self.request(
"POST",
"/v1/rooms",
dict(VALID_ROOM, room_name="Direct Pond", connection_mode="direct"),
)
self.assertEqual(status, 201)
assert created is not None
room = created["room"]
traversal = created["traversal"]
assert isinstance(room, dict)
assert isinstance(traversal, dict)
room_id = str(room["room_id"])
self.server.registry.verify_endpoint(
room_id,
str(traversal["verification_token"]),
"198.51.100.43",
45679,
)
allocations_before = self.server.relay_service.allocation_count()
status, join = self.request(
"POST", f"/v1/rooms/{room_id}/join-attempts"
)
self.assertEqual(status, 201)
assert join is not None
self.assertEqual(join["route"]["transport"], "direct")
self.assertEqual(join["route"]["address"], "198.51.100.43")
self.assertEqual(
self.server.relay_service.allocation_count(),
allocations_before,
)
def test_dedicated_relay_advertisement_is_rejected(self) -> None:
status, response = self.request(
"POST",
"/v1/rooms",
dict(
VALID_ROOM,
host_kind="dedicated",
connection_mode="relay",
),
)
self.assertEqual(status, 400)
assert response is not None
self.assertEqual(response["error"]["code"], "invalid_room")
if __name__ == "__main__":
unittest.main()