From 21d17c4211ba72cac4aea3f7717e9e199ce69986 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 23 Aug 2026 20:52:57 -0400 Subject: [PATCH] feat: add ephemeral friend presence and invites Add short-lived capability channels for online friend status and public-room invitations without storing accounts, fingerprints, social graphs, or offline messages. --- README.md | 4 + docs/API.md | 23 +- netfishing_discovery/registry.py | 20 ++ netfishing_discovery/server.py | 79 ++++++- netfishing_discovery/social_registry.py | 277 ++++++++++++++++++++++++ tests/test_http_api.py | 90 ++++++++ tests/test_social_registry.py | 136 ++++++++++++ 7 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 netfishing_discovery/social_registry.py create mode 100644 tests/test_social_registry.py diff --git a/README.md b/README.md index 16f6ec7..c66d342 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ The service also runs a small UDP rendezvous. It observes packets sent from the 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. +Friend presence and live room invitations use the same service as short-lived capability channels. +There are no discovery accounts, durable friend records, or offline messages; friendship state +stays on each player's device. + ## Requirements - Python 3.11 or newer diff --git a/docs/API.md b/docs/API.md index 01f37bb..3ce1434 100644 --- a/docs/API.md +++ b/docs/API.md @@ -77,6 +77,25 @@ UDP rendezvous from the same ENet socket used for the gameplay connection. 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. +## Live friend presence and invitations + +These endpoints are an ephemeral capability channel, not an account or social-graph service. The +server does not receive player identity fingerprints or a complete friend list, writes nothing to +a database, and discards presence and invitations after short timeouts. + +`POST /v1/presence` publishes or removes the caller's status under one or more secret write +capabilities. `POST /v1/presence/query` reads the corresponding hashed channels. A published room +is returned only while it remains a verified, version-compatible public room. + +`POST /v1/invitations/poll` marks the supplied capability inboxes as currently reachable and +consumes any live invitations. `POST /v1/invitations` sends a public-room invitation to one inbox. +The send fails with `409 friend_offline` and `This person needs to be online to do this.` unless +that inbox was polled recently. There is no offline delivery. + +All four requests include the normal `game_version` and `protocol_version`. Capability values are +64 lowercase hexadecimal characters and must be exchanged by the games during an authenticated, +live NETfishing session. + ## Error shape ```json @@ -94,4 +113,6 @@ same-socket UDP punch packets before ENet retries its connection. - `X-Forwarded-For` is honored only when the immediate peer belongs to an explicitly configured trusted proxy CIDR. - Lease tokens authorize update and deletion but are never included in public listings. -- The service is an ephemeral directory, not a source of gameplay authority. +- Friend capability channels are short-lived and reveal neither identity fingerprints nor a full + social graph to the service. +- The service is an ephemeral directory, not a source of gameplay or friendship authority. diff --git a/netfishing_discovery/registry.py b/netfishing_discovery/registry.py index 0aea285..f637a1e 100644 --- a/netfishing_discovery/registry.py +++ b/netfishing_discovery/registry.py @@ -224,6 +224,26 @@ class RoomRegistry: self._purge_locked(now) return len(self._rooms) + def get_public_room( + self, + room_id: str, + *, + game_version: str | None = None, + protocol_version: int | None = None, + ) -> RoomAdvertisement | None: + now = self._clock() + with self._lock: + self._purge_locked(now) + lease = self._rooms.get(room_id) + room = lease.advertisement if lease is not None else None + if room is None or not room.verified: + return None + if game_version is not None and room.game_version != game_version: + return None + if protocol_version is not None and room.protocol_version != protocol_version: + return None + return room + def verify_endpoint( self, room_id: str, diff --git a/netfishing_discovery/server.py b/netfishing_discovery/server.py index 4537e41..e1a39ac 100644 --- a/netfishing_discovery/server.py +++ b/netfishing_discovery/server.py @@ -27,10 +27,11 @@ from .registry import ( RoomRegistry, ValidationError, ) +from .social_registry import FriendOfflineError, SocialRegistry LOGGER = logging.getLogger("netfishing.discovery") -DEFAULT_MAX_BODY_BYTES = 8 * 1024 +DEFAULT_MAX_BODY_BYTES = 64 * 1024 TRAVERSAL_PACKET_PREFIX = b"NETFISHING_TRAVERSAL_V1 " MAX_TRAVERSAL_PACKET_BYTES = 2048 @@ -94,13 +95,19 @@ class DiscoveryHTTPServer(ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True - def __init__(self, config: ServerConfig, registry: RoomRegistry | None = None) -> None: + def __init__( + self, + config: ServerConfig, + registry: RoomRegistry | None = None, + social_registry: SocialRegistry | None = None, + ) -> None: self.config = config self.registry = registry or RoomRegistry( config.room_ttl_seconds, config.max_rooms, config.max_rooms_per_address, ) + self.social_registry = social_registry or SocialRegistry() super().__init__((config.bind_host, config.bind_port), DiscoveryRequestHandler) @@ -111,6 +118,7 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): def do_GET(self) -> None: route = urlsplit(self.path) if route.path == "/health": + presence_count, invitation_count = self.server.social_registry.counts() self._send_json( HTTPStatus.OK, { @@ -119,6 +127,8 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): "version": __version__, "build_revision": self.server.config.build_revision, "active_rooms": self.server.registry.room_count(), + "active_presence_channels": presence_count, + "pending_invitations": invitation_count, }, ) return @@ -163,6 +173,14 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): def do_POST(self) -> None: path = urlsplit(self.path).path + if path in [ + "/v1/presence", + "/v1/presence/query", + "/v1/invitations", + "/v1/invitations/poll", + ]: + self._handle_social_post(path) + return room_id = self._room_id_for_suffix(path, "/join-attempts") if room_id is not None: try: @@ -205,6 +223,63 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler): }, ) + def _handle_social_post(self, path: str) -> None: + payload = self._read_json_object() + if payload is None or self._reject_incompatible_game_version(payload): + return + try: + game_version = payload.get("game_version") + protocol_version = payload.get("protocol_version") + if not isinstance(game_version, str): + raise ValidationError("game_version must be a string") + if ( + isinstance(protocol_version, bool) + or not isinstance(protocol_version, int) + or protocol_version < 1 + ): + raise ValidationError("protocol_version must be a positive integer") + if path == "/v1/presence": + channels = self.server.social_registry.publish_presence( + self._client_address(), payload + ) + self._send_json(HTTPStatus.OK, {"channels": channels}) + return + if path == "/v1/presence/query": + presence = self.server.social_registry.query_presence( + payload.get("channels"), + game_version.strip(), + protocol_version, + self.server.registry, + ) + self._send_json(HTTPStatus.OK, {"presence": presence}) + return + if path == "/v1/invitations/poll": + invitations = self.server.social_registry.poll_invitations( + payload.get("inbox_tokens"), + game_version.strip(), + protocol_version, + self.server.registry, + ) + self._send_json(HTTPStatus.OK, {"invitations": invitations}) + return + invitation = self.server.social_registry.send_invitation( + payload.get("inbox_token"), + payload.get("room_id", ""), + game_version.strip(), + protocol_version, + self.server.registry, + ) + self._send_json( + HTTPStatus.CREATED, + {"invite_id": invitation.invite_id}, + ) + except FriendOfflineError as error: + self._send_error(HTTPStatus.CONFLICT, "friend_offline", str(error)) + except RoomNotFoundError as error: + self._send_error(HTTPStatus.NOT_FOUND, "room_not_found", str(error)) + except ValidationError as error: + self._send_error(HTTPStatus.BAD_REQUEST, "invalid_social_request", str(error)) + def do_PUT(self) -> None: room_id = self._room_id_from_path() if room_id is None: diff --git a/netfishing_discovery/social_registry.py b/netfishing_discovery/social_registry.py new file mode 100644 index 0000000..dce3ce4 --- /dev/null +++ b/netfishing_discovery/social_registry.py @@ -0,0 +1,277 @@ +"""Ephemeral, capability-addressed friend presence and live invitations.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import secrets +import threading +import time +import unicodedata +from collections.abc import Callable, Mapping +from typing import Any + +from .registry import RoomAdvertisement, RoomNotFoundError, RoomRegistry, ValidationError + + +PRESENCE_DOMAIN = "NETFISHING_PRESENCE_V1:" +INVITE_DOMAIN = "NETFISHING_INVITE_V1:" +SOCIAL_TOKEN_LENGTH = 64 +MAX_SOCIAL_CHANNELS_PER_REQUEST = 200 +MAX_SOCIAL_RECORDS = 8192 +MAX_SOCIAL_RECORDS_PER_ADDRESS = 512 +MAX_INVITES_PER_INBOX = 8 +PRESENCE_TTL_SECONDS = 45.0 +INBOX_REACHABLE_SECONDS = 15.0 +INVITE_TTL_SECONDS = 30.0 + + +class FriendOfflineError(Exception): + pass + + +@dataclass(frozen=True, slots=True) +class Presence: + channel: str + display_name: str + room_id: str + game_version: str + protocol_version: int + address: str + expires_at: float + + +@dataclass(frozen=True, slots=True) +class Invitation: + invite_id: str + inbox_id: str + room_id: str + expires_at: float + + +def _capability_id(domain: str, token: str) -> str: + return hashlib.sha256(f"{domain}{token}".encode("utf-8")).hexdigest() + + +def _clean_token(value: Any, field_name: str) -> str: + if not isinstance(value, str) or len(value) != SOCIAL_TOKEN_LENGTH: + raise ValidationError(f"{field_name} must be a 64-character capability") + if any(character not in "0123456789abcdef" for character in value): + raise ValidationError(f"{field_name} must contain lowercase hexadecimal characters") + return value + + +def _clean_token_list(value: Any, field_name: str) -> list[str]: + if not isinstance(value, list) or len(value) > MAX_SOCIAL_CHANNELS_PER_REQUEST: + raise ValidationError( + f"{field_name} must contain at most {MAX_SOCIAL_CHANNELS_PER_REQUEST} entries" + ) + result: list[str] = [] + for raw_token in value: + token = _clean_token(raw_token, field_name) + if token not in result: + result.append(token) + return result + + +def _clean_display_name(value: Any) -> str: + if not isinstance(value, str): + raise ValidationError("display_name must be a string") + result = unicodedata.normalize("NFC", value).strip() + if not result or len(result) > 24: + raise ValidationError("display_name must contain between 1 and 24 characters") + if any(unicodedata.category(character).startswith("C") for character in result): + raise ValidationError("display_name contains unsupported control characters") + return result + + +def _clean_protocol(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValidationError("protocol_version must be a positive integer") + return value + + +class SocialRegistry: + """Stores no accounts and no durable data; every record expires in seconds.""" + + def __init__(self, clock: Callable[[], float] = time.time) -> None: + self._clock = clock + self._presence: dict[str, Presence] = {} + self._inbox_polls: dict[str, float] = {} + self._invites: dict[str, list[Invitation]] = {} + self._lock = threading.RLock() + + def publish_presence( + self, address: str, payload: Mapping[str, Any] + ) -> list[str]: + write_tokens = _clean_token_list(payload.get("write_tokens"), "write_tokens") + online = payload.get("online") + if not isinstance(online, bool): + raise ValidationError("online must be a boolean") + channels = [_capability_id(PRESENCE_DOMAIN, token) for token in write_tokens] + now = self._clock() + with self._lock: + self._purge_locked(now) + if not online: + for channel in channels: + current = self._presence.get(channel) + if current is not None and current.address == address: + del self._presence[channel] + return channels + display_name = _clean_display_name(payload.get("display_name")) + game_version = payload.get("game_version") + if not isinstance(game_version, str) or not game_version.strip(): + raise ValidationError("game_version must be a non-empty string") + protocol_version = _clean_protocol(payload.get("protocol_version")) + room_id = payload.get("room_id", "") + if not isinstance(room_id, str) or len(room_id) > 64 or "/" in room_id: + raise ValidationError("room_id is invalid") + new_channels = [channel for channel in channels if channel not in self._presence] + address_count = sum( + 1 for record in self._presence.values() if record.address == address + ) + if len(self._presence) + len(new_channels) > MAX_SOCIAL_RECORDS: + raise ValidationError("the live presence directory is at capacity") + if address_count + len(new_channels) > MAX_SOCIAL_RECORDS_PER_ADDRESS: + raise ValidationError("too many live presence channels from this address") + for channel in channels: + self._presence[channel] = Presence( + channel=channel, + display_name=display_name, + room_id=room_id, + game_version=game_version.strip(), + protocol_version=protocol_version, + address=address, + expires_at=now + PRESENCE_TTL_SECONDS, + ) + return channels + + def query_presence( + self, + channels_value: Any, + game_version: str, + protocol_version: int, + rooms: RoomRegistry, + ) -> list[dict[str, Any]]: + channels = _clean_token_list(channels_value, "channels") + now = self._clock() + result: list[dict[str, Any]] = [] + with self._lock: + self._purge_locked(now) + records = [self._presence.get(channel) for channel in channels] + for record in records: + if ( + record is None + or record.game_version != game_version + or record.protocol_version != protocol_version + ): + continue + room = rooms.get_public_room( + record.room_id, + game_version=game_version, + protocol_version=protocol_version, + ) if record.room_id else None + result.append( + { + "channel": record.channel, + "display_name": record.display_name, + "online": True, + "room": room.public_dict() if room is not None else {}, + } + ) + return result + + def poll_invitations( + self, + inbox_tokens_value: Any, + game_version: str, + protocol_version: int, + rooms: RoomRegistry, + ) -> list[dict[str, Any]]: + tokens = _clean_token_list(inbox_tokens_value, "inbox_tokens") + inbox_ids = [_capability_id(INVITE_DOMAIN, token) for token in tokens] + now = self._clock() + invitations: list[Invitation] = [] + with self._lock: + self._purge_locked(now) + for inbox_id in inbox_ids: + self._inbox_polls[inbox_id] = now + invitations.extend(self._invites.pop(inbox_id, [])) + result: list[dict[str, Any]] = [] + for invitation in invitations: + room = rooms.get_public_room( + invitation.room_id, + game_version=game_version, + protocol_version=protocol_version, + ) + if room is None: + continue + result.append( + { + "invite_id": invitation.invite_id, + "inbox_id": invitation.inbox_id, + "room": room.public_dict(), + } + ) + return result + + def send_invitation( + self, + inbox_token_value: Any, + room_id: str, + game_version: str, + protocol_version: int, + rooms: RoomRegistry, + ) -> Invitation: + inbox_token = _clean_token(inbox_token_value, "inbox_token") + if not isinstance(room_id, str) or not room_id or len(room_id) > 64: + raise ValidationError("room_id is invalid") + room = rooms.get_public_room( + room_id, + game_version=game_version, + protocol_version=protocol_version, + ) + if room is None: + raise RoomNotFoundError("room is not available") + inbox_id = _capability_id(INVITE_DOMAIN, inbox_token) + now = self._clock() + with self._lock: + self._purge_locked(now) + last_poll = self._inbox_polls.get(inbox_id, 0.0) + if now - last_poll > INBOX_REACHABLE_SECONDS: + raise FriendOfflineError("This person needs to be online to do this.") + queue = self._invites.setdefault(inbox_id, []) + if len(queue) >= MAX_INVITES_PER_INBOX: + queue.pop(0) + invitation = Invitation( + invite_id=secrets.token_hex(16), + inbox_id=inbox_id, + room_id=room.room_id, + expires_at=now + INVITE_TTL_SECONDS, + ) + queue.append(invitation) + return invitation + + def counts(self) -> tuple[int, int]: + now = self._clock() + with self._lock: + self._purge_locked(now) + return len(self._presence), sum(len(queue) for queue in self._invites.values()) + + def _purge_locked(self, now: float) -> None: + self._presence = { + channel: record + for channel, record in self._presence.items() + if record.expires_at > now + } + self._inbox_polls = { + inbox_id: polled_at + for inbox_id, polled_at in self._inbox_polls.items() + if now - polled_at <= INBOX_REACHABLE_SECONDS + } + for inbox_id in list(self._invites): + queue = [invite for invite in self._invites[inbox_id] if invite.expires_at > now] + if queue: + self._invites[inbox_id] = queue + else: + del self._invites[inbox_id] diff --git a/tests/test_http_api.py b/tests/test_http_api.py index 87d3b20..d5d4833 100644 --- a/tests/test_http_api.py +++ b/tests/test_http_api.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib from http.client import HTTPConnection import json import socketserver @@ -203,6 +204,95 @@ class DiscoveryHTTPTests(unittest.TestCase): self.assertEqual(body["version"], "0.16.2-alpha") self.assertEqual(body["build_revision"], "test-build") + def test_live_friend_presence_and_invitation_endpoints(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, + ) + + inbox_token = "b" * 64 + social_version = { + "game_version": VALID_ROOM["game_version"], + "protocol_version": VALID_ROOM["protocol_version"], + } + status, polled = self.request( + "POST", + "/v1/invitations/poll", + dict(social_version, inbox_tokens=[inbox_token]), + ) + self.assertEqual(status, 200) + assert polled is not None + self.assertEqual(polled["invitations"], []) + + status, _sent = self.request( + "POST", + "/v1/invitations", + dict(social_version, inbox_token=inbox_token, room_id=room_id), + ) + self.assertEqual(status, 201) + status, polled = self.request( + "POST", + "/v1/invitations/poll", + dict(social_version, inbox_tokens=[inbox_token]), + ) + self.assertEqual(status, 200) + assert polled is not None + self.assertEqual(polled["invitations"][0]["room"]["room_id"], room_id) + + status, offline = self.request( + "POST", + "/v1/invitations", + dict(social_version, inbox_token="c" * 64, room_id=room_id), + ) + self.assertEqual(status, 409) + assert offline is not None + self.assertEqual(offline["error"]["code"], "friend_offline") + self.assertEqual( + offline["error"]["message"], + "This person needs to be online to do this.", + ) + + write_token = "d" * 64 + channel = hashlib.sha256( + f"NETFISHING_PRESENCE_V1:{write_token}".encode() + ).hexdigest() + status, _published = self.request( + "POST", + "/v1/presence", + dict( + social_version, + write_tokens=[write_token], + online=True, + display_name="Voyager", + room_id=room_id, + ), + ) + self.assertEqual(status, 200) + status, presence = self.request( + "POST", + "/v1/presence/query", + dict(social_version, channels=[channel]), + ) + self.assertEqual(status, 200) + assert presence is not None + self.assertEqual(presence["presence"][0]["room"]["room_id"], room_id) + + self.request( + "DELETE", + f"/v1/rooms/{room_id}", + token=str(created["lease_token"]), + ) + def test_udp_rendezvous_does_not_spawn_per_packet_threads(self) -> None: self.assertFalse(issubclass(TraversalUDPServer, socketserver.ThreadingMixIn)) diff --git a/tests/test_social_registry.py b/tests/test_social_registry.py new file mode 100644 index 0000000..8f4fbf2 --- /dev/null +++ b/tests/test_social_registry.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import hashlib +import unittest + +from netfishing_discovery.registry import RoomRegistry, ValidationError +from netfishing_discovery.social_registry import ( + FriendOfflineError, + INBOX_REACHABLE_SECONDS, + PRESENCE_TTL_SECONDS, + SocialRegistry, +) + + +GAME_VERSION = "0.16.2-alpha" +PROTOCOL_VERSION = 9 +VALID_ROOM = { + "room_name": "Pond Friends", + "port": 7777, + "current_players": 1, + "max_players": 8, + "game_version": GAME_VERSION, + "protocol_version": PROTOCOL_VERSION, +} + + +class FakeClock: + def __init__(self) -> None: + self.now = 1_000.0 + + def __call__(self) -> float: + return self.now + + +def capability_id(domain: str, token: str) -> str: + return hashlib.sha256(f"{domain}{token}".encode()).hexdigest() + + +class SocialRegistryTests(unittest.TestCase): + def setUp(self) -> None: + self.clock = FakeClock() + self.rooms = RoomRegistry(ttl_seconds=300.0, clock=self.clock) + room, _lease, verification = self.rooms.create( + "203.0.113.10", VALID_ROOM + ) + self.room = self.rooms.verify_endpoint( + room.room_id, verification, "203.0.113.10", 7777 + ) + self.social = SocialRegistry(clock=self.clock) + + def test_presence_is_capability_addressed_and_expires(self) -> None: + write_token = "a" * 64 + channel = capability_id("NETFISHING_PRESENCE_V1:", write_token) + self.social.publish_presence( + "203.0.113.10", + { + "write_tokens": [write_token], + "online": True, + "display_name": "Voyager", + "room_id": self.room.room_id, + "game_version": GAME_VERSION, + "protocol_version": PROTOCOL_VERSION, + }, + ) + presence = self.social.query_presence( + [channel], GAME_VERSION, PROTOCOL_VERSION, self.rooms + ) + self.assertEqual(len(presence), 1) + self.assertEqual(presence[0]["channel"], channel) + self.assertEqual(presence[0]["room"]["room_id"], self.room.room_id) + + self.clock.now += PRESENCE_TTL_SECONDS + self.assertEqual( + self.social.query_presence( + [channel], GAME_VERSION, PROTOCOL_VERSION, self.rooms + ), + [], + ) + + def test_invitation_requires_a_current_live_poll(self) -> None: + inbox_token = "b" * 64 + with self.assertRaisesRegex( + FriendOfflineError, + "This person needs to be online to do this", + ): + self.social.send_invitation( + inbox_token, + self.room.room_id, + GAME_VERSION, + PROTOCOL_VERSION, + self.rooms, + ) + + self.assertEqual( + self.social.poll_invitations( + [inbox_token], GAME_VERSION, PROTOCOL_VERSION, self.rooms + ), + [], + ) + sent = self.social.send_invitation( + inbox_token, + self.room.room_id, + GAME_VERSION, + PROTOCOL_VERSION, + self.rooms, + ) + received = self.social.poll_invitations( + [inbox_token], GAME_VERSION, PROTOCOL_VERSION, self.rooms + ) + self.assertEqual(len(received), 1) + self.assertEqual(received[0]["invite_id"], sent.invite_id) + self.assertEqual(received[0]["room"]["room_id"], self.room.room_id) + + self.clock.now += INBOX_REACHABLE_SECONDS + 0.01 + with self.assertRaises(FriendOfflineError): + self.social.send_invitation( + inbox_token, + self.room.room_id, + GAME_VERSION, + PROTOCOL_VERSION, + self.rooms, + ) + + def test_invalid_capabilities_are_rejected(self) -> None: + with self.assertRaises(ValidationError): + self.social.publish_presence( + "203.0.113.10", + { + "write_tokens": ["not-a-capability"], + "online": False, + }, + ) + + +if __name__ == "__main__": + unittest.main()