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

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