Harden discovery rendezvous service

This commit is contained in:
Alexander Sellite 2026-08-11 10:37:03 -04:00
parent 804e19c51a
commit ef30369865
8 changed files with 57 additions and 7 deletions

View file

@ -27,6 +27,9 @@ The default listener is `127.0.0.1:7770`.
curl http://127.0.0.1:7770/health
```
Set `NETFISHING_DISCOVERY_BUILD_REVISION` to the deployed Git commit. The health response reports
both the package version and that build revision, which makes production/source drift visible.
Run the focused tests with:
```bash
@ -66,6 +69,9 @@ The game first requests UPnP forwarding, then uses same-socket UDP rendezvous an
This covers common home NAT configurations without changing ENet gameplay authority. Symmetric
NAT and some carrier-grade networks can still require a future ENet-compatible relay fallback.
Expose the configured traversal port over UDP in both the host firewall and provider firewall.
The rendezvous listener intentionally processes its small, bounded packets serially so arbitrary
datagrams cannot create unbounded worker threads. Apply conservative edge rate limits as an
additional deployment control.
## API

View file

@ -6,6 +6,8 @@ NETFISHING_DISCOVERY_MAX_ROOMS=4096
NETFISHING_DISCOVERY_MAX_ROOMS_PER_ADDRESS=32
NETFISHING_DISCOVERY_MAX_BODY_BYTES=8192
NETFISHING_DISCOVERY_LOG_LEVEL=INFO
# Set this to the exact deployed Git commit so /health identifies the build.
NETFISHING_DISCOVERY_BUILD_REVISION=development
# Public UDP rendezvous used to observe ENet mappings and coordinate hole punching.
NETFISHING_DISCOVERY_TRAVERSAL_HOST=0.0.0.0

View file

@ -8,7 +8,9 @@ All request and response bodies use `application/json`. Production clients must
## `GET /health`
Returns service status and the number of active, unexpired room leases.
Returns service status, package version, configured build revision, and the number of active,
unexpired room leases. Deployments should set `NETFISHING_DISCOVERY_BUILD_REVISION` to the exact
Git commit they are running.
## `GET /v1/rooms`

View file

@ -1,3 +1,3 @@
"""NETfishing discovery service."""
__version__ = "0.1.1"
__version__ = "0.2.0"

View file

@ -47,6 +47,7 @@ class ServerConfig:
traversal_bind_host: str = "0.0.0.0"
traversal_port: int = 7771
traversal_public_host: str = "127.0.0.1"
build_revision: str = "unknown"
@classmethod
def from_environment(cls) -> "ServerConfig":
@ -82,6 +83,10 @@ class ServerConfig:
traversal_public_host=os.getenv(
"NETFISHING_DISCOVERY_TRAVERSAL_PUBLIC_HOST", "127.0.0.1"
),
build_revision=(
os.getenv("NETFISHING_DISCOVERY_BUILD_REVISION", "unknown").strip()
or "unknown"
)[:64],
)
@ -112,6 +117,7 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
"status": "ok",
"service": "netfishing-discovery-server",
"version": __version__,
"build_revision": self.server.config.build_revision,
"active_rooms": self.server.registry.room_count(),
},
)
@ -359,8 +365,10 @@ def _install_signal_handlers(server: BaseServer) -> None:
signal.signal(signal.SIGTERM, stop_server)
class TraversalUDPServer(socketserver.ThreadingUDPServer):
daemon_threads = True
class TraversalUDPServer(socketserver.UDPServer):
# Each packet performs one bounded JSON decode and one locked registry
# operation. A serial loop avoids creating an attacker-controlled thread
# for every untrusted datagram.
allow_reuse_address = True
def __init__(self, config: ServerConfig, registry: RoomRegistry) -> None:
@ -427,6 +435,7 @@ def main() -> None:
traversal_bind_host=config.traversal_bind_host,
traversal_port=config.traversal_port,
traversal_public_host=config.traversal_public_host,
build_revision=config.build_revision,
)
logging.basicConfig(
level=getattr(logging, arguments.log_level.upper(), logging.INFO),

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netfishing-discovery-server"
version = "0.1.1"
version = "0.2.0"
description = "Ephemeral public-room directory for NETfishing"
requires-python = ">=3.11"
dependencies = []

View file

@ -2,11 +2,16 @@ from __future__ import annotations
from http.client import HTTPConnection
import json
import socketserver
import threading
import unittest
from netfishing_discovery.registry import RoomRegistry
from netfishing_discovery.server import DiscoveryHTTPServer, ServerConfig
from netfishing_discovery.server import (
DiscoveryHTTPServer,
ServerConfig,
TraversalUDPServer,
)
VALID_ROOM = {
@ -22,7 +27,12 @@ VALID_ROOM = {
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)
config = ServerConfig(
bind_host="127.0.0.1",
bind_port=0,
room_ttl_seconds=30.0,
build_revision="test-build",
)
cls.server = DiscoveryHTTPServer(config, RoomRegistry(ttl_seconds=30.0))
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
@ -163,6 +173,11 @@ class DiscoveryHTTPTests(unittest.TestCase):
self.assertEqual(status, 200)
assert body is not None
self.assertEqual(body["status"], "ok")
self.assertEqual(body["version"], "0.2.0")
self.assertEqual(body["build_revision"], "test-build")
def test_udp_rendezvous_does_not_spawn_per_packet_threads(self) -> None:
self.assertFalse(issubclass(TraversalUDPServer, socketserver.ThreadingMixIn))
if __name__ == "__main__":

View file

@ -63,6 +63,22 @@ class RoomRegistryTests(unittest.TestCase):
)
self.assertEqual(room.current_players, 0)
def test_reverification_refreshes_changed_public_endpoint(self) -> None:
room, _token, verification_token = self.registry.create(
"203.0.113.10", VALID_ROOM
)
first = self.registry.verify_endpoint(
room.room_id, verification_token, "203.0.113.10", 41000
)
self.clock.now += 20.0
refreshed = self.registry.verify_endpoint(
room.room_id, verification_token, "198.51.100.25", 51000
)
self.assertEqual(refreshed.address, "198.51.100.25")
self.assertEqual(refreshed.port, 51000)
self.assertEqual(refreshed.created_at, first.created_at)
self.assertEqual(refreshed.expires_at, self.clock.now + 30.0)
def test_expired_room_is_removed(self) -> None:
room, token, _verification_token = self.registry.create(
"203.0.113.10", VALID_ROOM