Add NETfishing discovery service

This commit is contained in:
Alexander Sellite 2026-08-10 10:19:57 -04:00
commit b5b3cc0211
13 changed files with 1013 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
.coverage
.pytest_cache/
.venv/
dist/
build/
*.egg-info/
.env

77
README.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -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

76
docs/API.md Normal file
View file

@ -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 <lease_token>
```
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.

View file

@ -0,0 +1,3 @@
"""NETfishing discovery service."""
__version__ = "0.1.0"

View file

@ -0,0 +1,5 @@
from .server import main
if __name__ == "__main__":
main()

View file

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

View file

@ -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()

16
pyproject.toml Normal file
View file

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

1
tests/__init__.py Normal file
View file

@ -0,0 +1 @@

103
tests/test_http_api.py Normal file
View file

@ -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()

95
tests/test_registry.py Normal file
View file

@ -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()