"""Thread-safe ephemeral room leases for the NETfishing directory.""" from __future__ import annotations from dataclasses import dataclass 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 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 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), } @dataclass(slots=True) class _RoomLease: advertisement: RoomAdvertisement token: str 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._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]: now = self._clock() room_id = str(uuid.uuid4()) 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) return advertisement, 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, address, payload, lease.advertisement.created_at, now, ) 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 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 _build_advertisement( self, room_id: str, address: str, payload: Mapping[str, Any], created_at: float, now: float, ) -> RoomAdvertisement: room_name = _clean_text(payload.get("room_name"), "room_name", ROOM_NAME_MAX_LENGTH) port = _clean_int(payload.get("port"), "port", 1, 65_535) 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, ) 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]