Files
Rendezvous/docs/contracts/udp-v1.md
T
KyuubiYoru 6d076c281a
quality-gate / quality (push) Successful in 59s
feat: implement authenticated NAT mediator (#11)
Closes #11
2026-07-16 07:37:02 +02:00

4.9 KiB
Raw Blame History

UDP presence and NAT-punch contract v1

Tracking: #4, #11

The UDP mediator accepts the frozen bounded presence envelope below and native LiteNetLib NAT-introduction requests. Both forms associate an authenticated mediation handle with the packet's observed public source endpoint and the sender's reported local endpoint. Neither form carries gameplay packets.

All multi-byte integers use network byte order. UUID bytes use the canonical RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the mixed-endian layout returned by Guid.ToByteArray().

Datagram layout

Offset Size Field
0 2 Magic bytes 52 56 (RV).
2 1 Contract version, exactly 01.
3 1 Message type: host presence 01, client presence 02.
4 1 Flags, exactly 00 in v1.
5 16 Non-empty mediation-handle UUID.
21 1 Address family: IPv4 04, IPv6 06.
22 1 Address length: 04 for IPv4, 10 for IPv6.
23 4 or 16 Raw local IP address bytes.
next 2 Local UDP port, 165535.
next 1 Capability length, 1192.
next variable ASCII base64url capability, without padding.

No trailing bytes are permitted. The whole datagram is limited to 1,200 bytes, well below common Internet path MTUs. The v1 capability limit is 192 characters, which also keeps any value passed through LiteNetLib's 256-character NAT token surface safely below that library boundary.

Validation and failure behavior

Decoders return one stable failure category: oversized, truncated, invalid magic, unsupported version, unknown message type, non-zero flags, invalid handle, invalid address family, invalid address, invalid port, invalid capability, or trailing data. Unknown versions and message types are rejected; they are never interpreted as v1.

The address-family byte, encoded address length, and parsed address must agree. The service derives the public endpoint from the UDP packet source and never trusts a client-supplied public address. Reported local endpoints are candidates only and grant no authority.

Capabilities are short-lived, single-purpose, scoped to one mediation handle, and compared without exposing them in logs. A valid-looking packet does not prove authorization until the capability is checked. Invalid packets receive no UDP response, preventing the mediator from becoming an amplification oracle. For the frozen envelope, HostPresence is resolved against either the listing's host-presence capability or an attempt's host-role capability. ClientPresence is resolved only against the attempt's client-role capability. Handles are globally distinct in the active store, so this does not permit role confusion.

Native LiteNetLib request token

A game using LiteNetLib sends NatPunchModule.SendNatIntroduceRequest from its gameplay NetManager. The additionalInfo value is produced by NatPunchRequestTokenCodec and is exactly 192 ASCII characters:

rv1:<role>:<32 lowercase handle hex>:<43-character capability><dot padding>

role is p for listing host-presence refresh, h for the host side of a join attempt, or c for its client side. Padding is canonical and leaves the token below LiteNetLib's 256-character ceiling. Its fixed size also ensures that the two authenticated introduction responses remain within the 2.0 response-byte budget. Tokens with a wrong length, role, handle, capability, or padding receive no response.

The mediator runs LiteNetLib in bounded manual-poll mode. Its packet layer admits only the pinned native NatIntroduceRequest frame, consumes every inbound frame before LiteNetLib can act on it, and uses NatPunchModule only to emit authorized introductions. Native and frozen v1 inputs reach the same atomic role/capability checks. Only the packet source is used as the public endpoint. A claimed private candidate is retained only when it is private unicast, matches the observed address family, and both authorized peers were observed behind the same public address; otherwise the observed public endpoint is substituted. IPv4 punching is required. IPv6 sources must be observed global unicast and both roles must use IPv6; IPv6 NAT traversal remains best-effort rather than a v1 release requirement.

The second valid contribution atomically consumes the introduction and starts the connection-ticket lifetime. NatIntroduce is called once with the distinct 43-character connection ticket. Reordered and exact duplicate requests are idempotent. Endpoint substitution, cross-role use, stale host presence, expired or cancelled attempts, malformed packets, and gameplay payloads produce no introduction and create no mediator queue or endpoint state.

Frozen envelopes may refresh listing presence over IPv6 because that operation has no response. IPv6 attempt contributions must use the fixed-size native token; the shorter frozen IPv6 envelope cannot fund two IPv6 introduction frames within the 2.0 response-byte ceiling and is therefore dropped without response.