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 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: Run the focused tests with:
```bash ```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 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. 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. 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 ## API

View file

@ -6,6 +6,8 @@ NETFISHING_DISCOVERY_MAX_ROOMS=4096
NETFISHING_DISCOVERY_MAX_ROOMS_PER_ADDRESS=32 NETFISHING_DISCOVERY_MAX_ROOMS_PER_ADDRESS=32
NETFISHING_DISCOVERY_MAX_BODY_BYTES=8192 NETFISHING_DISCOVERY_MAX_BODY_BYTES=8192
NETFISHING_DISCOVERY_LOG_LEVEL=INFO 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. # Public UDP rendezvous used to observe ENet mappings and coordinate hole punching.
NETFISHING_DISCOVERY_TRAVERSAL_HOST=0.0.0.0 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` ## `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` ## `GET /v1/rooms`

View file

@ -1,3 +1,3 @@
"""NETfishing discovery service.""" """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_bind_host: str = "0.0.0.0"
traversal_port: int = 7771 traversal_port: int = 7771
traversal_public_host: str = "127.0.0.1" traversal_public_host: str = "127.0.0.1"
build_revision: str = "unknown"
@classmethod @classmethod
def from_environment(cls) -> "ServerConfig": def from_environment(cls) -> "ServerConfig":
@ -82,6 +83,10 @@ class ServerConfig:
traversal_public_host=os.getenv( traversal_public_host=os.getenv(
"NETFISHING_DISCOVERY_TRAVERSAL_PUBLIC_HOST", "127.0.0.1" "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", "status": "ok",
"service": "netfishing-discovery-server", "service": "netfishing-discovery-server",
"version": __version__, "version": __version__,
"build_revision": self.server.config.build_revision,
"active_rooms": self.server.registry.room_count(), "active_rooms": self.server.registry.room_count(),
}, },
) )
@ -359,8 +365,10 @@ def _install_signal_handlers(server: BaseServer) -> None:
signal.signal(signal.SIGTERM, stop_server) signal.signal(signal.SIGTERM, stop_server)
class TraversalUDPServer(socketserver.ThreadingUDPServer): class TraversalUDPServer(socketserver.UDPServer):
daemon_threads = True # 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 allow_reuse_address = True
def __init__(self, config: ServerConfig, registry: RoomRegistry) -> None: def __init__(self, config: ServerConfig, registry: RoomRegistry) -> None:
@ -427,6 +435,7 @@ def main() -> None:
traversal_bind_host=config.traversal_bind_host, traversal_bind_host=config.traversal_bind_host,
traversal_port=config.traversal_port, traversal_port=config.traversal_port,
traversal_public_host=config.traversal_public_host, traversal_public_host=config.traversal_public_host,
build_revision=config.build_revision,
) )
logging.basicConfig( logging.basicConfig(
level=getattr(logging, arguments.log_level.upper(), logging.INFO), level=getattr(logging, arguments.log_level.upper(), logging.INFO),

View file

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

View file

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

View file

@ -63,6 +63,22 @@ class RoomRegistryTests(unittest.TestCase):
) )
self.assertEqual(room.current_players, 0) 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: def test_expired_room_is_removed(self) -> None:
room, token, _verification_token = self.registry.create( room, token, _verification_token = self.registry.create(
"203.0.113.10", VALID_ROOM "203.0.113.10", VALID_ROOM