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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""NETfishing discovery service."""
|
"""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_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),
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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__":
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue