Add NETfishing discovery service
This commit is contained in:
commit
b5b3cc0211
13 changed files with 1013 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.env
|
||||
77
README.md
Normal file
77
README.md
Normal 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.
|
||||
12
deploy/netfishing-discovery.env.example
Normal file
12
deploy/netfishing-discovery.env.example
Normal 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
|
||||
27
deploy/netfishing-discovery.service
Normal file
27
deploy/netfishing-discovery.service
Normal 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
76
docs/API.md
Normal 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.
|
||||
3
netfishing_discovery/__init__.py
Normal file
3
netfishing_discovery/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""NETfishing discovery service."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
5
netfishing_discovery/__main__.py
Normal file
5
netfishing_discovery/__main__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from .server import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
253
netfishing_discovery/registry.py
Normal file
253
netfishing_discovery/registry.py
Normal 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]
|
||||
336
netfishing_discovery/server.py
Normal file
336
netfishing_discovery/server.py
Normal 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
16
pyproject.toml
Normal 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
1
tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
103
tests/test_http_api.py
Normal file
103
tests/test_http_api.py
Normal 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
95
tests/test_registry.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue