"""Thread-safe ephemeral room leases for the NETfishing directory.""" from __future__ import annotations from dataclasses import dataclass, replace import hmac import secrets import threading import time import unicodedata import uuid from collections.abc import Callable, Mapping from typing import Any ROOM_NAME_MAX_LENGTH = 48 VERSION_MAX_LENGTH = 32 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 class RegistryError(Exception): """Base class for errors safe to translate into API responses.""" class ValidationError(RegistryError): pass class RoomNotFoundError(RegistryError): pass class LeaseAuthorizationError(RegistryError): pass class RoomLimitError(RegistryError): pass @dataclass(frozen=True, slots=True) class RoomAdvertisement: room_id: str room_name: str address: str port: int current_players: int max_players: int game_version: str protocol_version: int created_at: float updated_at: float expires_at: float verified: bool = False def public_dict(self) -> dict[str, Any]: 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, "created_at": _iso_utc(self.created_at), "updated_at": _iso_utc(self.updated_at), "expires_at": _iso_utc(self.expires_at), "verified": self.verified, } @dataclass(slots=True) class _RoomLease: advertisement: RoomAdvertisement token: str verification_token: str @dataclass(slots=True) class _JoinAttempt: room_id: str expires_at: float address: str = "" port: int = 0 def _iso_utc(timestamp: float) -> str: from datetime import datetime, timezone return datetime.fromtimestamp(timestamp, timezone.utc).isoformat().replace("+00:00", "Z") def _clean_text(value: Any, field_name: str, max_length: int) -> str: if not isinstance(value, str): raise ValidationError(f"{field_name} must be a string") normalized = unicodedata.normalize("NFC", value).strip() if not normalized: raise ValidationError(f"{field_name} cannot be empty") if len(normalized) > max_length: raise ValidationError(f"{field_name} cannot exceed {max_length} characters") if any(unicodedata.category(character).startswith("C") for character in normalized): raise ValidationError(f"{field_name} contains unsupported control characters") return normalized def _clean_int(value: Any, field_name: str, minimum: int, maximum: int) -> int: if isinstance(value, bool) or not isinstance(value, int): raise ValidationError(f"{field_name} must be an integer") if value < minimum or value > maximum: raise ValidationError(f"{field_name} must be between {minimum} and {maximum}") return value class RoomRegistry: """Stores short-lived room advertisements; no state survives a restart.""" def __init__( self, ttl_seconds: float = 45.0, max_rooms: int = DEFAULT_MAX_ROOMS, max_rooms_per_address: int = DEFAULT_MAX_ROOMS_PER_ADDRESS, clock: Callable[[], float] = time.time, ) -> None: if ttl_seconds <= 0.0: raise ValueError("ttl_seconds must be positive") if max_rooms <= 0 or max_rooms_per_address <= 0: raise ValueError("room limits must be positive") self._ttl_seconds = float(ttl_seconds) self._max_rooms = int(max_rooms) self._max_rooms_per_address = int(max_rooms_per_address) self._clock = clock self._rooms: dict[str, _RoomLease] = {} self._join_attempts: dict[str, _JoinAttempt] = {} self._lock = threading.RLock() @property def ttl_seconds(self) -> float: return self._ttl_seconds def create( self, address: str, payload: Mapping[str, Any] ) -> tuple[RoomAdvertisement, str, str]: now = self._clock() room_id = str(uuid.uuid4()) token = secrets.token_urlsafe(32) verification_token = secrets.token_urlsafe(32) advertisement = self._build_advertisement(room_id, address, payload, now, now) with self._lock: self._purge_locked(now) if len(self._rooms) >= self._max_rooms: raise RoomLimitError("the public room directory is at capacity") address_room_count = sum( 1 for lease in self._rooms.values() if lease.advertisement.address == address ) if address_room_count >= self._max_rooms_per_address: raise RoomLimitError("too many active rooms from this address") self._rooms[room_id] = _RoomLease( advertisement, token, verification_token ) return advertisement, token, verification_token def update( self, room_id: str, token: str, address: str, payload: Mapping[str, Any], ) -> RoomAdvertisement: now = self._clock() with self._lock: self._purge_locked(now) lease = self._authorized_lease_locked(room_id, token) advertisement = self._build_advertisement( room_id, lease.advertisement.address if lease.advertisement.verified else address, payload, lease.advertisement.created_at, now, endpoint_port=( lease.advertisement.port if lease.advertisement.verified else None ), verified=lease.advertisement.verified, ) lease.advertisement = advertisement return advertisement def delete(self, room_id: str, token: str) -> None: now = self._clock() with self._lock: self._purge_locked(now) self._authorized_lease_locked(room_id, token) del self._rooms[room_id] def list_rooms( self, *, game_version: str | None = None, protocol_version: int | None = None, ) -> list[RoomAdvertisement]: now = self._clock() with self._lock: self._purge_locked(now) rooms = [ lease.advertisement for lease in self._rooms.values() if lease.advertisement.verified ] if game_version is not None: rooms = [room for room in rooms if room.game_version == game_version] if protocol_version is not None: rooms = [room for room in rooms if room.protocol_version == protocol_version] return sorted(rooms, key=lambda room: (-room.updated_at, room.room_name.casefold())) def room_count(self) -> int: now = self._clock() with self._lock: self._purge_locked(now) return len(self._rooms) def verify_endpoint( self, room_id: str, verification_token: str, address: str, port: int, ) -> RoomAdvertisement: if not address or port < 1 or port > 65_535: raise ValidationError("invalid observed UDP endpoint") now = self._clock() with self._lock: self._purge_locked(now) lease = self._rooms.get(room_id) if lease is None: raise RoomNotFoundError("room does not exist or its lease expired") if not hmac.compare_digest( lease.verification_token, verification_token ): raise LeaseAuthorizationError("invalid endpoint verification token") lease.advertisement = replace( lease.advertisement, address=address, port=port, updated_at=now, expires_at=now + self._ttl_seconds, verified=True, ) return lease.advertisement def create_join_attempt(self, room_id: str) -> str: 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") 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: 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 def register_join_endpoint( self, token: str, address: str, port: int ) -> None: if not address or port < 1 or port > 65_535: raise ValidationError("invalid observed join endpoint") now = self._clock() with self._lock: self._purge_locked(now) attempt = self._join_attempts.get(token) if attempt is None: raise RoomNotFoundError("join attempt does not exist or expired") attempt.address = address attempt.port = port def consume_join_endpoints( self, room_id: str, lease_token: str ) -> list[dict[str, Any]]: now = self._clock() with self._lock: self._purge_locked(now) self._authorized_lease_locked(room_id, lease_token) consumed_tokens = [ token for token, attempt in self._join_attempts.items() if attempt.room_id == room_id and attempt.address and attempt.port > 0 ] endpoints = [ { "address": self._join_attempts[token].address, "port": self._join_attempts[token].port, } for token in consumed_tokens ] for token in consumed_tokens: del self._join_attempts[token] return endpoints def _build_advertisement( self, room_id: str, address: str, payload: Mapping[str, Any], created_at: float, now: float, endpoint_port: int | None = None, verified: bool = False, ) -> RoomAdvertisement: room_name = _clean_text(payload.get("room_name"), "room_name", ROOM_NAME_MAX_LENGTH) supplied_port = _clean_int(payload.get("port"), "port", 1, 65_535) port = endpoint_port if endpoint_port is not None else supplied_port current_players = _clean_int( payload.get("current_players"), "current_players", 0, MAX_ROOM_CAPACITY ) max_players = _clean_int( payload.get("max_players"), "max_players", 1, MAX_ROOM_CAPACITY ) if current_players > max_players: raise ValidationError("current_players cannot exceed max_players") game_version = _clean_text( payload.get("game_version"), "game_version", VERSION_MAX_LENGTH ) protocol_version = _clean_int( payload.get("protocol_version"), "protocol_version", 1, 2_147_483_647 ) return RoomAdvertisement( room_id=room_id, room_name=room_name, address=address, port=port, current_players=current_players, max_players=max_players, game_version=game_version, protocol_version=protocol_version, created_at=created_at, updated_at=now, expires_at=now + self._ttl_seconds, verified=verified, ) def _authorized_lease_locked(self, room_id: str, token: str) -> _RoomLease: lease = self._rooms.get(room_id) if lease is None: raise RoomNotFoundError("room does not exist or its lease expired") if not token or not hmac.compare_digest(lease.token, token): raise LeaseAuthorizationError("invalid room lease token") return lease def _purge_locked(self, now: float) -> None: expired = [ room_id for room_id, lease in self._rooms.items() if lease.advertisement.expires_at <= now ] for room_id in expired: del self._rooms[room_id] expired_attempts = [ token for token, attempt in self._join_attempts.items() if attempt.expires_at <= now or attempt.room_id not in self._rooms ] for token in expired_attempts: del self._join_attempts[token]