diff --git a/README.md b/README.md index f167a57..113de58 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deploy/netfishing-discovery.env.example b/deploy/netfishing-discovery.env.example index 0e1b0d3..52c2dd3 100644 --- a/deploy/netfishing-discovery.env.example +++ b/deploy/netfishing-discovery.env.example @@ -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 diff --git a/docs/API.md b/docs/API.md index 5eeedee..d8b132e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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` diff --git a/netfishing_discovery/__init__.py b/netfishing_discovery/__init__.py index f1dc111..ace3363 100644 --- a/netfishing_discovery/__init__.py +++ b/netfishing_discovery/__init__.py @@ -1,3 +1,3 @@ """NETfishing discovery service.""" -__version__ = "0.1.1" +__version__ = "0.2.0" diff --git a/netfishing_discovery/server.py b/netfishing_discovery/server.py index 358221d..1308e09 100644 --- a/netfishing_discovery/server.py +++ b/netfishing_discovery/server.py @@ -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), diff --git a/pyproject.toml b/pyproject.toml index edbd0a0..e64bba3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [] diff --git a/tests/test_http_api.py b/tests/test_http_api.py index 2c8b53e..e09411f 100644 --- a/tests/test_http_api.py +++ b/tests/test_http_api.py @@ -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__": diff --git a/tests/test_registry.py b/tests/test_registry.py index 2a16834..5bdc356 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -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