From 46de16db001905e8e79f6693e8a558e1e19c70c5 Mon Sep 17 00:00:00 2001 From: Voyager Date: Tue, 1 Sep 2026 10:21:34 -0400 Subject: [PATCH] feat: add selectable allocation-based privacy relay --- README.md | 55 ++++- deploy/straywild-discovery.env.example | 18 ++ deploy/straywild-discovery.service | 1 + docs/API.md | 58 +++-- pyproject.toml | 4 +- straywild_discovery/__init__.py | 2 +- straywild_discovery/registry.py | 95 +++++++- straywild_discovery/relay.py | 312 +++++++++++++++++++++++++ straywild_discovery/server.py | 179 +++++++++++++- tests/test_http_api.py | 196 +++++++++++++++- tests/test_registry.py | 99 +++++++- tests/test_relay.py | 79 +++++++ tests/test_server_config.py | 10 + tests/test_social_registry.py | 2 +- 14 files changed, 1067 insertions(+), 43 deletions(-) create mode 100644 straywild_discovery/relay.py create mode 100644 tests/test_relay.py diff --git a/README.md b/README.md index 33acc53..b756bf6 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,15 @@ # straywild Discovery Server -An ephemeral public-room directory for straywild. It complements the game's existing direct ENet -transport: hosts advertise a room through HTTPS, browsers retrieve compatible rooms, and joining -still uses the authoritative host's UDP endpoint. +An ephemeral public-room directory and allocation-based UDP privacy relay for straywild. Player +hosts choose direct or relay routing when they advertise through HTTPS. Browsers retrieve +compatible metadata without receiving an IP address, and relay-enabled joins give each player an +isolated relay endpoint instead of the other peer's network address. Dedicated servers are +direct-only and cannot consume shared relay capacity. -The service also runs a small UDP rendezvous. It observes packets sent from the exact ENet sockets -used by hosts and joiners, then lets an authenticated room host retrieve pending endpoints and send -hole-punch packets. Gameplay remains direct and never passes through this service. +The service also runs a small UDP rendezvous. It observes packets from the host's exact ENet socket +and lets that host punch the allocated relay socket. Relay gameplay is pinned to the authenticated +joining socket and verified host endpoint, bounded by packet/byte budgets, and removed after an +idle timeout. Addresses are held only in memory and are not written to the application log. Friend presence and live room invitations use the same service as short-lived capability channels. There are no discovery accounts, durable friend records, or offline messages; friendship state @@ -50,7 +53,9 @@ The intended public topology is: ```text straywild client -> HTTPS reverse proxy -> 127.0.0.1:7770 discovery service straywild client -----------------------> public UDP rendezvous:7771 -straywild client -----------------------> advertised ENet/UDP host:port +direct room client ---------------------> authoritative ENet/UDP host +relay room client ----------------------> allocated UDP relay:20000-22047 +allocated UDP relay --------------------> player-hosted ENet/UDP host ``` Configure the Godot client with the reverse proxy's HTTPS origin through @@ -63,6 +68,26 @@ its own address must be listed in `straywild_DISCOVERY_TRUSTED_PROXY_CIDRS`. Apply ordinary request-rate limits to the write endpoints at the proxy. Do not expose the Python listener directly to the public Internet. +### Privacy logging policy + +The discovery and relay application must not log private player information. This includes raw IP +addresses and ports, address-derived pseudonyms, request paths tied to room IDs, room or player +names, request bodies, headers, join and lease tokens, authentication material, and friendship +capabilities. HTTP access logging is disabled completely. Unexpected request failures produce only +a generic operational error without a peer address, request contents, or request-specific +traceback. + +The service necessarily handles network endpoints temporarily in memory to observe routes, apply +abuse limits, and forward active relay traffic. Those endpoints expire with their room, join, or +relay state and are never written to the application log or a database. Temporary processing is +not permission to retain or repurpose that information. + +Production deployments must also disable reverse-proxy access logs for every discovery route. +Default proxy log formats normally contain a client address and are not acceptable here. Do not +log forwarded addresses, request bodies, authorization headers, or tokens at the proxy, firewall, +or service wrapper. Hosting providers and upstream networks may retain connection metadata under +their own policies; operators should choose providers accordingly and disclose that boundary. + Use the sample systemd unit in [`deploy/straywild-discovery.service`](deploy/straywild-discovery.service) as a deployment starting point. The service keeps only active leases in memory, so it requires no database, backups, or schema migrations. @@ -70,13 +95,23 @@ database, backups, or schema migrations. ## Connectivity boundary The game first requests UPnP forwarding, then uses same-socket UDP rendezvous and hole punching. -This covers common home NAT configurations without changing ENet gameplay authority. Symmetric -NAT and some carrier-grade networks can still require a future ENet-compatible relay fallback. -Expose the configured traversal port over UDP in both the host firewall and provider firewall. +A player host chooses whether its public listing is direct or relay. A relay room punches a unique +allocation and both peers exchange ENet packets through it; a direct room connects through the +verified host route and consumes no relay allocation. ENet gameplay authority does not move to +discovery. Dedicated advertisements must be direct and a dedicated relay request is rejected. +Expose the configured traversal port and full relay allocation range over UDP in both the host +firewall and provider firewall. The rendezvous listener intentionally processes its small, bounded packets serially so arbitrary datagrams cannot create unbounded worker threads. Apply conservative edge rate limits as an additional deployment control. +The relay prevents the player host and joiner from seeing one another's address during normal +network traffic. It is not anonymity from the relay operator, hosting provider, upstream network, +or a compromised client. The discovery process necessarily handles both endpoints in volatile +memory. Clients treat the host-selected connection mode as binding: failure to allocate or reach a +relay stops the join and never silently falls back to direct transport. Operators may explicitly +disable relay availability for private development; a player relay advertisement is then rejected. + ## API See [`docs/API.md`](docs/API.md) for the versioned HTTP contract. diff --git a/deploy/straywild-discovery.env.example b/deploy/straywild-discovery.env.example index a894d2e..c44f2ea 100644 --- a/deploy/straywild-discovery.env.example +++ b/deploy/straywild-discovery.env.example @@ -14,6 +14,24 @@ straywild_DISCOVERY_TRAVERSAL_HOST=0.0.0.0 straywild_DISCOVERY_TRAVERSAL_PORT=7771 straywild_DISCOVERY_TRAVERSAL_PUBLIC_HOST=discovery.straywild.io +# Allocation-based ENet/UDP privacy relay for player-hosted rooms. Player hosts +# explicitly select direct or relay discovery. Dedicated rooms are always +# direct and are rejected if they request this shared relay. Open the complete +# UDP range in the host and provider firewalls. Relay joins fail closed and +# never downgrade to direct when an allocation is unavailable. +straywild_DISCOVERY_RELAY_ENABLED=true +straywild_DISCOVERY_RELAY_HOST=0.0.0.0 +straywild_DISCOVERY_RELAY_PUBLIC_HOST=discovery.straywild.io +straywild_DISCOVERY_RELAY_PORT_START=20000 +straywild_DISCOVERY_RELAY_PORT_END=22047 +straywild_DISCOVERY_RELAY_MAX_ALLOCATIONS=2048 +straywild_DISCOVERY_RELAY_AUTH_TTL=12 +straywild_DISCOVERY_RELAY_IDLE_TTL=120 + # Only trust X-Forwarded-For when the immediate peer is within one of these CIDRs. # The loopback values suit a reverse proxy on the same host. straywild_DISCOVERY_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128 + +# Privacy invariant: the application does not emit request/access logs. The +# reverse proxy must also have access logging disabled for discovery routes; +# never log peer addresses, request bodies, headers, capabilities, or tokens. diff --git a/deploy/straywild-discovery.service b/deploy/straywild-discovery.service index f4a1d2a..83ed14a 100644 --- a/deploy/straywild-discovery.service +++ b/deploy/straywild-discovery.service @@ -22,6 +22,7 @@ ProtectControlGroups=true RestrictSUIDSGID=true LockPersonality=true MemoryDenyWriteExecute=true +LimitNOFILE=8192 [Install] WantedBy=multi-user.target diff --git a/docs/API.md b/docs/API.md index 5f1f1b2..644cd16 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,16 +1,17 @@ # Discovery API v1 -The API stores public room advertisements as short-lived leases and coordinates direct UDP hole -punching. It does not proxy or relay gameplay traffic. straywild continues to connect to the -returned host and UDP port through ENet. +The API stores public room advertisements as short-lived leases and coordinates UDP hole punching. +Player hosts explicitly advertise either direct or relay routing; dedicated servers must advertise +direct routing. Public room and social-presence responses never contain a host address or port. +Relay-enabled join responses contain only the relay endpoint. All request and response bodies use `application/json`. Production clients must use HTTPS. ## `GET /health` -Returns service status, package version, configured build revision, and the number of active, -unexpired room leases. Deployments should set `straywild_DISCOVERY_BUILD_REVISION` to the exact -Git commit they are running. The package version follows the coordinated straywild release tag. +Returns service status, package version, configured build revision, active room count, whether the +relay is enabled, and the active relay allocation count. Deployments should set +`straywild_DISCOVERY_BUILD_REVISION` to the exact Git commit they are running. ## `GET /v1/rooms` @@ -19,7 +20,9 @@ Lists active rooms. Optional exact-match filters: - `game_version` - `protocol_version` -The response contains `rooms` and the server's `ttl_seconds`. +The response contains `rooms` and the server's `ttl_seconds`. Each room includes metadata plus +either `connection_mode: "relay"` with `ip_privacy: "relayed"`, or an explicitly configured +`direct` / `peer_visible` pair. It intentionally omits `address` and `port`. ## `POST /v1/rooms` @@ -32,13 +35,18 @@ trusting an address supplied by the game client. "port": 7777, "current_players": 0, "max_players": 8, - "game_version": "0.20.2-alpha", - "protocol_version": 12 + "game_version": "0.20.3-alpha", + "protocol_version": 12, + "host_kind": "player", + "connection_mode": "relay" } ``` `current_players` may be zero for an empty dedicated server. A player-hosted -room normally includes its host in this count. +room normally includes its host in this count. `host_kind` accepts `player` or +`dedicated`. Player rooms may request `direct` or `relay`; dedicated rooms must +request `direct`. A dedicated relay advertisement, or a relay advertisement +when relay service is unavailable, is rejected with `400 invalid_room`. The `201` response includes the room, a secret `lease_token`, and an endpoint verification token. The host sends the verification token to the UDP rendezvous from its bound ENet socket. Rooms are @@ -69,8 +77,18 @@ but TTL expiry remains authoritative for crashes and lost connectivity. ## `POST /v1/rooms/{room_id}/join-attempts` -Creates a short-lived traversal token for a public room. The joining game sends that token to the -UDP rendezvous from the same ENet socket used for the gameplay connection. +Creates a short-lived join capability for a public room. For a relay route, the joining game sends +the capability to its allocated relay from the same ENet socket used for gameplay. The allocation +then accepts traffic only from that authenticated client socket and the verified host endpoint. +For an explicitly configured direct route, the capability retains the older rendezvous behavior. + +The `201` response also contains a short-lived `route` with `transport`, `address`, `port`, and +`ip_privacy`. For a relay room, these are the relay's public address and unique allocation port; +the host endpoint is never returned. For a direct room, this is the only unauthenticated response +that discloses the verified host endpoint, and it is issued only after an explicit Join. Responses +are marked `Cache-Control: no-store`. Join issuance is limited per source address, per room, +globally, and by concurrent pending count. A relay allocation failure returns +`503 relay_unavailable`; it never falls back to direct. ## `GET /v1/rooms/{room_id}/join-attempts` @@ -109,10 +127,24 @@ live straywild session. ## Trust boundary -- The UDP rendezvous observation is authoritative for a room's public address and port. +- The UDP rendezvous observation is authoritative for a room's private join address and port. +- Public browse, presence, and invitation payloads never contain endpoints. +- Endpoint-bearing join routes are short-lived and must never be shown, logged, or persisted by + clients. Relay routes contain no peer endpoint. - `X-Forwarded-For` is honored only when the immediate peer belongs to an explicitly configured trusted proxy CIDR. - Lease tokens authorize update and deletion but are never included in public listings. - Friend capability channels are short-lived and reveal neither identity fingerprints nor a full social graph to the service. - The service is an ephemeral directory, not a source of gameplay or friendship authority. +- The service emits no HTTP request/access log. It does not log raw addresses, ports, + address-derived pseudonyms, request paths tied to rooms, names, payloads, headers, tokens, + authentication material, or friendship capabilities. Unexpected request failures produce only + a generic operational error without peer or request metadata. +- Peer endpoints exist only in volatile room, join, abuse-limit, and relay state for as long as + needed to provide the service. They are never written to an application log or database. +- A production reverse proxy must have access logging disabled for all discovery routes. Default + proxy logs commonly contain raw client addresses and violate this service's privacy policy. +- The relay necessarily holds peer endpoints in volatile memory while an allocation is active. + Allocations require a capability from the joining ENet socket, accept host traffic only from the + verified endpoint, enforce traffic budgets, and expire when idle. diff --git a/pyproject.toml b/pyproject.toml index 5c86f89..f04c660 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "straywild-discovery-server" -version = "0.20.2-alpha" -description = "Ephemeral public-room directory for straywild" +version = "0.20.3-alpha" +description = "Ephemeral public-room directory and UDP privacy relay for straywild" requires-python = ">=3.11" dependencies = [] license = { file = "LICENSE" } diff --git a/straywild_discovery/__init__.py b/straywild_discovery/__init__.py index c7e00c8..ec02a84 100644 --- a/straywild_discovery/__init__.py +++ b/straywild_discovery/__init__.py @@ -1,3 +1,3 @@ """straywild discovery service.""" -__version__ = "0.20.2-alpha" +__version__ = "0.20.3-alpha" diff --git a/straywild_discovery/registry.py b/straywild_discovery/registry.py index 22289cd..2114d6d 100644 --- a/straywild_discovery/registry.py +++ b/straywild_discovery/registry.py @@ -9,6 +9,7 @@ import threading import time import unicodedata import uuid +from collections import deque from collections.abc import Callable, Mapping from typing import Any @@ -19,7 +20,11 @@ MAX_ROOM_CAPACITY = 128 DEFAULT_MAX_ROOMS = 4096 DEFAULT_MAX_ROOMS_PER_ADDRESS = 32 JOIN_ATTEMPT_TTL_SECONDS = 12.0 -MAX_JOIN_ATTEMPTS_PER_ROOM = 64 +MAX_ACTIVE_JOIN_ATTEMPTS_PER_ROOM = 16 +JOIN_RATE_WINDOW_SECONDS = 60.0 +MAX_JOIN_ATTEMPTS_PER_ADDRESS_PER_WINDOW = 10 +MAX_JOIN_ATTEMPTS_PER_ROOM_PER_WINDOW = 60 +MAX_JOIN_ATTEMPTS_GLOBAL_PER_WINDOW = 2048 class RegistryError(Exception): @@ -52,6 +57,8 @@ class RoomAdvertisement: max_players: int game_version: str protocol_version: int + host_kind: str + connection_mode: str created_at: float updated_at: float expires_at: float @@ -61,16 +68,28 @@ class RoomAdvertisement: return { "room_id": self.room_id, "room_name": self.room_name, - "address": self.address, - "port": self.port, "current_players": self.current_players, "max_players": self.max_players, "game_version": self.game_version, "protocol_version": self.protocol_version, + "host_kind": self.host_kind, "created_at": _iso_utc(self.created_at), "updated_at": _iso_utc(self.updated_at), "expires_at": _iso_utc(self.expires_at), "verified": self.verified, + "connection_mode": self.connection_mode, + "ip_privacy": ( + "relayed" if self.connection_mode == "relay" else "peer_visible" + ), + } + + def join_route_dict(self) -> dict[str, Any]: + """Return the transient route disclosed only after an explicit join.""" + return { + "transport": "direct", + "address": self.address, + "port": self.port, + "ip_privacy": "peer_visible", } @@ -124,6 +143,7 @@ class RoomRegistry: ttl_seconds: float = 45.0, max_rooms: int = DEFAULT_MAX_ROOMS, max_rooms_per_address: int = DEFAULT_MAX_ROOMS_PER_ADDRESS, + relay_available: bool = False, clock: Callable[[], float] = time.time, ) -> None: if ttl_seconds <= 0.0: @@ -133,9 +153,13 @@ class RoomRegistry: self._ttl_seconds = float(ttl_seconds) self._max_rooms = int(max_rooms) self._max_rooms_per_address = int(max_rooms_per_address) + self._relay_available = bool(relay_available) self._clock = clock self._rooms: dict[str, _RoomLease] = {} self._join_attempts: dict[str, _JoinAttempt] = {} + self._join_attempts_by_address: dict[str, deque[float]] = {} + self._join_attempts_by_room: dict[str, deque[float]] = {} + self._global_join_attempts: deque[float] = deque() self._lock = threading.RLock() @property @@ -273,26 +297,36 @@ class RoomRegistry: ) return lease.advertisement - def create_join_attempt(self, room_id: str) -> str: + def create_join_attempt( + self, room_id: str, requester_address: str + ) -> tuple[str, RoomAdvertisement]: + if not requester_address: + raise ValidationError("invalid join source") now = self._clock() with self._lock: self._purge_locked(now) lease = self._rooms.get(room_id) if lease is None or not lease.advertisement.verified: raise RoomNotFoundError("room is not available") + self._enforce_join_rate_limits_locked(room_id, requester_address, now) active_count = sum( 1 for attempt in self._join_attempts.values() if attempt.room_id == room_id ) - if active_count >= MAX_JOIN_ATTEMPTS_PER_ROOM: + if active_count >= MAX_ACTIVE_JOIN_ATTEMPTS_PER_ROOM: raise RoomLimitError("too many pending joins for this room") token = secrets.token_urlsafe(32) self._join_attempts[token] = _JoinAttempt( room_id=room_id, expires_at=now + JOIN_ATTEMPT_TTL_SECONDS, ) - return token + self._join_attempts_by_address.setdefault( + requester_address, deque() + ).append(now) + self._join_attempts_by_room.setdefault(room_id, deque()).append(now) + self._global_join_attempts.append(now) + return token, lease.advertisement def register_join_endpoint( self, token: str, address: str, port: int @@ -308,6 +342,10 @@ class RoomRegistry: attempt.address = address attempt.port = port + def cancel_join_attempt(self, token: str) -> None: + with self._lock: + self._join_attempts.pop(token, None) + def consume_join_endpoints( self, room_id: str, lease_token: str ) -> list[dict[str, Any]]: @@ -358,6 +396,16 @@ class RoomRegistry: protocol_version = _clean_int( payload.get("protocol_version"), "protocol_version", 1, 2_147_483_647 ) + host_kind = payload.get("host_kind", "player") + if host_kind not in {"player", "dedicated"}: + raise ValidationError("host_kind must be player or dedicated") + connection_mode = payload.get("connection_mode", "direct") + if connection_mode not in {"direct", "relay"}: + raise ValidationError("connection_mode must be direct or relay") + if connection_mode == "relay" and host_kind == "dedicated": + raise ValidationError("dedicated servers cannot use the privacy relay") + if connection_mode == "relay" and not self._relay_available: + raise ValidationError("the privacy relay is not available") return RoomAdvertisement( room_id=room_id, room_name=room_name, @@ -367,6 +415,8 @@ class RoomRegistry: max_players=max_players, game_version=game_version, protocol_version=protocol_version, + host_kind=host_kind, + connection_mode=connection_mode, created_at=created_at, updated_at=now, expires_at=now + self._ttl_seconds, @@ -381,6 +431,29 @@ class RoomRegistry: raise LeaseAuthorizationError("invalid room lease token") return lease + def _enforce_join_rate_limits_locked( + self, room_id: str, requester_address: str, now: float + ) -> None: + cutoff = now - JOIN_RATE_WINDOW_SECONDS + self._prune_history(self._global_join_attempts, cutoff) + address_history = self._join_attempts_by_address.setdefault( + requester_address, deque() + ) + room_history = self._join_attempts_by_room.setdefault(room_id, deque()) + self._prune_history(address_history, cutoff) + self._prune_history(room_history, cutoff) + if len(address_history) >= MAX_JOIN_ATTEMPTS_PER_ADDRESS_PER_WINDOW: + raise RoomLimitError("too many join requests; try again shortly") + if len(room_history) >= MAX_JOIN_ATTEMPTS_PER_ROOM_PER_WINDOW: + raise RoomLimitError("this room is receiving too many join requests") + if len(self._global_join_attempts) >= MAX_JOIN_ATTEMPTS_GLOBAL_PER_WINDOW: + raise RoomLimitError("the join service is busy; try again shortly") + + @staticmethod + def _prune_history(history: deque[float], cutoff: float) -> None: + while history and history[0] <= cutoff: + history.popleft() + def _purge_locked(self, now: float) -> None: expired = [ room_id @@ -396,3 +469,13 @@ class RoomRegistry: ] for token in expired_attempts: del self._join_attempts[token] + cutoff = now - JOIN_RATE_WINDOW_SECONDS + self._prune_history(self._global_join_attempts, cutoff) + for histories in [ + self._join_attempts_by_address, + self._join_attempts_by_room, + ]: + for key in list(histories): + self._prune_history(histories[key], cutoff) + if not histories[key]: + del histories[key] diff --git a/straywild_discovery/relay.py b/straywild_discovery/relay.py new file mode 100644 index 0000000..9ab96f1 --- /dev/null +++ b/straywild_discovery/relay.py @@ -0,0 +1,312 @@ +"""Bounded allocation-based UDP relay for ENet gameplay traffic.""" + +from __future__ import annotations + +from dataclasses import dataclass +import selectors +import secrets +import socket +import threading +import time +from collections.abc import Callable +from typing import Any + + +RELAY_AUTH_PREFIX = b"straywild_RELAY_AUTH_V1 " + + +class RelayError(Exception): + """A relay allocation could not be created safely.""" + + +@dataclass(frozen=True, slots=True) +class RelayConfig: + bind_host: str = "0.0.0.0" + public_host: str = "127.0.0.1" + port_start: int = 20_000 + port_end: int = 22_047 + max_allocations: int = 2_048 + authentication_ttl_seconds: float = 12.0 + idle_ttl_seconds: float = 120.0 + max_packet_bytes: int = 4_096 + max_packets_per_second: float = 512.0 + packet_burst: float = 1_024.0 + max_bytes_per_second: float = 1_500_000.0 + byte_burst: float = 3_000_000.0 + + def validate(self) -> None: + if not self.bind_host or not self.public_host: + raise ValueError("relay bind and public hosts are required") + ephemeral_ports = self.port_start == 0 and self.port_end == 0 + if not ephemeral_ports and ( + self.port_start < 1 or self.port_end > 65_535 + ): + raise ValueError("relay ports must be between 1 and 65535") + if self.port_end < self.port_start: + raise ValueError("relay port range is invalid") + if self.max_allocations < 1: + raise ValueError("relay allocation limit must be positive") + if ( + not ephemeral_ports + and self.max_allocations > self.port_end - self.port_start + 1 + ): + raise ValueError("relay allocation limit exceeds the port range") + if self.authentication_ttl_seconds <= 0 or self.idle_ttl_seconds <= 0: + raise ValueError("relay timeouts must be positive") + if self.max_packet_bytes < 512: + raise ValueError("relay packet limit is too small") + if min( + self.max_packets_per_second, + self.packet_burst, + self.max_bytes_per_second, + self.byte_burst, + ) <= 0: + raise ValueError("relay traffic limits must be positive") + + +@dataclass(frozen=True, slots=True) +class RelayRoute: + host: str + port: int + + def as_dict(self) -> dict[str, Any]: + return { + "transport": "relay", + "address": self.host, + "port": self.port, + "ip_privacy": "relayed", + } + + +@dataclass(slots=True) +class _Allocation: + udp_socket: socket.socket + port: int + authorization_packet: bytes + host_endpoint: tuple[str, int] + created_at: float + last_activity_at: float + client_endpoint: tuple[str, int] | None = None + packet_tokens: float = 0.0 + byte_tokens: float = 0.0 + token_updated_at: float = 0.0 + + +class RelayService: + """Runs many isolated relay sockets on one bounded selector thread.""" + + def __init__( + self, + config: RelayConfig, + clock: Callable[[], float] = time.monotonic, + ) -> None: + config.validate() + self.config = config + self._clock = clock + self._selector = selectors.DefaultSelector() + self._allocations: dict[int, _Allocation] = {} + self._lock = threading.RLock() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + def start(self) -> None: + with self._lock: + if self._thread is not None: + return + self._stop.clear() + self._thread = threading.Thread( + target=self._run, + name="discovery-relay", + daemon=True, + ) + self._thread.start() + + def close(self) -> None: + self._stop.set() + thread = self._thread + if thread is not None: + thread.join(timeout=2.0) + with self._lock: + for allocation in list(self._allocations.values()): + self._close_allocation_locked(allocation) + self._thread = None + self._selector.close() + + def allocation_count(self) -> int: + with self._lock: + return len(self._allocations) + + def allocate( + self, + authorization_token: str, + host_address: str, + host_port: int, + ) -> RelayRoute: + if ( + not authorization_token + or not host_address + or host_port < 1 + or host_port > 65_535 + ): + raise RelayError("invalid relay allocation request") + authorization_packet = RELAY_AUTH_PREFIX + authorization_token.encode("ascii") + now = self._clock() + with self._lock: + self._purge_expired_locked(now) + if len(self._allocations) >= self.config.max_allocations: + raise RelayError("relay capacity reached") + try: + udp_socket = self._bind_available_socket_locked(host_address) + except OSError as error: + raise RelayError("could not bind a relay socket") from error + port = int(udp_socket.getsockname()[1]) + allocation = _Allocation( + udp_socket=udp_socket, + port=port, + authorization_packet=authorization_packet, + host_endpoint=(host_address, host_port), + created_at=now, + last_activity_at=now, + packet_tokens=self.config.packet_burst, + byte_tokens=self.config.byte_burst, + token_updated_at=now, + ) + self._allocations[port] = allocation + try: + self._selector.register( + udp_socket, selectors.EVENT_READ, allocation + ) + except (OSError, ValueError) as error: + self._allocations.pop(port, None) + udp_socket.close() + raise RelayError("could not activate a relay socket") from error + return RelayRoute(self.config.public_host, port) + + def _bind_available_socket_locked(self, host_address: str) -> socket.socket: + family = socket.AF_INET6 if ":" in host_address else socket.AF_INET + bind_host = self.config.bind_host + if family == socket.AF_INET6 and bind_host == "0.0.0.0": + bind_host = "::" + if family == socket.AF_INET and bind_host == "::": + bind_host = "0.0.0.0" + if self.config.port_start == 0 and self.config.port_end == 0: + udp_socket = socket.socket(family, socket.SOCK_DGRAM) + udp_socket.setblocking(False) + udp_socket.bind((bind_host, 0)) + return udp_socket + range_size = self.config.port_end - self.config.port_start + 1 + start_offset = secrets.randbelow(range_size) + for offset in range(range_size): + port = self.config.port_start + ((start_offset + offset) % range_size) + if port in self._allocations: + continue + udp_socket = socket.socket(family, socket.SOCK_DGRAM) + udp_socket.setblocking(False) + try: + udp_socket.bind((bind_host, port)) + return udp_socket + except OSError: + udp_socket.close() + raise RelayError("no relay port is available") + + def _run(self) -> None: + while not self._stop.is_set(): + with self._lock: + events = self._selector.select(timeout=0.05) + for key, _mask in events: + allocation = key.data + if isinstance(allocation, _Allocation): + self._receive_locked(allocation) + self._purge_expired_locked(self._clock()) + + def _receive_locked(self, allocation: _Allocation) -> None: + try: + packet, source = allocation.udp_socket.recvfrom( + self.config.max_packet_bytes + 1 + ) + except (BlockingIOError, OSError): + return + if not packet or len(packet) > self.config.max_packet_bytes: + return + source_endpoint = (str(source[0]), int(source[1])) + now = self._clock() + if source_endpoint == allocation.host_endpoint: + if ( + allocation.client_endpoint is not None + and self._consume_budget_locked(allocation, len(packet), now) + ): + self._send_locked(allocation, packet, allocation.client_endpoint) + allocation.last_activity_at = now + return + if packet.startswith(RELAY_AUTH_PREFIX): + if ( + packet == allocation.authorization_packet + and now - allocation.created_at + <= self.config.authentication_ttl_seconds + and ( + allocation.client_endpoint is None + or allocation.client_endpoint == source_endpoint + ) + ): + allocation.client_endpoint = source_endpoint + allocation.authorization_packet = b"" + allocation.last_activity_at = now + return + if allocation.client_endpoint != source_endpoint: + return + if not self._consume_budget_locked(allocation, len(packet), now): + return + self._send_locked(allocation, packet, allocation.host_endpoint) + allocation.last_activity_at = now + + @staticmethod + def _send_locked( + allocation: _Allocation, + packet: bytes, + destination: tuple[str, int], + ) -> None: + try: + allocation.udp_socket.sendto(packet, destination) + except OSError: + return + + def _consume_budget_locked( + self, allocation: _Allocation, packet_bytes: int, now: float + ) -> bool: + elapsed = max(0.0, now - allocation.token_updated_at) + allocation.packet_tokens = min( + self.config.packet_burst, + allocation.packet_tokens + elapsed * self.config.max_packets_per_second, + ) + allocation.byte_tokens = min( + self.config.byte_burst, + allocation.byte_tokens + elapsed * self.config.max_bytes_per_second, + ) + allocation.token_updated_at = now + if allocation.packet_tokens < 1.0 or allocation.byte_tokens < packet_bytes: + return False + allocation.packet_tokens -= 1.0 + allocation.byte_tokens -= packet_bytes + return True + + def _purge_expired_locked(self, now: float) -> None: + expired = [ + allocation + for allocation in self._allocations.values() + if ( + allocation.client_endpoint is None + and now - allocation.created_at + > self.config.authentication_ttl_seconds + ) + or now - allocation.last_activity_at > self.config.idle_ttl_seconds + ] + for allocation in expired: + self._close_allocation_locked(allocation) + + def _close_allocation_locked(self, allocation: _Allocation) -> None: + self._allocations.pop(allocation.port, None) + try: + self._selector.unregister(allocation.udp_socket) + except (KeyError, ValueError): + pass + allocation.udp_socket.close() diff --git a/straywild_discovery/server.py b/straywild_discovery/server.py index 8198592..00497cb 100644 --- a/straywild_discovery/server.py +++ b/straywild_discovery/server.py @@ -27,6 +27,7 @@ from .registry import ( RoomRegistry, ValidationError, ) +from .relay import RelayConfig, RelayError, RelayService from .social_registry import FriendOfflineError, SocialRegistry @@ -43,6 +44,16 @@ def _environment(primary: str, legacy: str, default: str) -> str: return os.getenv(legacy, default) +def _environment_bool(primary: str, legacy: str, default: bool) -> bool: + value = _environment(primary, legacy, "true" if default else "false") + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ValueError(f"{primary} must be true or false") + + @dataclass(frozen=True, slots=True) class ServerConfig: bind_host: str = "127.0.0.1" @@ -55,6 +66,14 @@ class ServerConfig: traversal_bind_host: str = "0.0.0.0" traversal_port: int = 7771 traversal_public_host: str = "127.0.0.1" + relay_enabled: bool = False + relay_bind_host: str = "0.0.0.0" + relay_public_host: str = "127.0.0.1" + relay_port_start: int = 20_000 + relay_port_end: int = 22_047 + relay_max_allocations: int = 2_048 + relay_authentication_ttl_seconds: float = 12.0 + relay_idle_ttl_seconds: float = 120.0 build_revision: str = "unknown" @classmethod @@ -120,6 +139,50 @@ class ServerConfig: "NETFISHING_DISCOVERY_TRAVERSAL_PUBLIC_HOST", "127.0.0.1", ), + relay_enabled=_environment_bool( + "straywild_DISCOVERY_RELAY_ENABLED", + "NETFISHING_DISCOVERY_RELAY_ENABLED", + True, + ), + relay_bind_host=_environment( + "straywild_DISCOVERY_RELAY_HOST", + "NETFISHING_DISCOVERY_RELAY_HOST", + "0.0.0.0", + ), + relay_public_host=_environment( + "straywild_DISCOVERY_RELAY_PUBLIC_HOST", + "NETFISHING_DISCOVERY_RELAY_PUBLIC_HOST", + _environment( + "straywild_DISCOVERY_TRAVERSAL_PUBLIC_HOST", + "NETFISHING_DISCOVERY_TRAVERSAL_PUBLIC_HOST", + "127.0.0.1", + ), + ), + relay_port_start=int(_environment( + "straywild_DISCOVERY_RELAY_PORT_START", + "NETFISHING_DISCOVERY_RELAY_PORT_START", + "20000", + )), + relay_port_end=int(_environment( + "straywild_DISCOVERY_RELAY_PORT_END", + "NETFISHING_DISCOVERY_RELAY_PORT_END", + "22047", + )), + relay_max_allocations=int(_environment( + "straywild_DISCOVERY_RELAY_MAX_ALLOCATIONS", + "NETFISHING_DISCOVERY_RELAY_MAX_ALLOCATIONS", + "2048", + )), + relay_authentication_ttl_seconds=float(_environment( + "straywild_DISCOVERY_RELAY_AUTH_TTL", + "NETFISHING_DISCOVERY_RELAY_AUTH_TTL", + "12", + )), + relay_idle_ttl_seconds=float(_environment( + "straywild_DISCOVERY_RELAY_IDLE_TTL", + "NETFISHING_DISCOVERY_RELAY_IDLE_TTL", + "120", + )), build_revision=( _environment( "straywild_DISCOVERY_BUILD_REVISION", @@ -142,13 +205,51 @@ class DiscoveryHTTPServer(ThreadingHTTPServer): social_registry: SocialRegistry | None = None, ) -> None: self.config = config + self.relay_service: RelayService | None = None + if config.relay_enabled: + self.relay_service = RelayService(RelayConfig( + bind_host=config.relay_bind_host, + public_host=config.relay_public_host, + port_start=config.relay_port_start, + port_end=config.relay_port_end, + max_allocations=config.relay_max_allocations, + authentication_ttl_seconds=( + config.relay_authentication_ttl_seconds + ), + idle_ttl_seconds=config.relay_idle_ttl_seconds, + )) self.registry = registry or RoomRegistry( config.room_ttl_seconds, config.max_rooms, config.max_rooms_per_address, + self.relay_service is not None, ) self.social_registry = social_registry or SocialRegistry() - super().__init__((config.bind_host, config.bind_port), DiscoveryRequestHandler) + try: + super().__init__( + (config.bind_host, config.bind_port), DiscoveryRequestHandler + ) + except Exception: + if self.relay_service is not None: + self.relay_service.close() + raise + if self.relay_service is not None: + self.relay_service.start() + + def server_close(self) -> None: + if self.relay_service is not None: + self.relay_service.close() + self.relay_service = None + super().server_close() + + def handle_error( + self, + _request: object, + _client_address: object, + ) -> None: + # Privacy invariant: never let BaseServer print a peer address or + # request-specific traceback when an unexpected handler error occurs. + LOGGER.error("discovery HTTP request failed without logging request metadata") class DiscoveryRequestHandler(BaseHTTPRequestHandler): @@ -169,6 +270,12 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): "active_rooms": self.server.registry.room_count(), "active_presence_channels": presence_count, "pending_invitations": invitation_count, + "relay_enabled": self.server.relay_service is not None, + "active_relays": ( + self.server.relay_service.allocation_count() + if self.server.relay_service is not None + else 0 + ), }, ) return @@ -224,16 +331,48 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): room_id = self._room_id_for_suffix(path, "/join-attempts") if room_id is not None: try: - token = self.server.registry.create_join_attempt(room_id) + token, room = self.server.registry.create_join_attempt( + room_id, self._client_address() + ) except RoomNotFoundError as error: self._send_error(HTTPStatus.NOT_FOUND, "room_not_found", str(error)) return except RoomLimitError as error: self._send_error(HTTPStatus.TOO_MANY_REQUESTS, "join_limit", str(error)) return + route = room.join_route_dict() + if room.connection_mode == "relay": + if self.server.relay_service is None: + self.server.registry.cancel_join_attempt(token) + self._send_error( + HTTPStatus.SERVICE_UNAVAILABLE, + "relay_unavailable", + "A private route is not available. Try again shortly.", + ) + return + try: + relay_route = self.server.relay_service.allocate( + token, room.address, room.port + ) + self.server.registry.register_join_endpoint( + token, relay_route.host, relay_route.port + ) + route = relay_route.as_dict() + except RelayError: + self.server.registry.cancel_join_attempt(token) + self._send_error( + HTTPStatus.SERVICE_UNAVAILABLE, + "relay_unavailable", + "A private route is not available. Try again shortly.", + ) + return self._send_json( HTTPStatus.CREATED, - {"join_token": token, "traversal": self._traversal_details()}, + { + "join_token": token, + "route": route, + "traversal": self._traversal_details(), + }, ) return if path != "/v1/rooms": @@ -365,8 +504,10 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): self.send_header("Content-Length", "0") self.end_headers() - def log_message(self, format_string: str, *args: object) -> None: - LOGGER.info("%s - %s", self.client_address[0], format_string % args) + def log_message(self, _format_string: str, *_args: object) -> None: + # Privacy invariant: HTTP request/access logging is disabled entirely. + # Do not replace this with raw or pseudonymized peer identifiers. + return def _read_json_object(self) -> dict[str, Any] | None: content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() @@ -522,6 +663,15 @@ class TraversalUDPServer(socketserver.UDPServer): TraversalRequestHandler, ) + def handle_error( + self, + _request: object, + _client_address: object, + ) -> None: + # The socketserver default prints the remote endpoint. Never include + # peer details or packet contents in traversal error logs. + LOGGER.error("discovery traversal request failed without logging metadata") + class TraversalRequestHandler(socketserver.BaseRequestHandler): server: TraversalUDPServer @@ -586,6 +736,16 @@ def main() -> None: traversal_bind_host=config.traversal_bind_host, traversal_port=config.traversal_port, traversal_public_host=config.traversal_public_host, + relay_enabled=config.relay_enabled, + relay_bind_host=config.relay_bind_host, + relay_public_host=config.relay_public_host, + relay_port_start=config.relay_port_start, + relay_port_end=config.relay_port_end, + relay_max_allocations=config.relay_max_allocations, + relay_authentication_ttl_seconds=( + config.relay_authentication_ttl_seconds + ), + relay_idle_ttl_seconds=config.relay_idle_ttl_seconds, build_revision=config.build_revision, ) logging.basicConfig( @@ -609,6 +769,15 @@ def main() -> None: server.server_address[1], config.room_ttl_seconds, ) + LOGGER.info( + "straywild gameplay relay %s%s", + "enabled on UDP ports " if server.relay_service is not None else "disabled", + ( + f"{config.relay_port_start}-{config.relay_port_end}" + if server.relay_service is not None + else "" + ), + ) LOGGER.info( "straywild traversal rendezvous listening on %s:%d/udp", config.traversal_bind_host, diff --git a/tests/test_http_api.py b/tests/test_http_api.py index 8fda64d..5bc211e 100644 --- a/tests/test_http_api.py +++ b/tests/test_http_api.py @@ -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() diff --git a/tests/test_registry.py b/tests/test_registry.py index 681dda1..befa5fe 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -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) diff --git a/tests/test_relay.py b/tests/test_relay.py new file mode 100644 index 0000000..7bac2aa --- /dev/null +++ b/tests/test_relay.py @@ -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() diff --git a/tests/test_server_config.py b/tests/test_server_config.py index 559198b..f1305b7 100644 --- a/tests/test_server_config.py +++ b/tests/test_server_config.py @@ -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() diff --git a/tests/test_social_registry.py b/tests/test_social_registry.py index bfbd99a..7ed3073 100644 --- a/tests/test_social_registry.py +++ b/tests/test_social_registry.py @@ -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",