From b5b3cc02115615fb7fb2edb7bb890bcfb1a8a143 Mon Sep 17 00:00:00 2001 From: Voyager Date: Mon, 10 Aug 2026 10:19:57 -0400 Subject: [PATCH] Add NETfishing discovery service --- .gitignore | 9 + README.md | 77 ++++++ deploy/netfishing-discovery.env.example | 12 + deploy/netfishing-discovery.service | 27 ++ docs/API.md | 76 ++++++ netfishing_discovery/__init__.py | 3 + netfishing_discovery/__main__.py | 5 + netfishing_discovery/registry.py | 253 ++++++++++++++++++ netfishing_discovery/server.py | 336 ++++++++++++++++++++++++ pyproject.toml | 16 ++ tests/__init__.py | 1 + tests/test_http_api.py | 103 ++++++++ tests/test_registry.py | 95 +++++++ 13 files changed, 1013 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deploy/netfishing-discovery.env.example create mode 100644 deploy/netfishing-discovery.service create mode 100644 docs/API.md create mode 100644 netfishing_discovery/__init__.py create mode 100644 netfishing_discovery/__main__.py create mode 100644 netfishing_discovery/registry.py create mode 100644 netfishing_discovery/server.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_http_api.py create mode 100644 tests/test_registry.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1714ad3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +.coverage +.pytest_cache/ +.venv/ +dist/ +build/ +*.egg-info/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..f89045d --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# NETfishing Discovery Server + +An ephemeral public-room directory for NETfishing. It complements the game's existing direct ENet +transport: hosts advertise a room through HTTPS, browsers retrieve compatible rooms, and joining +still uses the authoritative host's UDP endpoint. + +This first phase intentionally does not use coturn. TURN relays WebRTC traffic; NETfishing currently +uses ENet/UDP, so NAT traversal and relay transport are a separate future phase. + +## Requirements + +- Python 3.11 or newer +- A TLS reverse proxy for public deployment + +There are no runtime Python package dependencies. + +## Run locally + +```bash +python3 -m netfishing_discovery +``` + +The default listener is `127.0.0.1:7770`. + +```bash +curl http://127.0.0.1:7770/health +``` + +Run the focused tests with: + +```bash +python3 -m unittest discover -v +``` + +## Configuration + +Environment variables are documented in +[`deploy/netfishing-discovery.env.example`](deploy/netfishing-discovery.env.example). + +The intended public topology is: + +```text +NETfishing client -> HTTPS reverse proxy -> 127.0.0.1:7770 discovery service +NETfishing client -----------------------> advertised ENet/UDP host:port +``` + +Configure the Godot client with the reverse proxy's HTTPS origin through +`network/discovery/base_url` in `project.godot`. For local development, the +same value can be overridden without modifying the project by setting +`NETFISHING_DISCOVERY_URL` before launching the game. + +The reverse proxy must preserve the client address in `X-Forwarded-For`, and +its own address must be listed in `NETFISHING_DISCOVERY_TRUSTED_PROXY_CIDRS`. +Apply ordinary request-rate limits to the write endpoints at the proxy. Do not +expose the Python listener directly to the public Internet. + +Use the sample systemd unit in [`deploy/netfishing-discovery.service`](deploy/netfishing-discovery.service) +as a deployment starting point. The service keeps only active leases in memory, so it requires no +database, backups, or schema migrations. + +## Connectivity boundary + +Discovery makes a room findable; it does not make an unreachable ENet host +reachable. A host still needs its advertised UDP port forwarded/reachable from +the Internet. The existing coturn service cannot relay ENet packets because +TURN is a WebRTC transport component. NAT traversal or relay support therefore +remains a separate transport project and is not hidden inside the directory. + +## API + +See [`docs/API.md`](docs/API.md) for the versioned HTTP contract. + +## Repository relationship + +This is deliberately a separate repository from the Godot game. The game and service can version, +deploy, and roll back independently. The discovery API version is independent from NETfishing's +save schema and gameplay network protocol. diff --git a/deploy/netfishing-discovery.env.example b/deploy/netfishing-discovery.env.example new file mode 100644 index 0000000..16999a8 --- /dev/null +++ b/deploy/netfishing-discovery.env.example @@ -0,0 +1,12 @@ +# Bind locally and place a TLS reverse proxy in front of the service. +NETFISHING_DISCOVERY_HOST=127.0.0.1 +NETFISHING_DISCOVERY_PORT=7770 +NETFISHING_DISCOVERY_ROOM_TTL=45 +NETFISHING_DISCOVERY_MAX_ROOMS=4096 +NETFISHING_DISCOVERY_MAX_ROOMS_PER_ADDRESS=32 +NETFISHING_DISCOVERY_MAX_BODY_BYTES=8192 +NETFISHING_DISCOVERY_LOG_LEVEL=INFO + +# Only trust X-Forwarded-For when the immediate peer is within one of these CIDRs. +# The loopback values suit a reverse proxy on the same host. +NETFISHING_DISCOVERY_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128 diff --git a/deploy/netfishing-discovery.service b/deploy/netfishing-discovery.service new file mode 100644 index 0000000..c79e59c --- /dev/null +++ b/deploy/netfishing-discovery.service @@ -0,0 +1,27 @@ +[Unit] +Description=NETfishing public room discovery service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=netfishing-discovery +Group=netfishing-discovery +WorkingDirectory=/var/www/netfishing.org.discovery +EnvironmentFile=/etc/netfishing-discovery.env +ExecStart=/usr/bin/python3 -m netfishing_discovery +Restart=on-failure +RestartSec=2 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..3dd2255 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,76 @@ +# Discovery API v1 + +The API stores public room advertisements as short-lived leases. It does not proxy or relay game +traffic. NETfishing continues to connect to the returned host and UDP port through ENet. + +All request and response bodies use `application/json`. Production clients must use HTTPS. + +## `GET /health` + +Returns service status and the number of active, unexpired room leases. + +## `GET /v1/rooms` + +Lists active rooms. Optional exact-match filters: + +- `game_version` +- `protocol_version` + +The response contains `rooms` and the server's `ttl_seconds`. + +## `POST /v1/rooms` + +Creates a room lease. The service derives the advertised address from the connection rather than +trusting an address supplied by the game client. + +```json +{ + "room_name": "Pond Friends", + "port": 7777, + "current_players": 1, + "max_players": 8, + "game_version": "0.6.4-alpha", + "protocol_version": 3 +} +``` + +The `201` response includes the public `room` and a secret `lease_token`. The host retains that +token only for the current hosting session. + +The service applies configured global and per-observed-address active-room limits. Exceeding one +returns `429 room_limit`; expired leases stop counting automatically. + +## `PUT /v1/rooms/{room_id}` + +Renews and updates a room lease. Send the complete room payload and: + +```text +Authorization: Bearer +``` + +Hosts should heartbeat well before the configured TTL, initially every 15 seconds for a 45-second +lease. A missing heartbeat causes automatic removal without requiring a disconnect callback. + +## `DELETE /v1/rooms/{room_id}` + +Removes a room immediately. Requires the same bearer token. A clean host shutdown should call this, +but TTL expiry remains authoritative for crashes and lost connectivity. + +## Error shape + +```json +{ + "error": { + "code": "invalid_room", + "message": "port must be between 1 and 65535" + } +} +``` + +## Trust boundary + +- The observed source IP is authoritative for a room's public address. +- `X-Forwarded-For` is honored only when the immediate peer belongs to an explicitly configured + trusted proxy CIDR. +- Lease tokens authorize update and deletion but are never included in public listings. +- The service is an ephemeral directory, not a source of gameplay authority. diff --git a/netfishing_discovery/__init__.py b/netfishing_discovery/__init__.py new file mode 100644 index 0000000..fb1da0b --- /dev/null +++ b/netfishing_discovery/__init__.py @@ -0,0 +1,3 @@ +"""NETfishing discovery service.""" + +__version__ = "0.1.0" diff --git a/netfishing_discovery/__main__.py b/netfishing_discovery/__main__.py new file mode 100644 index 0000000..5b9eec7 --- /dev/null +++ b/netfishing_discovery/__main__.py @@ -0,0 +1,5 @@ +from .server import main + + +if __name__ == "__main__": + main() diff --git a/netfishing_discovery/registry.py b/netfishing_discovery/registry.py new file mode 100644 index 0000000..cec54dd --- /dev/null +++ b/netfishing_discovery/registry.py @@ -0,0 +1,253 @@ +"""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", 1, 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] diff --git a/netfishing_discovery/server.py b/netfishing_discovery/server.py new file mode 100644 index 0000000..fb4ad60 --- /dev/null +++ b/netfishing_discovery/server.py @@ -0,0 +1,336 @@ +"""Small HTTP/JSON API for NETfishing public-room discovery.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import ipaddress +import json +import logging +import os +import signal +import threading +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from socketserver import BaseServer +from typing import Any +from urllib.parse import parse_qs, urlsplit + +from . import __version__ +from .registry import ( + DEFAULT_MAX_ROOMS, + DEFAULT_MAX_ROOMS_PER_ADDRESS, + LeaseAuthorizationError, + RoomLimitError, + RoomNotFoundError, + RoomRegistry, + ValidationError, +) + + +LOGGER = logging.getLogger("netfishing.discovery") +DEFAULT_MAX_BODY_BYTES = 8 * 1024 + + +@dataclass(frozen=True, slots=True) +class ServerConfig: + bind_host: str = "127.0.0.1" + bind_port: int = 7770 + room_ttl_seconds: float = 45.0 + max_rooms: int = DEFAULT_MAX_ROOMS + max_rooms_per_address: int = DEFAULT_MAX_ROOMS_PER_ADDRESS + max_body_bytes: int = DEFAULT_MAX_BODY_BYTES + trusted_proxy_cidrs: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = () + + @classmethod + def from_environment(cls) -> "ServerConfig": + proxy_cidrs: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [] + raw_cidrs = os.getenv("NETFISHING_DISCOVERY_TRUSTED_PROXY_CIDRS", "") + for value in raw_cidrs.split(","): + value = value.strip() + if value: + proxy_cidrs.append(ipaddress.ip_network(value, strict=False)) + return cls( + bind_host=os.getenv("NETFISHING_DISCOVERY_HOST", "127.0.0.1"), + bind_port=int(os.getenv("NETFISHING_DISCOVERY_PORT", "7770")), + room_ttl_seconds=float(os.getenv("NETFISHING_DISCOVERY_ROOM_TTL", "45")), + max_rooms=int( + os.getenv("NETFISHING_DISCOVERY_MAX_ROOMS", str(DEFAULT_MAX_ROOMS)) + ), + max_rooms_per_address=int( + os.getenv( + "NETFISHING_DISCOVERY_MAX_ROOMS_PER_ADDRESS", + str(DEFAULT_MAX_ROOMS_PER_ADDRESS), + ) + ), + max_body_bytes=int( + os.getenv("NETFISHING_DISCOVERY_MAX_BODY_BYTES", str(DEFAULT_MAX_BODY_BYTES)) + ), + trusted_proxy_cidrs=tuple(proxy_cidrs), + ) + + +class DiscoveryHTTPServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, config: ServerConfig, registry: RoomRegistry | None = None) -> None: + self.config = config + self.registry = registry or RoomRegistry( + config.room_ttl_seconds, + config.max_rooms, + config.max_rooms_per_address, + ) + super().__init__((config.bind_host, config.bind_port), DiscoveryRequestHandler) + + +class DiscoveryRequestHandler(BaseHTTPRequestHandler): + server: DiscoveryHTTPServer + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: + route = urlsplit(self.path) + if route.path == "/health": + self._send_json( + HTTPStatus.OK, + { + "status": "ok", + "service": "netfishing-discovery-server", + "version": __version__, + "active_rooms": self.server.registry.room_count(), + }, + ) + return + if route.path == "/v1/rooms": + try: + filters = parse_qs(route.query, keep_blank_values=False) + game_version = self._single_query_value(filters, "game_version") + raw_protocol = self._single_query_value(filters, "protocol_version") + protocol_version = int(raw_protocol) if raw_protocol is not None else None + if protocol_version is not None and protocol_version <= 0: + raise ValueError + except ValueError: + self._send_error(HTTPStatus.BAD_REQUEST, "invalid_query", "invalid room filter") + return + rooms = self.server.registry.list_rooms( + game_version=game_version, + protocol_version=protocol_version, + ) + self._send_json( + HTTPStatus.OK, + { + "rooms": [room.public_dict() for room in rooms], + "ttl_seconds": self.server.registry.ttl_seconds, + }, + ) + return + self._send_error(HTTPStatus.NOT_FOUND, "not_found", "route not found") + + def do_POST(self) -> None: + if urlsplit(self.path).path != "/v1/rooms": + self._send_error(HTTPStatus.NOT_FOUND, "not_found", "route not found") + return + payload = self._read_json_object() + if payload is None: + return + try: + room, token = self.server.registry.create(self._client_address(), payload) + except ValidationError as error: + self._send_error(HTTPStatus.BAD_REQUEST, "invalid_room", str(error)) + return + except RoomLimitError as error: + self._send_error(HTTPStatus.TOO_MANY_REQUESTS, "room_limit", str(error)) + return + self._send_json( + HTTPStatus.CREATED, + {"room": room.public_dict(), "lease_token": token}, + ) + + def do_PUT(self) -> None: + room_id = self._room_id_from_path() + if room_id is None: + self._send_error(HTTPStatus.NOT_FOUND, "not_found", "route not found") + return + payload = self._read_json_object() + if payload is None: + return + try: + room = self.server.registry.update( + room_id, + self._bearer_token(), + self._client_address(), + payload, + ) + except ValidationError as error: + self._send_error(HTTPStatus.BAD_REQUEST, "invalid_room", str(error)) + return + except LeaseAuthorizationError as error: + self._send_error(HTTPStatus.UNAUTHORIZED, "invalid_lease", str(error)) + return + except RoomNotFoundError as error: + self._send_error(HTTPStatus.NOT_FOUND, "room_not_found", str(error)) + return + self._send_json(HTTPStatus.OK, {"room": room.public_dict()}) + + def do_DELETE(self) -> None: + room_id = self._room_id_from_path() + if room_id is None: + self._send_error(HTTPStatus.NOT_FOUND, "not_found", "route not found") + return + try: + self.server.registry.delete(room_id, self._bearer_token()) + except LeaseAuthorizationError as error: + self._send_error(HTTPStatus.UNAUTHORIZED, "invalid_lease", str(error)) + return + except RoomNotFoundError as error: + self._send_error(HTTPStatus.NOT_FOUND, "room_not_found", str(error)) + return + self.send_response(HTTPStatus.NO_CONTENT) + 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 _read_json_object(self) -> dict[str, Any] | None: + content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() + if content_type != "application/json": + self._send_error( + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + "unsupported_media_type", + "Content-Type must be application/json", + ) + return None + try: + content_length = int(self.headers.get("Content-Length", "")) + except ValueError: + content_length = -1 + if content_length < 0: + self._send_error( + HTTPStatus.LENGTH_REQUIRED, + "length_required", + "Content-Length required", + ) + return None + if content_length > self.server.config.max_body_bytes: + self._send_error( + HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + "body_too_large", + "request too large", + ) + return None + try: + payload = json.loads(self.rfile.read(content_length)) + except (json.JSONDecodeError, UnicodeDecodeError): + self._send_error(HTTPStatus.BAD_REQUEST, "invalid_json", "invalid JSON body") + return None + if not isinstance(payload, dict): + self._send_error(HTTPStatus.BAD_REQUEST, "invalid_json", "JSON body must be an object") + return None + return payload + + def _client_address(self) -> str: + peer_text = self.client_address[0] + try: + peer = ipaddress.ip_address(peer_text) + except ValueError: + return peer_text + if not any(peer in network for network in self.server.config.trusted_proxy_cidrs): + return peer_text + forwarded = self.headers.get("X-Forwarded-For", "").split(",", 1)[0].strip() + try: + return str(ipaddress.ip_address(forwarded)) + except ValueError: + return peer_text + + def _room_id_from_path(self) -> str | None: + path = urlsplit(self.path).path + prefix = "/v1/rooms/" + room_id = path[len(prefix) :] if path.startswith(prefix) else "" + if not room_id or "/" in room_id: + return None + return room_id + + def _bearer_token(self) -> str: + value = self.headers.get("Authorization", "") + scheme, separator, token = value.partition(" ") + if separator and scheme.lower() == "bearer": + return token.strip() + return "" + + @staticmethod + def _single_query_value(filters: dict[str, list[str]], key: str) -> str | None: + values = filters.get(key) + if not values: + return None + if len(values) != 1: + raise ValueError + return values[0] + + def _send_error(self, status: HTTPStatus, code: str, message: str) -> None: + self._send_json(status, {"error": {"code": code, "message": message}}) + + def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(body) + + +def _install_signal_handlers(server: BaseServer) -> None: + def stop_server(_signum: int, _frame: object) -> None: + LOGGER.info("shutdown requested") + # BaseServer.shutdown() must run on a different thread from serve_forever(). + threading.Thread(target=server.shutdown, name="discovery-shutdown", daemon=True).start() + + signal.signal(signal.SIGINT, stop_server) + signal.signal(signal.SIGTERM, stop_server) + + +def _argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", help="bind address (overrides environment)") + parser.add_argument("--port", type=int, help="bind port (overrides environment)") + parser.add_argument("--log-level", default=os.getenv("NETFISHING_DISCOVERY_LOG_LEVEL", "INFO")) + return parser + + +def main() -> None: + arguments = _argument_parser().parse_args() + config = ServerConfig.from_environment() + if arguments.host is not None or arguments.port is not None: + config = ServerConfig( + bind_host=arguments.host or config.bind_host, + bind_port=arguments.port or config.bind_port, + room_ttl_seconds=config.room_ttl_seconds, + max_rooms=config.max_rooms, + max_rooms_per_address=config.max_rooms_per_address, + max_body_bytes=config.max_body_bytes, + trusted_proxy_cidrs=config.trusted_proxy_cidrs, + ) + logging.basicConfig( + level=getattr(logging, arguments.log_level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + server = DiscoveryHTTPServer(config) + _install_signal_handlers(server) + LOGGER.info( + "NETfishing discovery server %s listening on %s:%d (room TTL %.1fs)", + __version__, + config.bind_host, + server.server_address[1], + config.room_ttl_seconds, + ) + try: + server.serve_forever(poll_interval=0.25) + finally: + server.server_close() + LOGGER.info("server stopped") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1261e4c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "netfishing-discovery-server" +version = "0.1.0" +description = "Ephemeral public-room directory for NETfishing" +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +netfishing-discovery-server = "netfishing_discovery.server:main" + +[tool.setuptools.packages.find] +include = ["netfishing_discovery*"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_http_api.py b/tests/test_http_api.py new file mode 100644 index 0000000..85706f0 --- /dev/null +++ b/tests/test_http_api.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from http.client import HTTPConnection +import json +import threading +import unittest + +from netfishing_discovery.registry import RoomRegistry +from netfishing_discovery.server import DiscoveryHTTPServer, ServerConfig + + +VALID_ROOM = { + "room_name": "Pond Friends", + "port": 7777, + "current_players": 1, + "max_players": 8, + "game_version": "0.6.4-alpha", + "protocol_version": 3, +} + + +class DiscoveryHTTPTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + config = ServerConfig(bind_host="127.0.0.1", bind_port=0, room_ttl_seconds=30.0) + cls.server = DiscoveryHTTPServer(config, RoomRegistry(ttl_seconds=30.0)) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.port = cls.server.server_address[1] + + @classmethod + def tearDownClass(cls) -> None: + cls.server.shutdown() + cls.server.server_close() + cls.thread.join(timeout=2.0) + + def request( + self, + method: str, + path: str, + payload: dict[str, object] | None = None, + token: str | None = None, + ) -> tuple[int, dict[str, object] | None]: + connection = HTTPConnection("127.0.0.1", self.port, timeout=2.0) + headers: dict[str, str] = {} + body = None + if payload is not None: + body = json.dumps(payload) + headers["Content-Type"] = "application/json" + if token is not None: + headers["Authorization"] = f"Bearer {token}" + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + response_body = response.read() + connection.close() + decoded = json.loads(response_body) if response_body else None + return response.status, decoded + + def test_room_lease_lifecycle(self) -> None: + status, created = self.request("POST", "/v1/rooms", VALID_ROOM) + self.assertEqual(status, 201) + assert created is not None + room = created["room"] + assert isinstance(room, dict) + room_id = room["room_id"] + token = created["lease_token"] + self.assertIsInstance(room_id, str) + self.assertIsInstance(token, str) + + status, listing = self.request("GET", "/v1/rooms?protocol_version=3") + self.assertEqual(status, 200) + assert listing is not None + listed_rooms = listing["rooms"] + self.assertTrue(any(candidate["room_id"] == room_id for candidate in listed_rooms)) + + status, updated = self.request( + "PUT", + f"/v1/rooms/{room_id}", + dict(VALID_ROOM, current_players=2), + str(token), + ) + self.assertEqual(status, 200) + assert updated is not None + self.assertEqual(updated["room"]["current_players"], 2) + + status, _body = self.request("DELETE", f"/v1/rooms/{room_id}", token=str(token)) + self.assertEqual(status, 204) + + def test_invalid_payload_returns_structured_error(self) -> None: + status, body = self.request("POST", "/v1/rooms", dict(VALID_ROOM, port=70_000)) + self.assertEqual(status, 400) + assert body is not None + self.assertEqual(body["error"]["code"], "invalid_room") + + def test_health_endpoint(self) -> None: + status, body = self.request("GET", "/health") + self.assertEqual(status, 200) + assert body is not None + self.assertEqual(body["status"], "ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..f3558ba --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import unittest + +from netfishing_discovery.registry import ( + LeaseAuthorizationError, + RoomLimitError, + RoomNotFoundError, + RoomRegistry, + ValidationError, +) + + +VALID_ROOM = { + "room_name": "Pond Friends", + "port": 7777, + "current_players": 1, + "max_players": 8, + "game_version": "0.6.4-alpha", + "protocol_version": 3, +} + + +class FakeClock: + def __init__(self) -> None: + self.now = 1_000.0 + + def __call__(self) -> float: + return self.now + + +class RoomRegistryTests(unittest.TestCase): + def setUp(self) -> None: + self.clock = FakeClock() + self.registry = RoomRegistry(ttl_seconds=30.0, clock=self.clock) + + def test_create_list_update_and_delete(self) -> None: + room, token = self.registry.create("203.0.113.10", VALID_ROOM) + self.assertEqual(room.address, "203.0.113.10") + self.assertEqual(self.registry.list_rooms(), [room]) + + self.clock.now += 5.0 + updated_payload = dict(VALID_ROOM, current_players=3) + updated = self.registry.update(room.room_id, token, room.address, updated_payload) + self.assertEqual(updated.current_players, 3) + self.assertEqual(updated.created_at, room.created_at) + self.assertEqual(updated.expires_at, self.clock.now + 30.0) + + self.registry.delete(room.room_id, token) + self.assertEqual(self.registry.list_rooms(), []) + + def test_expired_room_is_removed(self) -> None: + room, token = self.registry.create("203.0.113.10", VALID_ROOM) + self.clock.now += 30.0 + self.assertEqual(self.registry.list_rooms(), []) + with self.assertRaises(RoomNotFoundError): + self.registry.update(room.room_id, token, room.address, VALID_ROOM) + + def test_wrong_token_cannot_mutate_room(self) -> None: + room, _token = self.registry.create("203.0.113.10", VALID_ROOM) + with self.assertRaises(LeaseAuthorizationError): + self.registry.delete(room.room_id, "wrong") + + def test_invalid_room_is_rejected(self) -> None: + with self.assertRaises(ValidationError): + self.registry.create("203.0.113.10", dict(VALID_ROOM, current_players=9)) + with self.assertRaises(ValidationError): + self.registry.create("203.0.113.10", dict(VALID_ROOM, room_name="bad\nname")) + + def test_filters_use_authored_versions(self) -> None: + expected, _token = self.registry.create("203.0.113.10", VALID_ROOM) + self.registry.create( + "203.0.113.11", + dict(VALID_ROOM, room_name="Older Room", protocol_version=2), + ) + self.assertEqual(self.registry.list_rooms(protocol_version=3), [expected]) + self.assertEqual(self.registry.list_rooms(game_version="missing"), []) + + def test_room_limits_bound_untrusted_advertisements(self) -> None: + registry = RoomRegistry( + ttl_seconds=30.0, + max_rooms=2, + max_rooms_per_address=1, + clock=self.clock, + ) + registry.create("203.0.113.10", VALID_ROOM) + with self.assertRaises(RoomLimitError): + registry.create("203.0.113.10", dict(VALID_ROOM, room_name="Duplicate")) + registry.create("203.0.113.11", dict(VALID_ROOM, room_name="Second")) + with self.assertRaises(RoomLimitError): + registry.create("203.0.113.12", dict(VALID_ROOM, room_name="Full")) + + +if __name__ == "__main__": + unittest.main()