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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue