Harden discovery rendezvous service
This commit is contained in:
parent
804e19c51a
commit
ef30369865
8 changed files with 57 additions and 7 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""NETfishing discovery service."""
|
||||
|
||||
__version__ = "0.1.1"
|
||||
__version__ = "0.2.0"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue