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()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,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,
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +54,103 @@ class RoomRegistryTests(unittest.TestCase):
|
|||
self.registry.delete(room.room_id, token)
|
||||
self.assertEqual(self.registry.list_rooms(), [])
|
||||
|
||||
def test_public_room_shape_never_discloses_endpoint(self) -> None:
|
||||
room, _token, verification_token = self.registry.create(
|
||||
"203.0.113.10", VALID_ROOM
|
||||
)
|
||||
room = self.registry.verify_endpoint(
|
||||
room.room_id, verification_token, "203.0.113.10", 49152
|
||||
)
|
||||
public = room.public_dict()
|
||||
self.assertNotIn("address", public)
|
||||
self.assertNotIn("port", public)
|
||||
self.assertEqual(public["connection_mode"], "direct")
|
||||
self.assertEqual(public["ip_privacy"], "peer_visible")
|
||||
self.assertEqual(
|
||||
room.join_route_dict(),
|
||||
{
|
||||
"transport": "direct",
|
||||
"address": "203.0.113.10",
|
||||
"port": 49152,
|
||||
"ip_privacy": "peer_visible",
|
||||
},
|
||||
)
|
||||
|
||||
def test_relay_room_shape_advertises_privacy_without_endpoint(self) -> None:
|
||||
registry = RoomRegistry(
|
||||
ttl_seconds=30.0,
|
||||
relay_available=True,
|
||||
clock=self.clock,
|
||||
)
|
||||
room, _token, verification_token = registry.create(
|
||||
"203.0.113.10", dict(VALID_ROOM, connection_mode="relay")
|
||||
)
|
||||
room = registry.verify_endpoint(
|
||||
room.room_id, verification_token, "203.0.113.10", 49152
|
||||
)
|
||||
public = room.public_dict()
|
||||
self.assertEqual(public["connection_mode"], "relay")
|
||||
self.assertEqual(public["ip_privacy"], "relayed")
|
||||
self.assertNotIn("address", public)
|
||||
self.assertNotIn("port", public)
|
||||
|
||||
def test_player_can_choose_direct_when_relay_is_available(self) -> None:
|
||||
registry = RoomRegistry(
|
||||
ttl_seconds=30.0,
|
||||
relay_available=True,
|
||||
clock=self.clock,
|
||||
)
|
||||
room, _token, _verification_token = registry.create(
|
||||
"203.0.113.10", dict(VALID_ROOM, connection_mode="direct")
|
||||
)
|
||||
self.assertEqual(room.connection_mode, "direct")
|
||||
|
||||
def test_dedicated_server_cannot_request_relay(self) -> None:
|
||||
registry = RoomRegistry(
|
||||
ttl_seconds=30.0,
|
||||
relay_available=True,
|
||||
clock=self.clock,
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
ValidationError,
|
||||
"dedicated servers cannot use the privacy relay",
|
||||
):
|
||||
registry.create(
|
||||
"203.0.113.10",
|
||||
dict(
|
||||
VALID_ROOM,
|
||||
host_kind="dedicated",
|
||||
connection_mode="relay",
|
||||
),
|
||||
)
|
||||
|
||||
def test_relay_request_fails_when_service_has_no_relay(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ValidationError,
|
||||
"privacy relay is not available",
|
||||
):
|
||||
self.registry.create(
|
||||
"203.0.113.10",
|
||||
dict(VALID_ROOM, connection_mode="relay"),
|
||||
)
|
||||
|
||||
def test_join_attempts_are_rate_limited_per_requester(self) -> None:
|
||||
registry = RoomRegistry(ttl_seconds=300.0, clock=self.clock)
|
||||
room, _token, verification_token = registry.create(
|
||||
"203.0.113.10", VALID_ROOM
|
||||
)
|
||||
room = registry.verify_endpoint(
|
||||
room.room_id, verification_token, "203.0.113.10", 7777
|
||||
)
|
||||
for _attempt in range(10):
|
||||
token, routed_room = registry.create_join_attempt(
|
||||
room.room_id, "198.51.100.20"
|
||||
)
|
||||
self.assertTrue(token)
|
||||
self.assertEqual(routed_room, room)
|
||||
with self.assertRaises(RoomLimitError):
|
||||
registry.create_join_attempt(room.room_id, "198.51.100.20")
|
||||
|
||||
def test_empty_dedicated_room_is_valid(self) -> None:
|
||||
room, _token, verification_token = self.registry.create(
|
||||
"203.0.113.10", dict(VALID_ROOM, current_players=0)
|
||||
|
|
|
|||
79
tests/test_relay.py
Normal file
79
tests/test_relay.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import unittest
|
||||
|
||||
from straywild_discovery.relay import (
|
||||
RELAY_AUTH_PREFIX,
|
||||
RelayConfig,
|
||||
RelayService,
|
||||
)
|
||||
|
||||
|
||||
class RelayServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.host = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.host.bind(("127.0.0.1", 0))
|
||||
self.host.settimeout(0.35)
|
||||
self.client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.client.bind(("127.0.0.1", 0))
|
||||
self.client.settimeout(1.0)
|
||||
self.relay = RelayService(RelayConfig(
|
||||
bind_host="127.0.0.1",
|
||||
public_host="127.0.0.1",
|
||||
port_start=0,
|
||||
port_end=0,
|
||||
max_allocations=4,
|
||||
))
|
||||
self.relay.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.relay.close()
|
||||
self.client.close()
|
||||
self.host.close()
|
||||
|
||||
def test_authenticated_allocation_masks_both_peer_endpoints(self) -> None:
|
||||
token = "join-capability"
|
||||
route = self.relay.allocate(
|
||||
token,
|
||||
"127.0.0.1",
|
||||
int(self.host.getsockname()[1]),
|
||||
)
|
||||
relay_endpoint = (route.host, route.port)
|
||||
|
||||
self.client.sendto(b"not authenticated", relay_endpoint)
|
||||
with self.assertRaises(TimeoutError):
|
||||
self.host.recvfrom(4096)
|
||||
|
||||
self.client.sendto(RELAY_AUTH_PREFIX + token.encode(), relay_endpoint)
|
||||
self.client.sendto(b"client gameplay", relay_endpoint)
|
||||
packet, relay_as_seen_by_host = self.host.recvfrom(4096)
|
||||
self.assertEqual(packet, b"client gameplay")
|
||||
self.assertEqual(relay_as_seen_by_host[1], route.port)
|
||||
self.assertNotEqual(
|
||||
relay_as_seen_by_host[1], int(self.client.getsockname()[1])
|
||||
)
|
||||
|
||||
self.host.sendto(b"host gameplay", relay_as_seen_by_host)
|
||||
packet, relay_as_seen_by_client = self.client.recvfrom(4096)
|
||||
self.assertEqual(packet, b"host gameplay")
|
||||
self.assertEqual(relay_as_seen_by_client, relay_endpoint)
|
||||
self.assertNotEqual(
|
||||
relay_as_seen_by_client[1], int(self.host.getsockname()[1])
|
||||
)
|
||||
|
||||
def test_wrong_capability_cannot_claim_allocation(self) -> None:
|
||||
route = self.relay.allocate(
|
||||
"right-token",
|
||||
"127.0.0.1",
|
||||
int(self.host.getsockname()[1]),
|
||||
)
|
||||
relay_endpoint = (route.host, route.port)
|
||||
self.client.sendto(RELAY_AUTH_PREFIX + b"wrong-token", relay_endpoint)
|
||||
self.client.sendto(b"blocked gameplay", relay_endpoint)
|
||||
with self.assertRaises(TimeoutError):
|
||||
self.host.recvfrom(4096)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -22,6 +22,7 @@ class ServerConfigCompatibilityTests(unittest.TestCase):
|
|||
self.assertEqual(config.bind_host, "127.0.0.9")
|
||||
self.assertEqual(config.bind_port, 7791)
|
||||
self.assertEqual(config.build_revision, "legacy-config")
|
||||
self.assertTrue(config.relay_enabled)
|
||||
|
||||
def test_straywild_environment_wins_over_legacy_names(self) -> None:
|
||||
with patch.dict(
|
||||
|
|
@ -38,6 +39,15 @@ class ServerConfigCompatibilityTests(unittest.TestCase):
|
|||
self.assertEqual(config.bind_host, "127.0.0.8")
|
||||
self.assertEqual(config.bind_port, 7792)
|
||||
|
||||
def test_relay_can_be_explicitly_disabled_without_fallback_ambiguity(self) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"straywild_DISCOVERY_RELAY_ENABLED": "false"},
|
||||
clear=True,
|
||||
):
|
||||
config = ServerConfig.from_environment()
|
||||
self.assertFalse(config.relay_enabled)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from straywild_discovery.social_registry import (
|
|||
)
|
||||
|
||||
|
||||
GAME_VERSION = "0.20.2-alpha"
|
||||
GAME_VERSION = "0.20.3-alpha"
|
||||
PROTOCOL_VERSION = 12
|
||||
VALID_ROOM = {
|
||||
"room_name": "Pond Friends",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue