feat: add UDP NAT rendezvous
This commit is contained in:
parent
6a7c7a676d
commit
804e19c51a
7 changed files with 370 additions and 27 deletions
15
README.md
15
README.md
|
|
@ -4,8 +4,9 @@ An ephemeral public-room directory for NETfishing. It complements the game's exi
|
||||||
transport: hosts advertise a room through HTTPS, browsers retrieve compatible rooms, and joining
|
transport: hosts advertise a room through HTTPS, browsers retrieve compatible rooms, and joining
|
||||||
still uses the authoritative host's UDP endpoint.
|
still uses the authoritative host's UDP endpoint.
|
||||||
|
|
||||||
This first phase intentionally does not use coturn. TURN relays WebRTC traffic; NETfishing currently
|
The service also runs a small UDP rendezvous. It observes packets sent from the exact ENet sockets
|
||||||
uses ENet/UDP, so NAT traversal and relay transport are a separate future phase.
|
used by hosts and joiners, then lets an authenticated room host retrieve pending endpoints and send
|
||||||
|
hole-punch packets. Gameplay remains direct and never passes through this service.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ The intended public topology is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
NETfishing client -> HTTPS reverse proxy -> 127.0.0.1:7770 discovery service
|
NETfishing client -> HTTPS reverse proxy -> 127.0.0.1:7770 discovery service
|
||||||
|
NETfishing client -----------------------> public UDP rendezvous:7771
|
||||||
NETfishing client -----------------------> advertised ENet/UDP host:port
|
NETfishing client -----------------------> advertised ENet/UDP host:port
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -60,11 +62,10 @@ database, backups, or schema migrations.
|
||||||
|
|
||||||
## Connectivity boundary
|
## Connectivity boundary
|
||||||
|
|
||||||
Discovery makes a room findable; it does not make an unreachable ENet host
|
The game first requests UPnP forwarding, then uses same-socket UDP rendezvous and hole punching.
|
||||||
reachable. A host still needs its advertised UDP port forwarded/reachable from
|
This covers common home NAT configurations without changing ENet gameplay authority. Symmetric
|
||||||
the Internet. The existing coturn service cannot relay ENet packets because
|
NAT and some carrier-grade networks can still require a future ENet-compatible relay fallback.
|
||||||
TURN is a WebRTC transport component. NAT traversal or relay support therefore
|
Expose the configured traversal port over UDP in both the host firewall and provider firewall.
|
||||||
remains a separate transport project and is not hidden inside the directory.
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ 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
|
||||||
|
|
||||||
|
# Public UDP rendezvous used to observe ENet mappings and coordinate hole punching.
|
||||||
|
NETFISHING_DISCOVERY_TRAVERSAL_HOST=0.0.0.0
|
||||||
|
NETFISHING_DISCOVERY_TRAVERSAL_PORT=7771
|
||||||
|
NETFISHING_DISCOVERY_TRAVERSAL_PUBLIC_HOST=discovery.netfishing.org
|
||||||
|
|
||||||
# Only trust X-Forwarded-For when the immediate peer is within one of these CIDRs.
|
# Only trust X-Forwarded-For when the immediate peer is within one of these CIDRs.
|
||||||
# The loopback values suit a reverse proxy on the same host.
|
# The loopback values suit a reverse proxy on the same host.
|
||||||
NETFISHING_DISCOVERY_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128
|
NETFISHING_DISCOVERY_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128
|
||||||
|
|
|
||||||
22
docs/API.md
22
docs/API.md
|
|
@ -1,7 +1,8 @@
|
||||||
# Discovery API v1
|
# Discovery API v1
|
||||||
|
|
||||||
The API stores public room advertisements as short-lived leases. It does not proxy or relay game
|
The API stores public room advertisements as short-lived leases and coordinates direct UDP hole
|
||||||
traffic. NETfishing continues to connect to the returned host and UDP port through ENet.
|
punching. It does not proxy or relay gameplay traffic. NETfishing continues to connect to the
|
||||||
|
returned host and UDP port through ENet.
|
||||||
|
|
||||||
All request and response bodies use `application/json`. Production clients must use HTTPS.
|
All request and response bodies use `application/json`. Production clients must use HTTPS.
|
||||||
|
|
||||||
|
|
@ -37,8 +38,9 @@ trusting an address supplied by the game client.
|
||||||
`current_players` may be zero for an empty dedicated server. A player-hosted
|
`current_players` may be zero for an empty dedicated server. A player-hosted
|
||||||
room normally includes its host in this count.
|
room normally includes its host in this count.
|
||||||
|
|
||||||
The `201` response includes the public `room` and a secret `lease_token`. The host retains that
|
The `201` response includes the room, a secret `lease_token`, and an endpoint verification token.
|
||||||
token only for the current hosting session.
|
The host sends the verification token to the UDP rendezvous from its bound ENet socket. Rooms are
|
||||||
|
excluded from public listings until the service observes that endpoint.
|
||||||
|
|
||||||
The service applies configured global and per-observed-address active-room limits. Exceeding one
|
The service applies configured global and per-observed-address active-room limits. Exceeding one
|
||||||
returns `429 room_limit`; expired leases stop counting automatically.
|
returns `429 room_limit`; expired leases stop counting automatically.
|
||||||
|
|
@ -59,6 +61,16 @@ lease. A missing heartbeat causes automatic removal without requiring a disconne
|
||||||
Removes a room immediately. Requires the same bearer token. A clean host shutdown should call this,
|
Removes a room immediately. Requires the same bearer token. A clean host shutdown should call this,
|
||||||
but TTL expiry remains authoritative for crashes and lost connectivity.
|
but TTL expiry remains authoritative for crashes and lost connectivity.
|
||||||
|
|
||||||
|
## `POST /v1/rooms/{room_id}/join-attempts`
|
||||||
|
|
||||||
|
Creates a short-lived traversal token for a public room. The joining game sends that token to the
|
||||||
|
UDP rendezvous from the same ENet socket used for the gameplay connection.
|
||||||
|
|
||||||
|
## `GET /v1/rooms/{room_id}/join-attempts`
|
||||||
|
|
||||||
|
Requires the room lease bearer token. It consumes observed joining endpoints so the host can send
|
||||||
|
same-socket UDP punch packets before ENet retries its connection.
|
||||||
|
|
||||||
## Error shape
|
## Error shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -72,7 +84,7 @@ but TTL expiry remains authoritative for crashes and lost connectivity.
|
||||||
|
|
||||||
## Trust boundary
|
## Trust boundary
|
||||||
|
|
||||||
- The observed source IP is authoritative for a room's public address.
|
- The UDP rendezvous observation is authoritative for a room's public address and port.
|
||||||
- `X-Forwarded-For` is honored only when the immediate peer belongs to an explicitly configured
|
- `X-Forwarded-For` is honored only when the immediate peer belongs to an explicitly configured
|
||||||
trusted proxy CIDR.
|
trusted proxy CIDR.
|
||||||
- Lease tokens authorize update and deletion but are never included in public listings.
|
- Lease tokens authorize update and deletion but are never included in public listings.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
import hmac
|
import hmac
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -18,6 +18,8 @@ VERSION_MAX_LENGTH = 32
|
||||||
MAX_ROOM_CAPACITY = 128
|
MAX_ROOM_CAPACITY = 128
|
||||||
DEFAULT_MAX_ROOMS = 4096
|
DEFAULT_MAX_ROOMS = 4096
|
||||||
DEFAULT_MAX_ROOMS_PER_ADDRESS = 32
|
DEFAULT_MAX_ROOMS_PER_ADDRESS = 32
|
||||||
|
JOIN_ATTEMPT_TTL_SECONDS = 12.0
|
||||||
|
MAX_JOIN_ATTEMPTS_PER_ROOM = 64
|
||||||
|
|
||||||
|
|
||||||
class RegistryError(Exception):
|
class RegistryError(Exception):
|
||||||
|
|
@ -53,6 +55,7 @@ class RoomAdvertisement:
|
||||||
created_at: float
|
created_at: float
|
||||||
updated_at: float
|
updated_at: float
|
||||||
expires_at: float
|
expires_at: float
|
||||||
|
verified: bool = False
|
||||||
|
|
||||||
def public_dict(self) -> dict[str, Any]:
|
def public_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -67,6 +70,7 @@ class RoomAdvertisement:
|
||||||
"created_at": _iso_utc(self.created_at),
|
"created_at": _iso_utc(self.created_at),
|
||||||
"updated_at": _iso_utc(self.updated_at),
|
"updated_at": _iso_utc(self.updated_at),
|
||||||
"expires_at": _iso_utc(self.expires_at),
|
"expires_at": _iso_utc(self.expires_at),
|
||||||
|
"verified": self.verified,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -74,6 +78,15 @@ class RoomAdvertisement:
|
||||||
class _RoomLease:
|
class _RoomLease:
|
||||||
advertisement: RoomAdvertisement
|
advertisement: RoomAdvertisement
|
||||||
token: str
|
token: str
|
||||||
|
verification_token: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _JoinAttempt:
|
||||||
|
room_id: str
|
||||||
|
expires_at: float
|
||||||
|
address: str = ""
|
||||||
|
port: int = 0
|
||||||
|
|
||||||
|
|
||||||
def _iso_utc(timestamp: float) -> str:
|
def _iso_utc(timestamp: float) -> str:
|
||||||
|
|
@ -122,16 +135,20 @@ class RoomRegistry:
|
||||||
self._max_rooms_per_address = int(max_rooms_per_address)
|
self._max_rooms_per_address = int(max_rooms_per_address)
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
self._rooms: dict[str, _RoomLease] = {}
|
self._rooms: dict[str, _RoomLease] = {}
|
||||||
|
self._join_attempts: dict[str, _JoinAttempt] = {}
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ttl_seconds(self) -> float:
|
def ttl_seconds(self) -> float:
|
||||||
return self._ttl_seconds
|
return self._ttl_seconds
|
||||||
|
|
||||||
def create(self, address: str, payload: Mapping[str, Any]) -> tuple[RoomAdvertisement, str]:
|
def create(
|
||||||
|
self, address: str, payload: Mapping[str, Any]
|
||||||
|
) -> tuple[RoomAdvertisement, str, str]:
|
||||||
now = self._clock()
|
now = self._clock()
|
||||||
room_id = str(uuid.uuid4())
|
room_id = str(uuid.uuid4())
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
|
verification_token = secrets.token_urlsafe(32)
|
||||||
advertisement = self._build_advertisement(room_id, address, payload, now, now)
|
advertisement = self._build_advertisement(room_id, address, payload, now, now)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._purge_locked(now)
|
self._purge_locked(now)
|
||||||
|
|
@ -144,8 +161,10 @@ class RoomRegistry:
|
||||||
)
|
)
|
||||||
if address_room_count >= self._max_rooms_per_address:
|
if address_room_count >= self._max_rooms_per_address:
|
||||||
raise RoomLimitError("too many active rooms from this address")
|
raise RoomLimitError("too many active rooms from this address")
|
||||||
self._rooms[room_id] = _RoomLease(advertisement, token)
|
self._rooms[room_id] = _RoomLease(
|
||||||
return advertisement, token
|
advertisement, token, verification_token
|
||||||
|
)
|
||||||
|
return advertisement, token, verification_token
|
||||||
|
|
||||||
def update(
|
def update(
|
||||||
self,
|
self,
|
||||||
|
|
@ -160,10 +179,14 @@ class RoomRegistry:
|
||||||
lease = self._authorized_lease_locked(room_id, token)
|
lease = self._authorized_lease_locked(room_id, token)
|
||||||
advertisement = self._build_advertisement(
|
advertisement = self._build_advertisement(
|
||||||
room_id,
|
room_id,
|
||||||
address,
|
lease.advertisement.address if lease.advertisement.verified else address,
|
||||||
payload,
|
payload,
|
||||||
lease.advertisement.created_at,
|
lease.advertisement.created_at,
|
||||||
now,
|
now,
|
||||||
|
endpoint_port=(
|
||||||
|
lease.advertisement.port if lease.advertisement.verified else None
|
||||||
|
),
|
||||||
|
verified=lease.advertisement.verified,
|
||||||
)
|
)
|
||||||
lease.advertisement = advertisement
|
lease.advertisement = advertisement
|
||||||
return advertisement
|
return advertisement
|
||||||
|
|
@ -184,7 +207,11 @@ class RoomRegistry:
|
||||||
now = self._clock()
|
now = self._clock()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._purge_locked(now)
|
self._purge_locked(now)
|
||||||
rooms = [lease.advertisement for lease in self._rooms.values()]
|
rooms = [
|
||||||
|
lease.advertisement
|
||||||
|
for lease in self._rooms.values()
|
||||||
|
if lease.advertisement.verified
|
||||||
|
]
|
||||||
if game_version is not None:
|
if game_version is not None:
|
||||||
rooms = [room for room in rooms if room.game_version == game_version]
|
rooms = [room for room in rooms if room.game_version == game_version]
|
||||||
if protocol_version is not None:
|
if protocol_version is not None:
|
||||||
|
|
@ -197,6 +224,93 @@ class RoomRegistry:
|
||||||
self._purge_locked(now)
|
self._purge_locked(now)
|
||||||
return len(self._rooms)
|
return len(self._rooms)
|
||||||
|
|
||||||
|
def verify_endpoint(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
verification_token: str,
|
||||||
|
address: str,
|
||||||
|
port: int,
|
||||||
|
) -> RoomAdvertisement:
|
||||||
|
if not address or port < 1 or port > 65_535:
|
||||||
|
raise ValidationError("invalid observed UDP endpoint")
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
self._purge_locked(now)
|
||||||
|
lease = self._rooms.get(room_id)
|
||||||
|
if lease is None:
|
||||||
|
raise RoomNotFoundError("room does not exist or its lease expired")
|
||||||
|
if not hmac.compare_digest(
|
||||||
|
lease.verification_token, verification_token
|
||||||
|
):
|
||||||
|
raise LeaseAuthorizationError("invalid endpoint verification token")
|
||||||
|
lease.advertisement = replace(
|
||||||
|
lease.advertisement,
|
||||||
|
address=address,
|
||||||
|
port=port,
|
||||||
|
updated_at=now,
|
||||||
|
expires_at=now + self._ttl_seconds,
|
||||||
|
verified=True,
|
||||||
|
)
|
||||||
|
return lease.advertisement
|
||||||
|
|
||||||
|
def create_join_attempt(self, room_id: str) -> str:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
self._purge_locked(now)
|
||||||
|
lease = self._rooms.get(room_id)
|
||||||
|
if lease is None or not lease.advertisement.verified:
|
||||||
|
raise RoomNotFoundError("room is not available")
|
||||||
|
active_count = sum(
|
||||||
|
1
|
||||||
|
for attempt in self._join_attempts.values()
|
||||||
|
if attempt.room_id == room_id
|
||||||
|
)
|
||||||
|
if active_count >= MAX_JOIN_ATTEMPTS_PER_ROOM:
|
||||||
|
raise RoomLimitError("too many pending joins for this room")
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
self._join_attempts[token] = _JoinAttempt(
|
||||||
|
room_id=room_id,
|
||||||
|
expires_at=now + JOIN_ATTEMPT_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def register_join_endpoint(
|
||||||
|
self, token: str, address: str, port: int
|
||||||
|
) -> None:
|
||||||
|
if not address or port < 1 or port > 65_535:
|
||||||
|
raise ValidationError("invalid observed join endpoint")
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
self._purge_locked(now)
|
||||||
|
attempt = self._join_attempts.get(token)
|
||||||
|
if attempt is None:
|
||||||
|
raise RoomNotFoundError("join attempt does not exist or expired")
|
||||||
|
attempt.address = address
|
||||||
|
attempt.port = port
|
||||||
|
|
||||||
|
def consume_join_endpoints(
|
||||||
|
self, room_id: str, lease_token: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
now = self._clock()
|
||||||
|
with self._lock:
|
||||||
|
self._purge_locked(now)
|
||||||
|
self._authorized_lease_locked(room_id, lease_token)
|
||||||
|
consumed_tokens = [
|
||||||
|
token
|
||||||
|
for token, attempt in self._join_attempts.items()
|
||||||
|
if attempt.room_id == room_id and attempt.address and attempt.port > 0
|
||||||
|
]
|
||||||
|
endpoints = [
|
||||||
|
{
|
||||||
|
"address": self._join_attempts[token].address,
|
||||||
|
"port": self._join_attempts[token].port,
|
||||||
|
}
|
||||||
|
for token in consumed_tokens
|
||||||
|
]
|
||||||
|
for token in consumed_tokens:
|
||||||
|
del self._join_attempts[token]
|
||||||
|
return endpoints
|
||||||
|
|
||||||
def _build_advertisement(
|
def _build_advertisement(
|
||||||
self,
|
self,
|
||||||
room_id: str,
|
room_id: str,
|
||||||
|
|
@ -204,9 +318,12 @@ class RoomRegistry:
|
||||||
payload: Mapping[str, Any],
|
payload: Mapping[str, Any],
|
||||||
created_at: float,
|
created_at: float,
|
||||||
now: float,
|
now: float,
|
||||||
|
endpoint_port: int | None = None,
|
||||||
|
verified: bool = False,
|
||||||
) -> RoomAdvertisement:
|
) -> RoomAdvertisement:
|
||||||
room_name = _clean_text(payload.get("room_name"), "room_name", ROOM_NAME_MAX_LENGTH)
|
room_name = _clean_text(payload.get("room_name"), "room_name", ROOM_NAME_MAX_LENGTH)
|
||||||
port = _clean_int(payload.get("port"), "port", 1, 65_535)
|
supplied_port = _clean_int(payload.get("port"), "port", 1, 65_535)
|
||||||
|
port = endpoint_port if endpoint_port is not None else supplied_port
|
||||||
current_players = _clean_int(
|
current_players = _clean_int(
|
||||||
payload.get("current_players"), "current_players", 0, MAX_ROOM_CAPACITY
|
payload.get("current_players"), "current_players", 0, MAX_ROOM_CAPACITY
|
||||||
)
|
)
|
||||||
|
|
@ -233,6 +350,7 @@ class RoomRegistry:
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
expires_at=now + self._ttl_seconds,
|
expires_at=now + self._ttl_seconds,
|
||||||
|
verified=verified,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _authorized_lease_locked(self, room_id: str, token: str) -> _RoomLease:
|
def _authorized_lease_locked(self, room_id: str, token: str) -> _RoomLease:
|
||||||
|
|
@ -251,3 +369,10 @@ class RoomRegistry:
|
||||||
]
|
]
|
||||||
for room_id in expired:
|
for room_id in expired:
|
||||||
del self._rooms[room_id]
|
del self._rooms[room_id]
|
||||||
|
expired_attempts = [
|
||||||
|
token
|
||||||
|
for token, attempt in self._join_attempts.items()
|
||||||
|
if attempt.expires_at <= now or attempt.room_id not in self._rooms
|
||||||
|
]
|
||||||
|
for token in expired_attempts:
|
||||||
|
del self._join_attempts[token]
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
import socketserver
|
||||||
import threading
|
import threading
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
@ -30,6 +31,8 @@ from .registry import (
|
||||||
|
|
||||||
LOGGER = logging.getLogger("netfishing.discovery")
|
LOGGER = logging.getLogger("netfishing.discovery")
|
||||||
DEFAULT_MAX_BODY_BYTES = 8 * 1024
|
DEFAULT_MAX_BODY_BYTES = 8 * 1024
|
||||||
|
TRAVERSAL_PACKET_PREFIX = b"NETFISHING_TRAVERSAL_V1 "
|
||||||
|
MAX_TRAVERSAL_PACKET_BYTES = 2048
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|
@ -41,6 +44,9 @@ class ServerConfig:
|
||||||
max_rooms_per_address: int = DEFAULT_MAX_ROOMS_PER_ADDRESS
|
max_rooms_per_address: int = DEFAULT_MAX_ROOMS_PER_ADDRESS
|
||||||
max_body_bytes: int = DEFAULT_MAX_BODY_BYTES
|
max_body_bytes: int = DEFAULT_MAX_BODY_BYTES
|
||||||
trusted_proxy_cidrs: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = ()
|
trusted_proxy_cidrs: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = ()
|
||||||
|
traversal_bind_host: str = "0.0.0.0"
|
||||||
|
traversal_port: int = 7771
|
||||||
|
traversal_public_host: str = "127.0.0.1"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_environment(cls) -> "ServerConfig":
|
def from_environment(cls) -> "ServerConfig":
|
||||||
|
|
@ -67,6 +73,15 @@ class ServerConfig:
|
||||||
os.getenv("NETFISHING_DISCOVERY_MAX_BODY_BYTES", str(DEFAULT_MAX_BODY_BYTES))
|
os.getenv("NETFISHING_DISCOVERY_MAX_BODY_BYTES", str(DEFAULT_MAX_BODY_BYTES))
|
||||||
),
|
),
|
||||||
trusted_proxy_cidrs=tuple(proxy_cidrs),
|
trusted_proxy_cidrs=tuple(proxy_cidrs),
|
||||||
|
traversal_bind_host=os.getenv(
|
||||||
|
"NETFISHING_DISCOVERY_TRAVERSAL_HOST", "0.0.0.0"
|
||||||
|
),
|
||||||
|
traversal_port=int(
|
||||||
|
os.getenv("NETFISHING_DISCOVERY_TRAVERSAL_PORT", "7771")
|
||||||
|
),
|
||||||
|
traversal_public_host=os.getenv(
|
||||||
|
"NETFISHING_DISCOVERY_TRAVERSAL_PUBLIC_HOST", "127.0.0.1"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,6 +116,20 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
room_id = self._room_id_for_suffix(route.path, "/join-attempts")
|
||||||
|
if room_id is not None:
|
||||||
|
try:
|
||||||
|
endpoints = self.server.registry.consume_join_endpoints(
|
||||||
|
room_id, self._bearer_token()
|
||||||
|
)
|
||||||
|
except LeaseAuthorizationError as error:
|
||||||
|
self._send_error(HTTPStatus.UNAUTHORIZED, "invalid_lease", str(error))
|
||||||
|
return
|
||||||
|
except RoomNotFoundError as error:
|
||||||
|
self._send_error(HTTPStatus.NOT_FOUND, "room_not_found", str(error))
|
||||||
|
return
|
||||||
|
self._send_json(HTTPStatus.OK, {"endpoints": endpoints})
|
||||||
|
return
|
||||||
if route.path == "/v1/rooms":
|
if route.path == "/v1/rooms":
|
||||||
try:
|
try:
|
||||||
filters = parse_qs(route.query, keep_blank_values=False)
|
filters = parse_qs(route.query, keep_blank_values=False)
|
||||||
|
|
@ -127,14 +156,32 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
|
||||||
self._send_error(HTTPStatus.NOT_FOUND, "not_found", "route not found")
|
self._send_error(HTTPStatus.NOT_FOUND, "not_found", "route not found")
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
if urlsplit(self.path).path != "/v1/rooms":
|
path = urlsplit(self.path).path
|
||||||
|
room_id = self._room_id_for_suffix(path, "/join-attempts")
|
||||||
|
if room_id is not None:
|
||||||
|
try:
|
||||||
|
token = self.server.registry.create_join_attempt(room_id)
|
||||||
|
except RoomNotFoundError as error:
|
||||||
|
self._send_error(HTTPStatus.NOT_FOUND, "room_not_found", str(error))
|
||||||
|
return
|
||||||
|
except RoomLimitError as error:
|
||||||
|
self._send_error(HTTPStatus.TOO_MANY_REQUESTS, "join_limit", str(error))
|
||||||
|
return
|
||||||
|
self._send_json(
|
||||||
|
HTTPStatus.CREATED,
|
||||||
|
{"join_token": token, "traversal": self._traversal_details()},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path != "/v1/rooms":
|
||||||
self._send_error(HTTPStatus.NOT_FOUND, "not_found", "route not found")
|
self._send_error(HTTPStatus.NOT_FOUND, "not_found", "route not found")
|
||||||
return
|
return
|
||||||
payload = self._read_json_object()
|
payload = self._read_json_object()
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
room, token = self.server.registry.create(self._client_address(), payload)
|
room, token, verification_token = self.server.registry.create(
|
||||||
|
self._client_address(), payload
|
||||||
|
)
|
||||||
except ValidationError as error:
|
except ValidationError as error:
|
||||||
self._send_error(HTTPStatus.BAD_REQUEST, "invalid_room", str(error))
|
self._send_error(HTTPStatus.BAD_REQUEST, "invalid_room", str(error))
|
||||||
return
|
return
|
||||||
|
|
@ -143,7 +190,11 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
|
||||||
return
|
return
|
||||||
self._send_json(
|
self._send_json(
|
||||||
HTTPStatus.CREATED,
|
HTTPStatus.CREATED,
|
||||||
{"room": room.public_dict(), "lease_token": token},
|
{
|
||||||
|
"room": room.public_dict(),
|
||||||
|
"lease_token": token,
|
||||||
|
"traversal": self._traversal_details(verification_token),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def do_PUT(self) -> None:
|
def do_PUT(self) -> None:
|
||||||
|
|
@ -251,6 +302,23 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
|
||||||
return None
|
return None
|
||||||
return room_id
|
return room_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _room_id_for_suffix(path: str, suffix: str) -> str | None:
|
||||||
|
prefix = "/v1/rooms/"
|
||||||
|
if not path.startswith(prefix) or not path.endswith(suffix):
|
||||||
|
return None
|
||||||
|
room_id = path[len(prefix) : -len(suffix)]
|
||||||
|
return room_id if room_id and "/" not in room_id else None
|
||||||
|
|
||||||
|
def _traversal_details(self, verification_token: str = "") -> dict[str, Any]:
|
||||||
|
details: dict[str, Any] = {
|
||||||
|
"host": self.server.config.traversal_public_host,
|
||||||
|
"port": self.server.config.traversal_port,
|
||||||
|
}
|
||||||
|
if verification_token:
|
||||||
|
details["verification_token"] = verification_token
|
||||||
|
return details
|
||||||
|
|
||||||
def _bearer_token(self) -> str:
|
def _bearer_token(self) -> str:
|
||||||
value = self.headers.get("Authorization", "")
|
value = self.headers.get("Authorization", "")
|
||||||
scheme, separator, token = value.partition(" ")
|
scheme, separator, token = value.partition(" ")
|
||||||
|
|
@ -291,6 +359,51 @@ def _install_signal_handlers(server: BaseServer) -> None:
|
||||||
signal.signal(signal.SIGTERM, stop_server)
|
signal.signal(signal.SIGTERM, stop_server)
|
||||||
|
|
||||||
|
|
||||||
|
class TraversalUDPServer(socketserver.ThreadingUDPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
def __init__(self, config: ServerConfig, registry: RoomRegistry) -> None:
|
||||||
|
self.registry = registry
|
||||||
|
super().__init__(
|
||||||
|
(config.traversal_bind_host, config.traversal_port),
|
||||||
|
TraversalRequestHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TraversalRequestHandler(socketserver.BaseRequestHandler):
|
||||||
|
server: TraversalUDPServer
|
||||||
|
|
||||||
|
def handle(self) -> None:
|
||||||
|
packet = self.request[0]
|
||||||
|
if (
|
||||||
|
not isinstance(packet, bytes)
|
||||||
|
or len(packet) > MAX_TRAVERSAL_PACKET_BYTES
|
||||||
|
or not packet.startswith(TRAVERSAL_PACKET_PREFIX)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = json.loads(packet[len(TRAVERSAL_PACKET_PREFIX) :])
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
return
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return
|
||||||
|
address = str(self.client_address[0])
|
||||||
|
port = int(self.client_address[1])
|
||||||
|
kind = payload.get("kind")
|
||||||
|
token = payload.get("token")
|
||||||
|
room_id = payload.get("room_id")
|
||||||
|
if not isinstance(token, str) or not isinstance(room_id, str):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if kind == "host":
|
||||||
|
self.server.registry.verify_endpoint(room_id, token, address, port)
|
||||||
|
elif kind == "join":
|
||||||
|
self.server.registry.register_join_endpoint(token, address, port)
|
||||||
|
except (ValidationError, LeaseAuthorizationError, RoomNotFoundError):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _argument_parser() -> argparse.ArgumentParser:
|
def _argument_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--host", help="bind address (overrides environment)")
|
parser.add_argument("--host", help="bind address (overrides environment)")
|
||||||
|
|
@ -311,12 +424,23 @@ def main() -> None:
|
||||||
max_rooms_per_address=config.max_rooms_per_address,
|
max_rooms_per_address=config.max_rooms_per_address,
|
||||||
max_body_bytes=config.max_body_bytes,
|
max_body_bytes=config.max_body_bytes,
|
||||||
trusted_proxy_cidrs=config.trusted_proxy_cidrs,
|
trusted_proxy_cidrs=config.trusted_proxy_cidrs,
|
||||||
|
traversal_bind_host=config.traversal_bind_host,
|
||||||
|
traversal_port=config.traversal_port,
|
||||||
|
traversal_public_host=config.traversal_public_host,
|
||||||
)
|
)
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, arguments.log_level.upper(), logging.INFO),
|
level=getattr(logging, arguments.log_level.upper(), logging.INFO),
|
||||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
)
|
)
|
||||||
server = DiscoveryHTTPServer(config)
|
server = DiscoveryHTTPServer(config)
|
||||||
|
traversal_server = TraversalUDPServer(config, server.registry)
|
||||||
|
traversal_thread = threading.Thread(
|
||||||
|
target=traversal_server.serve_forever,
|
||||||
|
kwargs={"poll_interval": 0.25},
|
||||||
|
name="discovery-traversal",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
traversal_thread.start()
|
||||||
_install_signal_handlers(server)
|
_install_signal_handlers(server)
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"NETfishing discovery server %s listening on %s:%d (room TTL %.1fs)",
|
"NETfishing discovery server %s listening on %s:%d (room TTL %.1fs)",
|
||||||
|
|
@ -325,9 +449,17 @@ def main() -> None:
|
||||||
server.server_address[1],
|
server.server_address[1],
|
||||||
config.room_ttl_seconds,
|
config.room_ttl_seconds,
|
||||||
)
|
)
|
||||||
|
LOGGER.info(
|
||||||
|
"NETfishing traversal rendezvous listening on %s:%d/udp",
|
||||||
|
config.traversal_bind_host,
|
||||||
|
traversal_server.server_address[1],
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
server.serve_forever(poll_interval=0.25)
|
server.serve_forever(poll_interval=0.25)
|
||||||
finally:
|
finally:
|
||||||
|
traversal_server.shutdown()
|
||||||
|
traversal_server.server_close()
|
||||||
|
traversal_thread.join(timeout=2.0)
|
||||||
server.server_close()
|
server.server_close()
|
||||||
LOGGER.info("server stopped")
|
LOGGER.info("server stopped")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,14 @@ class DiscoveryHTTPTests(unittest.TestCase):
|
||||||
assert isinstance(room, dict)
|
assert isinstance(room, dict)
|
||||||
room_id = room["room_id"]
|
room_id = room["room_id"]
|
||||||
token = created["lease_token"]
|
token = created["lease_token"]
|
||||||
|
traversal = created["traversal"]
|
||||||
|
assert isinstance(traversal, dict)
|
||||||
|
self.server.registry.verify_endpoint(
|
||||||
|
str(room_id),
|
||||||
|
str(traversal["verification_token"]),
|
||||||
|
"127.0.0.1",
|
||||||
|
7777,
|
||||||
|
)
|
||||||
self.assertIsInstance(room_id, str)
|
self.assertIsInstance(room_id, str)
|
||||||
self.assertIsInstance(token, str)
|
self.assertIsInstance(token, str)
|
||||||
|
|
||||||
|
|
@ -100,6 +108,14 @@ class DiscoveryHTTPTests(unittest.TestCase):
|
||||||
assert created is not None
|
assert created is not None
|
||||||
room = created["room"]
|
room = created["room"]
|
||||||
assert isinstance(room, dict)
|
assert isinstance(room, dict)
|
||||||
|
traversal = created["traversal"]
|
||||||
|
assert isinstance(traversal, dict)
|
||||||
|
self.server.registry.verify_endpoint(
|
||||||
|
str(room["room_id"]),
|
||||||
|
str(traversal["verification_token"]),
|
||||||
|
"127.0.0.1",
|
||||||
|
7777,
|
||||||
|
)
|
||||||
self.assertEqual(room["current_players"], 0)
|
self.assertEqual(room["current_players"], 0)
|
||||||
self.request(
|
self.request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
|
|
@ -107,6 +123,41 @@ class DiscoveryHTTPTests(unittest.TestCase):
|
||||||
token=str(created["lease_token"]),
|
token=str(created["lease_token"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_join_attempt_reports_observed_endpoint_to_host(self) -> None:
|
||||||
|
status, created = self.request("POST", "/v1/rooms", VALID_ROOM)
|
||||||
|
self.assertEqual(status, 201)
|
||||||
|
assert created is not None
|
||||||
|
room = created["room"]
|
||||||
|
traversal = created["traversal"]
|
||||||
|
assert isinstance(room, dict)
|
||||||
|
assert isinstance(traversal, dict)
|
||||||
|
room_id = str(room["room_id"])
|
||||||
|
self.server.registry.verify_endpoint(
|
||||||
|
room_id,
|
||||||
|
str(traversal["verification_token"]),
|
||||||
|
"127.0.0.1",
|
||||||
|
7777,
|
||||||
|
)
|
||||||
|
status, join = self.request(
|
||||||
|
"POST", f"/v1/rooms/{room_id}/join-attempts"
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 201)
|
||||||
|
assert join is not None
|
||||||
|
self.server.registry.register_join_endpoint(
|
||||||
|
str(join["join_token"]), "198.51.100.7", 49152
|
||||||
|
)
|
||||||
|
status, pending = self.request(
|
||||||
|
"GET",
|
||||||
|
f"/v1/rooms/{room_id}/join-attempts",
|
||||||
|
token=str(created["lease_token"]),
|
||||||
|
)
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
assert pending is not None
|
||||||
|
self.assertEqual(
|
||||||
|
pending["endpoints"],
|
||||||
|
[{"address": "198.51.100.7", "port": 49152}],
|
||||||
|
)
|
||||||
|
|
||||||
def test_health_endpoint(self) -> None:
|
def test_health_endpoint(self) -> None:
|
||||||
status, body = self.request("GET", "/health")
|
status, body = self.request("GET", "/health")
|
||||||
self.assertEqual(status, 200)
|
self.assertEqual(status, 200)
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,12 @@ class RoomRegistryTests(unittest.TestCase):
|
||||||
self.registry = RoomRegistry(ttl_seconds=30.0, clock=self.clock)
|
self.registry = RoomRegistry(ttl_seconds=30.0, clock=self.clock)
|
||||||
|
|
||||||
def test_create_list_update_and_delete(self) -> None:
|
def test_create_list_update_and_delete(self) -> None:
|
||||||
room, token = self.registry.create("203.0.113.10", VALID_ROOM)
|
room, token, verification_token = self.registry.create(
|
||||||
|
"203.0.113.10", VALID_ROOM
|
||||||
|
)
|
||||||
|
room = self.registry.verify_endpoint(
|
||||||
|
room.room_id, verification_token, "203.0.113.10", 7777
|
||||||
|
)
|
||||||
self.assertEqual(room.address, "203.0.113.10")
|
self.assertEqual(room.address, "203.0.113.10")
|
||||||
self.assertEqual(self.registry.list_rooms(), [room])
|
self.assertEqual(self.registry.list_rooms(), [room])
|
||||||
|
|
||||||
|
|
@ -50,20 +55,27 @@ class RoomRegistryTests(unittest.TestCase):
|
||||||
self.assertEqual(self.registry.list_rooms(), [])
|
self.assertEqual(self.registry.list_rooms(), [])
|
||||||
|
|
||||||
def test_empty_dedicated_room_is_valid(self) -> None:
|
def test_empty_dedicated_room_is_valid(self) -> None:
|
||||||
room, _token = self.registry.create(
|
room, _token, verification_token = self.registry.create(
|
||||||
"203.0.113.10", dict(VALID_ROOM, current_players=0)
|
"203.0.113.10", dict(VALID_ROOM, current_players=0)
|
||||||
)
|
)
|
||||||
|
room = self.registry.verify_endpoint(
|
||||||
|
room.room_id, verification_token, "203.0.113.10", 7777
|
||||||
|
)
|
||||||
self.assertEqual(room.current_players, 0)
|
self.assertEqual(room.current_players, 0)
|
||||||
|
|
||||||
def test_expired_room_is_removed(self) -> None:
|
def test_expired_room_is_removed(self) -> None:
|
||||||
room, token = self.registry.create("203.0.113.10", VALID_ROOM)
|
room, token, _verification_token = self.registry.create(
|
||||||
|
"203.0.113.10", VALID_ROOM
|
||||||
|
)
|
||||||
self.clock.now += 30.0
|
self.clock.now += 30.0
|
||||||
self.assertEqual(self.registry.list_rooms(), [])
|
self.assertEqual(self.registry.list_rooms(), [])
|
||||||
with self.assertRaises(RoomNotFoundError):
|
with self.assertRaises(RoomNotFoundError):
|
||||||
self.registry.update(room.room_id, token, room.address, VALID_ROOM)
|
self.registry.update(room.room_id, token, room.address, VALID_ROOM)
|
||||||
|
|
||||||
def test_wrong_token_cannot_mutate_room(self) -> None:
|
def test_wrong_token_cannot_mutate_room(self) -> None:
|
||||||
room, _token = self.registry.create("203.0.113.10", VALID_ROOM)
|
room, _token, _verification_token = self.registry.create(
|
||||||
|
"203.0.113.10", VALID_ROOM
|
||||||
|
)
|
||||||
with self.assertRaises(LeaseAuthorizationError):
|
with self.assertRaises(LeaseAuthorizationError):
|
||||||
self.registry.delete(room.room_id, "wrong")
|
self.registry.delete(room.room_id, "wrong")
|
||||||
|
|
||||||
|
|
@ -74,7 +86,12 @@ class RoomRegistryTests(unittest.TestCase):
|
||||||
self.registry.create("203.0.113.10", dict(VALID_ROOM, room_name="bad\nname"))
|
self.registry.create("203.0.113.10", dict(VALID_ROOM, room_name="bad\nname"))
|
||||||
|
|
||||||
def test_filters_use_authored_versions(self) -> None:
|
def test_filters_use_authored_versions(self) -> None:
|
||||||
expected, _token = self.registry.create("203.0.113.10", VALID_ROOM)
|
expected, _token, verification_token = self.registry.create(
|
||||||
|
"203.0.113.10", VALID_ROOM
|
||||||
|
)
|
||||||
|
expected = self.registry.verify_endpoint(
|
||||||
|
expected.room_id, verification_token, "203.0.113.10", 7777
|
||||||
|
)
|
||||||
self.registry.create(
|
self.registry.create(
|
||||||
"203.0.113.11",
|
"203.0.113.11",
|
||||||
dict(VALID_ROOM, room_name="Older Room", protocol_version=2),
|
dict(VALID_ROOM, room_name="Older Room", protocol_version=2),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue