feat: add selectable allocation-based privacy relay
This commit is contained in:
parent
e979da9e76
commit
46de16db00
14 changed files with 1067 additions and 43 deletions
55
README.md
55
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ ProtectControlGroups=true
|
|||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
LimitNOFILE=8192
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
|
|||
58
docs/API.md
58
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.
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""straywild discovery service."""
|
||||
|
||||
__version__ = "0.20.2-alpha"
|
||||
__version__ = "0.20.3-alpha"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
312
straywild_discovery/relay.py
Normal file
312
straywild_discovery/relay.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import json
|
|||
import socketserver
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from straywild_discovery.registry import RoomRegistry
|
||||
from straywild_discovery.server import (
|
||||
|
|
@ -20,7 +21,7 @@ VALID_ROOM = {
|
|||
"port": 7777,
|
||||
"current_players": 1,
|
||||
"max_players": 8,
|
||||
"game_version": "0.20.2-alpha",
|
||||
"game_version": "0.20.3-alpha",
|
||||
"protocol_version": 12,
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +92,12 @@ class DiscoveryHTTPTests(unittest.TestCase):
|
|||
assert listing is not None
|
||||
listed_rooms = listing["rooms"]
|
||||
self.assertTrue(any(candidate["room_id"] == room_id for candidate in listed_rooms))
|
||||
listed_room = next(
|
||||
candidate for candidate in listed_rooms if candidate["room_id"] == room_id
|
||||
)
|
||||
self.assertNotIn("address", listed_room)
|
||||
self.assertNotIn("port", listed_room)
|
||||
self.assertEqual(listed_room["connection_mode"], "direct")
|
||||
|
||||
status, updated = self.request(
|
||||
"PUT",
|
||||
|
|
@ -102,7 +109,7 @@ class DiscoveryHTTPTests(unittest.TestCase):
|
|||
assert updated is not None
|
||||
self.assertEqual(updated["error"]["code"], "game_version_mismatch")
|
||||
self.assertEqual(
|
||||
updated["error"]["required_game_version"], "0.20.2-alpha"
|
||||
updated["error"]["required_game_version"], "0.20.3-alpha"
|
||||
)
|
||||
|
||||
status, updated = self.request(
|
||||
|
|
@ -135,7 +142,7 @@ class DiscoveryHTTPTests(unittest.TestCase):
|
|||
error = body["error"]
|
||||
assert isinstance(error, dict)
|
||||
self.assertEqual(error["code"], "game_version_mismatch")
|
||||
self.assertEqual(error["required_game_version"], "0.20.2-alpha")
|
||||
self.assertEqual(error["required_game_version"], "0.20.3-alpha")
|
||||
self.assertIn("will not be listed", str(error["message"]))
|
||||
|
||||
def test_empty_dedicated_room_can_be_listed(self) -> None:
|
||||
|
|
@ -181,6 +188,15 @@ class DiscoveryHTTPTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(status, 201)
|
||||
assert join is not None
|
||||
self.assertEqual(
|
||||
join["route"],
|
||||
{
|
||||
"transport": "direct",
|
||||
"address": "127.0.0.1",
|
||||
"port": 7777,
|
||||
"ip_privacy": "peer_visible",
|
||||
},
|
||||
)
|
||||
self.server.registry.register_join_endpoint(
|
||||
str(join["join_token"]), "198.51.100.7", 49152
|
||||
)
|
||||
|
|
@ -201,7 +217,7 @@ class DiscoveryHTTPTests(unittest.TestCase):
|
|||
self.assertEqual(status, 200)
|
||||
assert body is not None
|
||||
self.assertEqual(body["status"], "ok")
|
||||
self.assertEqual(body["version"], "0.20.2-alpha")
|
||||
self.assertEqual(body["version"], "0.20.3-alpha")
|
||||
self.assertEqual(body["build_revision"], "test-build")
|
||||
|
||||
def test_live_friend_presence_and_invitation_endpoints(self) -> None:
|
||||
|
|
@ -296,6 +312,178 @@ class DiscoveryHTTPTests(unittest.TestCase):
|
|||
def test_udp_rendezvous_does_not_spawn_per_packet_threads(self) -> None:
|
||||
self.assertFalse(issubclass(TraversalUDPServer, socketserver.ThreadingMixIn))
|
||||
|
||||
def test_http_access_logging_is_completely_disabled(self) -> None:
|
||||
with patch("straywild_discovery.server.LOGGER.info") as log_info:
|
||||
status, _health = self.request("GET", "/health")
|
||||
self.assertEqual(status, 200)
|
||||
log_info.assert_not_called()
|
||||
|
||||
def test_unexpected_http_error_log_contains_no_private_metadata(self) -> None:
|
||||
private_address = "198.51.100.77"
|
||||
with patch("straywild_discovery.server.LOGGER.error") as log_error:
|
||||
self.server.handle_error(object(), (private_address, 49152))
|
||||
log_error.assert_called_once_with(
|
||||
"discovery HTTP request failed without logging request metadata"
|
||||
)
|
||||
self.assertNotIn(private_address, str(log_error.call_args))
|
||||
|
||||
def test_unexpected_udp_error_log_contains_no_private_metadata(self) -> None:
|
||||
private_address = "198.51.100.78"
|
||||
traversal = TraversalUDPServer(
|
||||
ServerConfig(
|
||||
traversal_bind_host="127.0.0.1",
|
||||
traversal_port=0,
|
||||
),
|
||||
self.server.registry,
|
||||
)
|
||||
try:
|
||||
with patch("straywild_discovery.server.LOGGER.error") as log_error:
|
||||
traversal.handle_error(object(), (private_address, 49153))
|
||||
log_error.assert_called_once_with(
|
||||
"discovery traversal request failed without logging metadata"
|
||||
)
|
||||
self.assertNotIn(private_address, str(log_error.call_args))
|
||||
finally:
|
||||
traversal.server_close()
|
||||
|
||||
|
||||
class RelayDiscoveryHTTPTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
config = ServerConfig(
|
||||
bind_host="127.0.0.1",
|
||||
bind_port=0,
|
||||
room_ttl_seconds=30.0,
|
||||
relay_enabled=True,
|
||||
relay_bind_host="127.0.0.1",
|
||||
relay_public_host="127.0.0.1",
|
||||
relay_port_start=0,
|
||||
relay_port_end=0,
|
||||
relay_max_allocations=32,
|
||||
build_revision="relay-test-build",
|
||||
)
|
||||
cls.server = DiscoveryHTTPServer(config)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.port = cls.server.server_address[1]
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.server.shutdown()
|
||||
cls.server.server_close()
|
||||
cls.thread.join(timeout=2.0)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, object] | None = None,
|
||||
) -> tuple[int, dict[str, object] | None]:
|
||||
connection = HTTPConnection("127.0.0.1", self.port, timeout=2.0)
|
||||
headers: dict[str, str] = {}
|
||||
body = None
|
||||
if payload is not None:
|
||||
body = json.dumps(payload)
|
||||
headers["Content-Type"] = "application/json"
|
||||
connection.request(method, path, body=body, headers=headers)
|
||||
response = connection.getresponse()
|
||||
response_body = response.read()
|
||||
connection.close()
|
||||
decoded = json.loads(response_body) if response_body else None
|
||||
return response.status, decoded
|
||||
|
||||
def test_relay_join_never_returns_host_endpoint(self) -> None:
|
||||
status, created = self.request(
|
||||
"POST",
|
||||
"/v1/rooms",
|
||||
dict(VALID_ROOM, connection_mode="relay"),
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
assert created is not None
|
||||
room = created["room"]
|
||||
traversal = created["traversal"]
|
||||
assert isinstance(room, dict)
|
||||
assert isinstance(traversal, dict)
|
||||
room_id = str(room["room_id"])
|
||||
self.server.registry.verify_endpoint(
|
||||
room_id,
|
||||
str(traversal["verification_token"]),
|
||||
"198.51.100.42",
|
||||
45678,
|
||||
)
|
||||
|
||||
status, listing = self.request("GET", "/v1/rooms?protocol_version=12")
|
||||
self.assertEqual(status, 200)
|
||||
assert listing is not None
|
||||
listed_room = next(
|
||||
candidate
|
||||
for candidate in listing["rooms"]
|
||||
if candidate["room_id"] == room_id
|
||||
)
|
||||
self.assertEqual(listed_room["connection_mode"], "relay")
|
||||
self.assertEqual(listed_room["ip_privacy"], "relayed")
|
||||
self.assertNotIn("address", listed_room)
|
||||
self.assertNotIn("port", listed_room)
|
||||
|
||||
status, join = self.request(
|
||||
"POST", f"/v1/rooms/{room_id}/join-attempts"
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
assert join is not None
|
||||
route = join["route"]
|
||||
assert isinstance(route, dict)
|
||||
self.assertEqual(route["transport"], "relay")
|
||||
self.assertEqual(route["ip_privacy"], "relayed")
|
||||
self.assertEqual(route["address"], "127.0.0.1")
|
||||
self.assertNotEqual(route["address"], "198.51.100.42")
|
||||
self.assertNotEqual(route["port"], 45678)
|
||||
|
||||
def test_direct_room_does_not_consume_relay_allocation(self) -> None:
|
||||
status, created = self.request(
|
||||
"POST",
|
||||
"/v1/rooms",
|
||||
dict(VALID_ROOM, room_name="Direct Pond", connection_mode="direct"),
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
assert created is not None
|
||||
room = created["room"]
|
||||
traversal = created["traversal"]
|
||||
assert isinstance(room, dict)
|
||||
assert isinstance(traversal, dict)
|
||||
room_id = str(room["room_id"])
|
||||
self.server.registry.verify_endpoint(
|
||||
room_id,
|
||||
str(traversal["verification_token"]),
|
||||
"198.51.100.43",
|
||||
45679,
|
||||
)
|
||||
allocations_before = self.server.relay_service.allocation_count()
|
||||
status, join = self.request(
|
||||
"POST", f"/v1/rooms/{room_id}/join-attempts"
|
||||
)
|
||||
self.assertEqual(status, 201)
|
||||
assert join is not None
|
||||
self.assertEqual(join["route"]["transport"], "direct")
|
||||
self.assertEqual(join["route"]["address"], "198.51.100.43")
|
||||
self.assertEqual(
|
||||
self.server.relay_service.allocation_count(),
|
||||
allocations_before,
|
||||
)
|
||||
|
||||
def test_dedicated_relay_advertisement_is_rejected(self) -> None:
|
||||
status, response = self.request(
|
||||
"POST",
|
||||
"/v1/rooms",
|
||||
dict(
|
||||
VALID_ROOM,
|
||||
host_kind="dedicated",
|
||||
connection_mode="relay",
|
||||
),
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
assert response is not None
|
||||
self.assertEqual(response["error"]["code"], "invalid_room")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ VALID_ROOM = {
|
|||
"port": 7777,
|
||||
"current_players": 1,
|
||||
"max_players": 8,
|
||||
"game_version": "0.20.2-alpha",
|
||||
"game_version": "0.20.3-alpha",
|
||||
"protocol_version": 12,
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +54,103 @@ class RoomRegistryTests(unittest.TestCase):
|
|||
self.registry.delete(room.room_id, token)
|
||||
self.assertEqual(self.registry.list_rooms(), [])
|
||||
|
||||
def test_public_room_shape_never_discloses_endpoint(self) -> None:
|
||||
room, _token, verification_token = self.registry.create(
|
||||
"203.0.113.10", VALID_ROOM
|
||||
)
|
||||
room = self.registry.verify_endpoint(
|
||||
room.room_id, verification_token, "203.0.113.10", 49152
|
||||
)
|
||||
public = room.public_dict()
|
||||
self.assertNotIn("address", public)
|
||||
self.assertNotIn("port", public)
|
||||
self.assertEqual(public["connection_mode"], "direct")
|
||||
self.assertEqual(public["ip_privacy"], "peer_visible")
|
||||
self.assertEqual(
|
||||
room.join_route_dict(),
|
||||
{
|
||||
"transport": "direct",
|
||||
"address": "203.0.113.10",
|
||||
"port": 49152,
|
||||
"ip_privacy": "peer_visible",
|
||||
},
|
||||
)
|
||||
|
||||
def test_relay_room_shape_advertises_privacy_without_endpoint(self) -> None:
|
||||
registry = RoomRegistry(
|
||||
ttl_seconds=30.0,
|
||||
relay_available=True,
|
||||
clock=self.clock,
|
||||
)
|
||||
room, _token, verification_token = registry.create(
|
||||
"203.0.113.10", dict(VALID_ROOM, connection_mode="relay")
|
||||
)
|
||||
room = registry.verify_endpoint(
|
||||
room.room_id, verification_token, "203.0.113.10", 49152
|
||||
)
|
||||
public = room.public_dict()
|
||||
self.assertEqual(public["connection_mode"], "relay")
|
||||
self.assertEqual(public["ip_privacy"], "relayed")
|
||||
self.assertNotIn("address", public)
|
||||
self.assertNotIn("port", public)
|
||||
|
||||
def test_player_can_choose_direct_when_relay_is_available(self) -> None:
|
||||
registry = RoomRegistry(
|
||||
ttl_seconds=30.0,
|
||||
relay_available=True,
|
||||
clock=self.clock,
|
||||
)
|
||||
room, _token, _verification_token = registry.create(
|
||||
"203.0.113.10", dict(VALID_ROOM, connection_mode="direct")
|
||||
)
|
||||
self.assertEqual(room.connection_mode, "direct")
|
||||
|
||||
def test_dedicated_server_cannot_request_relay(self) -> None:
|
||||
registry = RoomRegistry(
|
||||
ttl_seconds=30.0,
|
||||
relay_available=True,
|
||||
clock=self.clock,
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
ValidationError,
|
||||
"dedicated servers cannot use the privacy relay",
|
||||
):
|
||||
registry.create(
|
||||
"203.0.113.10",
|
||||
dict(
|
||||
VALID_ROOM,
|
||||
host_kind="dedicated",
|
||||
connection_mode="relay",
|
||||
),
|
||||
)
|
||||
|
||||
def test_relay_request_fails_when_service_has_no_relay(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
ValidationError,
|
||||
"privacy relay is not available",
|
||||
):
|
||||
self.registry.create(
|
||||
"203.0.113.10",
|
||||
dict(VALID_ROOM, connection_mode="relay"),
|
||||
)
|
||||
|
||||
def test_join_attempts_are_rate_limited_per_requester(self) -> None:
|
||||
registry = RoomRegistry(ttl_seconds=300.0, clock=self.clock)
|
||||
room, _token, verification_token = registry.create(
|
||||
"203.0.113.10", VALID_ROOM
|
||||
)
|
||||
room = registry.verify_endpoint(
|
||||
room.room_id, verification_token, "203.0.113.10", 7777
|
||||
)
|
||||
for _attempt in range(10):
|
||||
token, routed_room = registry.create_join_attempt(
|
||||
room.room_id, "198.51.100.20"
|
||||
)
|
||||
self.assertTrue(token)
|
||||
self.assertEqual(routed_room, room)
|
||||
with self.assertRaises(RoomLimitError):
|
||||
registry.create_join_attempt(room.room_id, "198.51.100.20")
|
||||
|
||||
def test_empty_dedicated_room_is_valid(self) -> None:
|
||||
room, _token, verification_token = self.registry.create(
|
||||
"203.0.113.10", dict(VALID_ROOM, current_players=0)
|
||||
|
|
|
|||
79
tests/test_relay.py
Normal file
79
tests/test_relay.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import unittest
|
||||
|
||||
from straywild_discovery.relay import (
|
||||
RELAY_AUTH_PREFIX,
|
||||
RelayConfig,
|
||||
RelayService,
|
||||
)
|
||||
|
||||
|
||||
class RelayServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.host = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.host.bind(("127.0.0.1", 0))
|
||||
self.host.settimeout(0.35)
|
||||
self.client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.client.bind(("127.0.0.1", 0))
|
||||
self.client.settimeout(1.0)
|
||||
self.relay = RelayService(RelayConfig(
|
||||
bind_host="127.0.0.1",
|
||||
public_host="127.0.0.1",
|
||||
port_start=0,
|
||||
port_end=0,
|
||||
max_allocations=4,
|
||||
))
|
||||
self.relay.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.relay.close()
|
||||
self.client.close()
|
||||
self.host.close()
|
||||
|
||||
def test_authenticated_allocation_masks_both_peer_endpoints(self) -> None:
|
||||
token = "join-capability"
|
||||
route = self.relay.allocate(
|
||||
token,
|
||||
"127.0.0.1",
|
||||
int(self.host.getsockname()[1]),
|
||||
)
|
||||
relay_endpoint = (route.host, route.port)
|
||||
|
||||
self.client.sendto(b"not authenticated", relay_endpoint)
|
||||
with self.assertRaises(TimeoutError):
|
||||
self.host.recvfrom(4096)
|
||||
|
||||
self.client.sendto(RELAY_AUTH_PREFIX + token.encode(), relay_endpoint)
|
||||
self.client.sendto(b"client gameplay", relay_endpoint)
|
||||
packet, relay_as_seen_by_host = self.host.recvfrom(4096)
|
||||
self.assertEqual(packet, b"client gameplay")
|
||||
self.assertEqual(relay_as_seen_by_host[1], route.port)
|
||||
self.assertNotEqual(
|
||||
relay_as_seen_by_host[1], int(self.client.getsockname()[1])
|
||||
)
|
||||
|
||||
self.host.sendto(b"host gameplay", relay_as_seen_by_host)
|
||||
packet, relay_as_seen_by_client = self.client.recvfrom(4096)
|
||||
self.assertEqual(packet, b"host gameplay")
|
||||
self.assertEqual(relay_as_seen_by_client, relay_endpoint)
|
||||
self.assertNotEqual(
|
||||
relay_as_seen_by_client[1], int(self.host.getsockname()[1])
|
||||
)
|
||||
|
||||
def test_wrong_capability_cannot_claim_allocation(self) -> None:
|
||||
route = self.relay.allocate(
|
||||
"right-token",
|
||||
"127.0.0.1",
|
||||
int(self.host.getsockname()[1]),
|
||||
)
|
||||
relay_endpoint = (route.host, route.port)
|
||||
self.client.sendto(RELAY_AUTH_PREFIX + b"wrong-token", relay_endpoint)
|
||||
self.client.sendto(b"blocked gameplay", relay_endpoint)
|
||||
with self.assertRaises(TimeoutError):
|
||||
self.host.recvfrom(4096)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -22,6 +22,7 @@ class ServerConfigCompatibilityTests(unittest.TestCase):
|
|||
self.assertEqual(config.bind_host, "127.0.0.9")
|
||||
self.assertEqual(config.bind_port, 7791)
|
||||
self.assertEqual(config.build_revision, "legacy-config")
|
||||
self.assertTrue(config.relay_enabled)
|
||||
|
||||
def test_straywild_environment_wins_over_legacy_names(self) -> None:
|
||||
with patch.dict(
|
||||
|
|
@ -38,6 +39,15 @@ class ServerConfigCompatibilityTests(unittest.TestCase):
|
|||
self.assertEqual(config.bind_host, "127.0.0.8")
|
||||
self.assertEqual(config.bind_port, 7792)
|
||||
|
||||
def test_relay_can_be_explicitly_disabled_without_fallback_ambiguity(self) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"straywild_DISCOVERY_RELAY_ENABLED": "false"},
|
||||
clear=True,
|
||||
):
|
||||
config = ServerConfig.from_environment()
|
||||
self.assertFalse(config.relay_enabled)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from straywild_discovery.social_registry import (
|
|||
)
|
||||
|
||||
|
||||
GAME_VERSION = "0.20.2-alpha"
|
||||
GAME_VERSION = "0.20.3-alpha"
|
||||
PROTOCOL_VERSION = 12
|
||||
VALID_ROOM = {
|
||||
"room_name": "Pond Friends",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue