feat: add selectable allocation-based privacy relay
This commit is contained in:
parent
e979da9e76
commit
46de16db00
14 changed files with 1067 additions and 43 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue