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

@ -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.

View file

@ -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.

View file

@ -22,6 +22,7 @@ ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
LimitNOFILE=8192
[Install]
WantedBy=multi-user.target

View file

@ -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.

View file

@ -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" }

View file

@ -1,3 +1,3 @@
"""straywild discovery service."""
__version__ = "0.20.2-alpha"
__version__ = "0.20.3-alpha"

View file

@ -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]

View file

@ -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()

View file

@ -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,

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()

View file

@ -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
View 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()

View file

@ -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()

View file

@ -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",