netfishing-discovery-server/netfishing_discovery/registry.py

253 lines
8.3 KiB
Python

"""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]