Compare commits

..

4 Commits

Author SHA1 Message Date
KyuubiYoru 6d076c281a feat: implement authenticated NAT mediator (#11)
quality-gate / quality (push) Successful in 59s
Closes #11
2026-07-16 07:37:02 +02:00
KyuubiYoru 1baa1055dc feat: implement scoped join attempts and tickets (#10)
quality-gate / quality (push) Successful in 1m1s
Closes #10
2026-07-16 06:56:30 +02:00
KyuubiYoru 06c3973ce7 feat: add publisher and browser client SDK (#9)
quality-gate / quality (push) Successful in 1m6s
Closes #9
2026-07-16 06:27:44 +02:00
KyuubiYoru a9a2b3db35 feat: add bounded compatible session browser (#8)
quality-gate / quality (push) Successful in 57s
Closes #8
2026-07-16 06:06:29 +02:00
60 changed files with 5957 additions and 218 deletions
+200 -8
View File
@@ -207,6 +207,13 @@
"format": "int32" "format": "int32"
} }
}, },
{
"name": "excludeFull",
"in": "query",
"schema": {
"type": "boolean"
}
},
{ {
"name": "cursor", "name": "cursor",
"in": "query", "in": "query",
@@ -226,8 +233,18 @@
} }
} }
}, },
"501": { "400": {
"description": "Not Implemented", "description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -530,6 +547,40 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "contractVersion",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "gameId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "environmentId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "protocolVersion",
"in": "query",
"required": true,
"schema": {
"type": "integer",
"format": "uint32"
}
} }
], ],
"responses": { "responses": {
@@ -543,8 +594,28 @@
} }
} }
}, },
"501": { "400": {
"description": "Not Implemented", "description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -615,8 +686,28 @@
} }
} }
}, },
"501": { "400": {
"description": "Not Implemented", "description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -655,8 +746,109 @@
} }
} }
}, },
"501": { "400": {
"description": "Not Implemented", "description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"429": {
"description": "Too Many Requests",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/v1/join-attempts/{attemptId}": {
"delete": {
"tags": [
"Join attempts"
],
"operationId": "CancelJoinAttempt",
"parameters": [
{
"name": "attemptId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "X-Rendezvous-Client-Punch-Capability",
"in": "header",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -64,7 +64,7 @@ them but must not raise them without security review.
| Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort | | Browser page | 100 listings and 256 KiB encoded response; opaque cursor; stable bounded sort |
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response | | UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
| Opaque HTTP credential | 1,024 bytes encoded | | Opaque HTTP credential | 1,024 bytes encoded |
| UDP capability or ticket | 768 bytes encoded, with the complete datagram still at most 1,200 bytes | | UDP capability or connection ticket | 192 base64url characters; NAT punch capabilities also remain below LiteNetLib's 256-character token ceiling; complete datagram at most 1,200 bytes |
| Clock skew | 30 seconds maximum when validating issued/not-before/expiry times | | Clock skew | 30 seconds maximum when validating issued/not-before/expiry times |
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension | | Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
| Host presence freshness | 20 seconds | | Host presence freshness | 20 seconds |
@@ -60,12 +60,14 @@ the supplied ID.
### UDP presence ### UDP presence
Only a structurally valid `HostPresence` datagram with the issued capability can Only a structurally valid frozen `HostPresence` envelope or native LiteNetLib
refresh presence. The public endpoint is the UDP packet's observed source on the host-presence request with the issued capability can refresh presence. The public
host's gameplay socket; the HTTP API never accepts one. The bounded local candidate endpoint is the UDP packet's observed source on the host's gameplay socket; the
comes from the authenticated datagram. Invalid, unknown, or client-presence packets HTTP API never accepts one. The bounded local candidate comes from the authenticated
receive no response. Presence expiry demotes public visibility but keeps the lease, packet. Invalid or unknown inputs receive no response. ADR 0009 defines the later
so the same handle can restore visibility without changing session identity. attempt-role use of frozen `ClientPresence` and native host/client requests.
Presence expiry demotes public visibility but keeps the lease, so the same handle
can restore visibility without changing session identity.
Public listing responses contain bounded listing data only. They never contain Public listing responses contain bounded listing data only. They never contain
public/local endpoints, lease tokens, presence capabilities, fingerprints, store public/local endpoints, lease tokens, presence capabilities, fingerprints, store
@@ -0,0 +1,51 @@
# ADR 0006: bounded compatible session browser
- Status: Accepted
- Date: 2026-07-16
- Tracking: #8
## Decision
The public list endpoint requires game, environment, and exact gameplay protocol.
Region is optional, page size is 1100, and callers may exclude sessions whose
advisory current-player count has reached the advertised maximum. Lists contain
public sessions only and only while both lease and authenticated host presence are
fresh. Unlisted sessions never appear in a list; they may be retrieved directly by
their 128-bit unguessable listing ID only when the caller also supplies the exact
game, environment, and protocol scope.
Results use ascending opaque listing ID as a deterministic keyset. A cursor carries
the last ID plus every compatibility/filter field, a five-minute expiry, and an
HMAC-SHA256 signature under a per-process key. Tampering, expiry, or reuse with a
different tenant/protocol/region/full filter returns `InvalidRequest`. Restart
rotates the key, matching the loss of ephemeral listings.
Pagination is a bounded live view, not a database snapshot. A record that remains
eligible and whose ID is greater than the cursor is returned exactly once. Records
removed or made stale disappear immediately. A record created after a page whose ID
sorts before that page's cursor is outside that traversal; callers refresh from the
first page to discover new sessions. This avoids skips or duplicates among stable
eligible records without retaining per-browser snapshot state.
The store reads at most page size plus one record. The service serializes against
the 256 KiB response ceiling and shortens a page before returning it when metadata
makes the requested count too large. A continuation cursor is emitted whenever an
extra or byte-trimmed record remains. All cursor, page, metadata, property, scalar,
and collection sizes are bounded before untrusted allocation can grow without a
ceiling.
Browser DTOs are fresh copies containing only opaque listing ID, exact compatibility,
region, visibility/trust presentation, advisory capacity, build/display labels, and
policy-validated string metadata. They contain no observed endpoint, lease,
capability, ticket, credential fingerprint, derivation salt, principal subject, or
store key. Metadata is display text: JSON encoding escapes markup, but game UI must
still render values as text and must never execute markup, interpret endpoints, or
use metadata for authorization.
## Consequences
- Cross-game, cross-environment, incompatible, stale, revoked, expired, unlisted,
and optionally full sessions are removed before response construction.
- Direct unlisted lookup is suitable for an out-of-band invite carrying the opaque
ID; human join codes remain future work and require their own bounded abuse model.
- Host capacity remains advisory. The host makes the final admission decision.
@@ -0,0 +1,53 @@
# ADR 0007: caller-owned .NET publisher and browser SDK
- Status: Accepted
- Date: 2026-07-16
- Tracking: #9
## Decision
The .NET client package exposes separate publisher and browser interfaces plus
concrete clients over a caller-supplied `HttpClient`. The caller owns that client,
its handler, base address, connection pool, proxy, and lifetime. SDK operations
dispose every request, response, and response body they create, but never dispose
the supplied client. The package targets `netstandard2.1`, depends only on the
wire-contract package and LiteNetLib, and contains no Godot types, global client,
service URL, publisher secret, or embedded game credential.
Every operation returns `RendezvousClientResult<T>` with a stable error code,
message, and optional retry guidance. Cancellation remains exceptional through
the caller's `CancellationToken`; transport failures become `ServiceUnavailable`.
Response bodies are streamed under the contract's 256 KiB browser ceiling before
deserialization. Invalid or oversized success bodies become `InternalError` and
never escape as partially trusted contract objects.
The SDK retries only operations whose duplicate execution is safe: scoped reads,
idempotency-keyed registration, lease renewal with the same lease token, complete
resource update, and lease-token deregistration. It honors bounded server retry
guidance and otherwise uses capped exponential backoff with jitter. Each retry
creates a fresh HTTP request while preserving the caller's registration
idempotency key. Configuration is copied on construction so later option mutation
cannot change an in-flight client's behavior.
`PublishedSession` holds the server-issued lease and presence capabilities needed
by the host. Its string representation always redacts them. Update requests are
copied before the lease token is attached, so the SDK never mutates caller-owned
DTOs. The browser exposes one-page calls and bounded cursor traversal; cursor
values remain opaque and caller requests remain unchanged.
Lease maintenance is explicit. Creating a `SessionLeaseMaintainer` starts no task;
the game chooses when to call `RunAsync`, owns cancellation, and awaits
`DisposeAsync`. The loop uses the latest server-provided renewal interval and
returns a distinct cancelled, disposed, lost-lease, or failed result. Terminal
authorization, expiry, and missing-lease responses also raise `LeaseLost` so the
host can stop advertising or re-register deliberately.
## Consequences
- SpaceGame and Unscouted can inject the publisher/browser interfaces in tests
without an engine runtime or real network.
- Games must configure an absolute `HttpClient.BaseAddress` (or equivalent
handler routing), obtain publisher credentials from their deployment boundary,
and explicitly run and dispose lease maintenance.
- The versioned client public-API snapshot and live-server integration tests fail
together when SDK and HTTP contracts drift.
@@ -0,0 +1,66 @@
# ADR 0008: scoped join attempts and one-time connection tickets
- Status: Accepted
- Date: 2026-07-16
- Tracking: #10
## Decision
Join creation is an unauthenticated public operation because v1 does not treat a
Rendezvous caller as game identity. The HTTP source address is normalized and
converted to a process-keyed opaque subject for idempotency and bounded policy
accounting; raw addresses and the derived subject are never returned or logged.
A successful request means only that this network client may try to connect to
this active session. It does not reserve capacity or grant gameplay admission.
Creation validates the v1 contract, caller idempotency key, enabled tenant policy,
exact gameplay protocol, listing scope, live lease, and fresh authenticated host
presence in one atomic store operation. A listing advertised as full remains
joinable because its player count is advisory and the game host owns the final
capacity, identity, ban, and admission decision.
Each attempt derives independent host-punch, client-punch, and connection-ticket
credentials plus opaque attempt and mediation IDs from a process-ephemeral HMAC
key, the client subject, the complete canonical request fingerprint, a fresh salt,
and a purpose/role label. Credentials are 32-byte base64url values (43 characters),
below both the 192-character Rendezvous capability ceiling and LiteNetLib's
256-character NAT token ceiling. State retains keyed credential fingerprints,
derivation inputs, and salt—not issued plaintext. All diagnostic string
representations redact credentials and derivation material.
The client receives only its punch capability. A host polls its own listing with
the lease token in `X-Rendezvous-Lease-Token` and receives only host-role
capabilities through a signed, listing-bound, five-minute cursor. Replaying an
identical join request returns the same live attempt; changing the request under
the same owner/key conflicts. A client may cancel with its punch capability in
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically removes the
attempt. Listing deletion, expiry, revocation, or process restart removes every
associated attempt and credential fingerprint.
Endpoint binding remains role- and capability-specific. The first endpoint
observed for a role wins atomically; an exact UDP duplicate is idempotent, while
endpoint or role substitution is rejected. An introduction is consumable once
only after both roles bind, so concurrent attempts for the same listing cannot
cross-wire.
The connection ticket is distinct from both punch capabilities and is reproduced
only after introduction succeeds. Its window begins at that moment and lasts at
most 20 seconds without outliving the 30-second attempt. The server has an atomic
fingerprint-consumption seam for mediator tests and revocation. On the game host,
the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest,
accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/
revoked/replayed tickets, and zeroes retained digests and key material on disposal.
Issue #11 carries the ticket in the authenticated introduction; issue #12 wires
authorization and consumption into the caller-owned LiteNetLib coordinator.
## Consequences
- A join attempt is transport authorization, never proof of player identity or a
game slot.
- Network-address-derived subjects are process-local abuse/idempotency scopes,
not stable user identifiers; stronger authenticated player scopes require a
future game-owned identity contract.
- Cancellation after a ticket has reached a host must also revoke that host's
local validator entry; coordinator wiring owns that race in issue #12.
- Capability and ticket plaintext never enter browser results, state snapshots,
logs, metrics, or generated string representations.
@@ -0,0 +1,68 @@
# ADR 0009: authenticated bounded LiteNetLib NAT mediator
- Status: Accepted
- Date: 2026-07-16
- Tracking: #11
## Decision
The server owns one LiteNetLib `NetManager` and its `NatPunchModule` on the
configured UDP endpoint. It runs in manual mode with a configured maximum number
of datagrams per poll and a short caller-owned poll interval. LiteNetLib events
are unsynchronized so authenticated requests are processed immediately on that
single polling path rather than accumulated in an unbounded event queue. The
mediator never accepts a LiteNetLib gameplay connection or handles application
payloads.
The packet layer also consumes the frozen v1 presence envelope on the same
socket. Native NAT requests use a canonical fixed-size 192-character token that
binds a role (`HostPresence`, attempt `Host`, or attempt `Client`), mediation
handle, and the already-issued capability. Both transports enter one processor
and the same atomic store operations. No transport-supplied public address is
trusted; the socket source is authoritative.
LiteNetLib's native NAT packet family also contains introduction-response and
punch frames that are appropriate for peers but unsafe on a public mediator: a
forged response can name arbitrary destinations. The packet layer therefore
decodes only the pinned `NatIntroduceRequest` wire shape and consumes every
inbound packet before `NatPunchModule` sees it. The module is outbound-only and
may send introductions solely from a completed authorized plan.
Listing presence refreshes authorize no response. Attempt contributions bind the
first observed endpoint for exactly one capability role. Exact duplicates are
idempotent; a different endpoint, the opposite role, an expired/cancelled
attempt, or a stale listing presence cannot replace it. The introduction is
consumed atomically only after both roles bind and their observed address
families match, preventing concurrent attempts for one listing from cross-wiring.
A reported local candidate is eligible only when it is RFC 1918 IPv4 or IPv6
unique-local unicast, matches the observed family, and both peers have the same
observed public address. Otherwise `NatIntroduce` receives the observed public
endpoint in the local slot. Loopback, link-local, multicast, unspecified,
documentation IPv6, global-address claims, and cross-family claims are never
disclosed as local targets. IPv4 is required; observed global IPv6 can be used
when both peers contribute IPv6, without claiming guaranteed IPv6 NAT traversal.
The introduction carries only the distinct connection ticket and is emitted at
most once to each verified observed endpoint. The fixed authenticated native
request and bounded frozen envelope keep the combined response bytes within the
2.0 verified amplification budget; unauthenticated inputs receive zero bytes.
Malformed, truncated, oversized, spoofed, or unrelated LiteNetLib packets do not
grow Rendezvous state. Raw endpoints and credentials are never logged or exposed
through diagnostic string representations.
Frozen IPv6 listing-presence refresh remains valid because it emits no response.
IPv6 attempt roles require the fixed-size native LiteNetLib request; accepting the
short frozen envelope would exceed the 2.0 byte budget for two IPv6 introduction
frames. The required IPv4 listen address and optional IPv6 listen address are
configured separately so enabling one family never widens the other family to a
wildcard bind.
## Consequences
- Hosts refresh listing presence and answer invitations from their actual
gameplay socket; a separate mediator socket would observe the wrong mapping.
- Caller-owned SDK coordination in #12 must poll the host invitation endpoint,
send the corresponding native role token, and consume the returned ticket.
- UDP loss can prevent traversal, but it cannot cause an arbitrary destination,
replay, role substitution, or cross-attempt introduction.
+4
View File
@@ -8,6 +8,10 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md) - [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md)
- [ADR 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.md) - [ADR 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.md)
- [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md) - [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md)
- [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md)
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md)
- [ADR 0009: authenticated bounded LiteNetLib NAT mediator](0009-authenticated-litenet-nat-mediator.md)
- [Threat model](../security/threat-model.md) - [Threat model](../security/threat-model.md)
- [Security promise and test matrix](../security/control-matrix.md) - [Security promise and test matrix](../security/control-matrix.md)
- [Versioned HTTP and UDP contracts](../contracts/README.md) - [Versioned HTTP and UDP contracts](../contracts/README.md)
+6
View File
@@ -33,6 +33,7 @@ the same value as a required query parameter.
| `GET` | `/v1/sessions` | Browse compatible public sessions. | | `GET` | `/v1/sessions` | Browse compatible public sessions. |
| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. | | `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. | | `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. |
| `DELETE` | `/v1/join-attempts/{attemptId}` | Cancel an attempt using its client punch capability. |
| `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. | | `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. | | `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
| `GET` | `/health/live` | Report that the HTTP process is alive. | | `GET` | `/health/live` | Report that the HTTP process is alive. |
@@ -49,6 +50,11 @@ for mutation operations are carried in their request bodies. Public browser
responses contain no IP endpoints, lease tokens, punch capabilities, connection responses contain no IP endpoints, lease tokens, punch capabilities, connection
tickets, player identifiers, or gameplay state. tickets, player identifiers, or gameplay state.
Attempt cancellation sends the short-lived client punch capability in
`X-Rendezvous-Client-Punch-Capability`. Join creation uses the observed HTTP
source only for a process-keyed, short-lived idempotency/abuse scope; this is not
player authentication and is never returned to callers.
## Idempotency, cursors, and retries ## Idempotency, cursors, and retries
Registration and join creation require a caller-generated visible-ASCII Registration and join creation require a caller-generated visible-ASCII
+51 -8
View File
@@ -1,11 +1,11 @@
# UDP presence contract v1 # UDP presence and NAT-punch contract v1
Tracking: #4 Tracking: #4, #11
The UDP mediator accepts a single bounded presence envelope from a host or The UDP mediator accepts the frozen bounded presence envelope below and native
client. It associates the authenticated mediation handle with the packet's LiteNetLib NAT-introduction requests. Both forms associate an authenticated
observed public source endpoint and the sender's reported local endpoint. It mediation handle with the packet's observed public source endpoint and the
does not carry gameplay packets. sender's reported local endpoint. Neither form carries gameplay packets.
All multi-byte integers use network byte order. UUID bytes use the canonical 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 RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
@@ -49,5 +49,48 @@ Capabilities are short-lived, single-purpose, scoped to one mediation handle,
and compared without exposing them in logs. A valid-looking packet does not and compared without exposing them in logs. A valid-looking packet does not
prove authorization until the capability is checked. Invalid packets receive prove authorization until the capability is checked. Invalid packets receive
no UDP response, preventing the mediator from becoming an amplification oracle. no UDP response, preventing the mediator from becoming an amplification oracle.
Replay, expiry, pairing, and rate-limit policy are defined by later mediator For the frozen envelope, `HostPresence` is resolved against either the listing's
issues; the v1 envelope deliberately leaves no unbounded or reflected payload. 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:
```text
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.
@@ -0,0 +1,232 @@
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public enum ConnectionTicketConsumptionResult
{
Accepted = 1,
NotFound = 2,
Expired = 3,
Rejected = 4,
AlreadyConsumed = 5,
Revoked = 6,
}
public sealed class ConnectionTicketValidator : IDisposable
{
private readonly object _gate = new();
private readonly Dictionary<JoinAttemptId, TicketEntry> _tickets = [];
private readonly int _maximumAuthorizedTickets;
private readonly IConnectionTicketClock _clock;
private readonly byte[] _fingerprintKey = new byte[32];
private bool _disposed;
public ConnectionTicketValidator(int maximumAuthorizedTickets = 1_024)
: this(maximumAuthorizedTickets, new SystemConnectionTicketClock())
{
}
internal ConnectionTicketValidator(
int maximumAuthorizedTickets,
IConnectionTicketClock clock)
{
if (maximumAuthorizedTickets is < 1 or > 10_000)
{
throw new ArgumentOutOfRangeException(nameof(maximumAuthorizedTickets));
}
_maximumAuthorizedTickets = maximumAuthorizedTickets;
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
RandomNumberGenerator.Fill(_fingerprintKey);
}
public bool TryAuthorize(
JoinAttemptId attemptId,
string connectionTicket,
DateTimeOffset expiresAt)
{
lock (_gate)
{
ThrowIfDisposed();
DateTimeOffset now = _clock.UtcNow;
if (attemptId.Value == Guid.Empty
|| !ContractValidation.IsConnectionTicketValid(connectionTicket)
|| expiresAt <= now)
{
return false;
}
RemoveExpired(now);
byte[] fingerprint = Fingerprint(connectionTicket);
if (_tickets.TryGetValue(attemptId, out TicketEntry? current))
{
bool idempotent = current.State == TicketState.Active
&& current.ExpiresAt == expiresAt
&& CryptographicOperations.FixedTimeEquals(current.Fingerprint, fingerprint);
CryptographicOperations.ZeroMemory(fingerprint);
return idempotent;
}
if (_tickets.Count >= _maximumAuthorizedTickets)
{
CryptographicOperations.ZeroMemory(fingerprint);
return false;
}
_tickets.Add(attemptId, new(fingerprint, expiresAt));
return true;
}
}
public ConnectionTicketConsumptionResult Consume(
JoinAttemptId attemptId,
string connectionTicket)
{
lock (_gate)
{
ThrowIfDisposed();
DateTimeOffset now = _clock.UtcNow;
if (attemptId.Value == Guid.Empty
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
{
return ConnectionTicketConsumptionResult.Rejected;
}
if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry))
{
RemoveExpired(now);
return ConnectionTicketConsumptionResult.NotFound;
}
if (entry.ExpiresAt <= now)
{
Remove(attemptId, entry);
return ConnectionTicketConsumptionResult.Expired;
}
if (entry.State == TicketState.Revoked)
{
return ConnectionTicketConsumptionResult.Revoked;
}
if (entry.State == TicketState.Consumed)
{
return ConnectionTicketConsumptionResult.AlreadyConsumed;
}
byte[] supplied = Fingerprint(connectionTicket);
bool matches = CryptographicOperations.FixedTimeEquals(entry.Fingerprint, supplied);
CryptographicOperations.ZeroMemory(supplied);
if (!matches)
{
return ConnectionTicketConsumptionResult.Rejected;
}
entry.State = TicketState.Consumed;
return ConnectionTicketConsumptionResult.Accepted;
}
}
public bool Revoke(JoinAttemptId attemptId)
{
lock (_gate)
{
ThrowIfDisposed();
RemoveExpired(_clock.UtcNow);
if (!_tickets.TryGetValue(attemptId, out TicketEntry? entry))
{
return false;
}
entry.State = TicketState.Revoked;
CryptographicOperations.ZeroMemory(entry.Fingerprint);
return true;
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
{
return;
}
foreach (TicketEntry entry in _tickets.Values)
{
CryptographicOperations.ZeroMemory(entry.Fingerprint);
}
_tickets.Clear();
CryptographicOperations.ZeroMemory(_fingerprintKey);
_disposed = true;
}
}
public override string ToString() => "[ConnectionTicketValidator: tickets and key redacted]";
private byte[] Fingerprint(string ticket)
{
byte[] encoded = Encoding.ASCII.GetBytes(ticket);
try
{
using HMACSHA256 hmac = new(_fingerprintKey);
return hmac.ComputeHash(encoded);
}
finally
{
CryptographicOperations.ZeroMemory(encoded);
}
}
private void RemoveExpired(DateTimeOffset now)
{
foreach (KeyValuePair<JoinAttemptId, TicketEntry> item in _tickets
.Where(item => item.Value.ExpiresAt <= now)
.ToArray())
{
Remove(item.Key, item.Value);
}
}
private void Remove(JoinAttemptId attemptId, TicketEntry entry)
{
CryptographicOperations.ZeroMemory(entry.Fingerprint);
_tickets.Remove(attemptId);
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(ConnectionTicketValidator));
}
}
private sealed class TicketEntry(byte[] fingerprint, DateTimeOffset expiresAt)
{
public byte[] Fingerprint { get; } = fingerprint;
public DateTimeOffset ExpiresAt { get; } = expiresAt;
public TicketState State { get; set; }
}
private enum TicketState
{
Active = 0,
Consumed = 1,
Revoked = 2,
}
}
internal interface IConnectionTicketClock
{
DateTimeOffset UtcNow { get; }
}
internal sealed class SystemConnectionTicketClock : IConnectionTicketClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
@@ -5,10 +5,12 @@
<RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace> <RootNamespace>FinalFactory.Rendezvous.Client</RootNamespace>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
<PackageId>FinalFactory.Rendezvous.Client</PackageId> <PackageId>FinalFactory.Rendezvous.Client</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description> <Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" /> <ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" /> <PackageReference Include="LiteNetLib" />
<None Update="README.md" Pack="true" PackagePath="\" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
@@ -0,0 +1,94 @@
# FinalFactory.Rendezvous.Client
Godot-independent .NET publisher and session-browser SDK for Rendezvous v1.
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
```csharp
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using HttpClient http = new()
{
BaseAddress = new Uri("https://rendezvous.example/"),
};
string publisherCredential = Environment.GetEnvironmentVariable(
"RENDEZVOUS_PUBLISHER_CREDENTIAL")
?? throw new InvalidOperationException("Publisher credential is not configured.");
CancellationToken cancellationToken = default;
RendezvousPublisherClient publisher = new(http);
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
new RegisterSessionRequest
{
IdempotencyKey = Guid.NewGuid().ToString("N"),
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.0.0",
DisplayName = "My server",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
},
publisherCredential,
cancellationToken);
if (!registered.IsSuccess || registered.Value is null)
{
throw new InvalidOperationException(
$"Registration failed: {registered.Error} ({registered.Message})");
}
```
Load `publisherCredential` from the game's deployment secret boundary; never
embed it in a client build or source control. A successful registration returns a
`PublishedSession` containing the lease and host-presence capabilities.
Send a periodic presence request from the host's gameplay `NetManager` using the
server-controlled refresh interval and the fixed-size native token:
```csharp
string presenceToken = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
session.HostPresenceHandle,
session.HostPresenceCapability);
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
```
The same codec creates `Host` tokens for host-polled invitations and `Client`
tokens for a created join attempt. Always send them from the same LiteNetLib
socket that will carry the direct game connection; the mediator ignores any
caller-supplied public endpoint.
Lease renewal is explicit and caller-controlled:
```csharp
PublishedSession session = registered.Value;
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
session,
publisherCredential);
LeaseMaintenanceResult stopped = await maintainer.RunAsync(cancellationToken);
```
Creating the maintainer does not start background work. Await its run and dispose
it when hosting stops. Use `IRendezvousPublisherClient` and
`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes
the requests and responses it creates but never disposes the supplied `HttpClient`.
The host-side `ConnectionTicketValidator` is a bounded, thread-safe one-time gate.
Authorize only tickets delivered by the authenticated Rendezvous introduction,
then consume the exact ticket presented by the direct LiteNetLib connection:
```csharp
using ConnectionTicketValidator tickets = new();
tickets.TryAuthorize(attemptId, expectedTicket, expiresAt);
ConnectionTicketConsumptionResult admission = tickets.Consume(
attemptId,
presentedTicket);
```
An `Accepted` ticket authorizes only this connection attempt. The game must still
apply its own player identity, capacity, ban, and gameplay admission rules. Revoke
the attempt on cancellation and dispose the validator during host shutdown so its
keyed ticket digests are zeroed.
See the repository's ADR 0007 for HTTP ownership/retry semantics and ADR 0008 for
join-capability and connection-ticket security semantics.
@@ -0,0 +1,141 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousClientResult<T>
{
internal RendezvousClientResult(
RendezvousErrorCode error,
T? value,
string message,
int? retryAfterSeconds)
{
Error = error;
Value = value;
Message = message;
RetryAfterSeconds = retryAfterSeconds;
}
public bool IsSuccess => Error == RendezvousErrorCode.None;
public RendezvousErrorCode Error { get; }
public T? Value { get; }
public string Message { get; }
public int? RetryAfterSeconds { get; }
}
public static class RendezvousClientResult
{
public static RendezvousClientResult<T> Success<T>(T value) =>
value is null
? throw new ArgumentNullException(nameof(value))
: new(RendezvousErrorCode.None, value, string.Empty, null);
public static RendezvousClientResult<T> Failure<T>(
RendezvousErrorCode error,
string message,
int? retryAfterSeconds = null) =>
error == RendezvousErrorCode.None
? throw new ArgumentException("A failure requires a non-success error.", nameof(error))
: new(error, default, message ?? string.Empty, retryAfterSeconds);
}
public sealed class PublishedSession
{
internal PublishedSession(RegisterSessionResponse response)
{
ListingId = response.ListingId;
LeaseId = response.LeaseId;
LeaseToken = response.LeaseToken;
HostPresenceHandle = response.HostPresenceHandle;
HostPresenceCapability = response.HostPresenceCapability;
ExpiresAt = response.ExpiresAt;
LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
}
public SessionListingId ListingId { get; }
public LeaseId LeaseId { get; }
public string LeaseToken { get; }
public MediationHandle HostPresenceHandle { get; }
public string HostPresenceCapability { get; }
public DateTimeOffset ExpiresAt { get; internal set; }
public int LeaseRenewAfterSeconds { get; internal set; }
public int HostPresenceRefreshAfterSeconds { get; }
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
}
public interface IRendezvousPublisherClient
{
Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
RegisterSessionRequest request,
string publisherCredential,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
PublishedSession session,
string publisherCredential,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<bool>> UpdateAsync(
PublishedSession session,
UpdateSessionRequest request,
string publisherCredential,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<bool>> DeregisterAsync(
PublishedSession session,
string publisherCredential,
CancellationToken cancellationToken = default);
}
public interface IRendezvousSessionBrowserClient
{
Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
BrowseSessionsRequest request,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
BrowseSessionsRequest request,
int maximumPages = 100,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
SessionListingId listingId,
GameId gameId,
EnvironmentId environmentId,
uint protocolVersion,
CancellationToken cancellationToken = default);
}
public interface IRendezvousDelay
{
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
}
public sealed class RendezvousClientOptions
{
public int MaximumSafeRetries { get; set; } = 2;
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
public double JitterRatio { get; set; } = 0.2;
internal void Validate()
{
if (MaximumSafeRetries is < 0 or > 5
|| InitialRetryDelay < TimeSpan.Zero
|| MaximumRetryDelay < InitialRetryDelay
|| MaximumRetryDelay > TimeSpan.FromSeconds(30)
|| JitterRatio is < 0 or > 1)
{
throw new ArgumentOutOfRangeException(nameof(RendezvousClientOptions));
}
}
}
internal sealed class SystemRendezvousDelay : IRendezvousDelay
{
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
Task.Delay(delay, cancellationToken);
}
@@ -0,0 +1,242 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
internal sealed class RendezvousHttpTransport
{
private readonly HttpClient _httpClient;
private readonly RendezvousClientOptions _options;
private readonly IRendezvousDelay _delay;
internal RendezvousHttpTransport(
HttpClient httpClient,
RendezvousClientOptions? options,
IRendezvousDelay? delay)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
RendezvousClientOptions suppliedOptions = options ?? new RendezvousClientOptions();
suppliedOptions.Validate();
_options = new RendezvousClientOptions
{
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
InitialRetryDelay = suppliedOptions.InitialRetryDelay,
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
JitterRatio = suppliedOptions.JitterRatio,
};
_delay = delay ?? new SystemRendezvousDelay();
}
internal async Task<RendezvousClientResult<T>> SendSafeAsync<T>(
Func<HttpRequestMessage> requestFactory,
CancellationToken cancellationToken)
{
for (int attempt = 0; ; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
using HttpRequestMessage request = requestFactory();
using HttpResponseMessage response = await _httpClient
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
if (typeof(T) == typeof(bool) && response.StatusCode == HttpStatusCode.NoContent)
{
return RendezvousClientResult.Success((T)(object)true);
}
byte[] payload;
try
{
payload = await ReadBoundedAsync(response.Content, cancellationToken)
.ConfigureAwait(false);
}
catch (InvalidDataException)
{
return RendezvousClientResult.Failure<T>(
RendezvousErrorCode.InternalError,
"The service returned an oversized success response.");
}
T? value;
try
{
value = JsonSerializer.Deserialize<T>(payload, ContractJson.Options);
}
catch (JsonException)
{
value = default;
}
return value is null
? RendezvousClientResult.Failure<T>(
RendezvousErrorCode.InternalError,
"The service returned an invalid success response.")
: RendezvousClientResult.Success(value);
}
ApiError error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false);
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
{
await _delay.DelayAsync(
GetRetryDelay(attempt, retryAfter),
cancellationToken).ConfigureAwait(false);
continue;
}
return RendezvousClientResult.Failure<T>(error.Code, error.Message, retryAfter);
}
catch (Exception exception) when (
IsTransientTransportFailure(exception, cancellationToken)
&& attempt < _options.MaximumSafeRetries)
{
await _delay.DelayAsync(GetRetryDelay(attempt, null), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
{
return RendezvousClientResult.Failure<T>(
RendezvousErrorCode.ServiceUnavailable,
"The Rendezvous service did not return a valid response.");
}
}
}
internal static HttpRequestMessage JsonRequest<T>(
HttpMethod method,
string uri,
T body,
string? publisherCredential = null)
{
HttpRequestMessage request = new(method, uri)
{
Content = new StringContent(
JsonSerializer.Serialize(body, ContractJson.Options),
Encoding.UTF8,
"application/json"),
};
if (publisherCredential is not null)
{
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer",
RequireCredential(publisherCredential));
}
return request;
}
internal static string RequireCredential(string credential) =>
!string.IsNullOrWhiteSpace(credential)
? credential
: throw new ArgumentException("A publisher credential is required.", nameof(credential));
private static async Task<ApiError> ReadErrorAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
try
{
byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken)
.ConfigureAwait(false);
ApiError? error = JsonSerializer.Deserialize<ApiError>(payload, ContractJson.Options);
return error is not null && error.Code != RendezvousErrorCode.None
? error
: FallbackError(response.StatusCode);
}
catch (Exception exception) when (exception is JsonException or InvalidDataException)
{
return FallbackError(response.StatusCode);
}
}
private static async Task<byte[]> ReadBoundedAsync(
HttpContent content,
CancellationToken cancellationToken)
{
using Stream source = await content.ReadAsStreamAsync().ConfigureAwait(false);
using MemoryStream destination = new();
byte[] buffer = new byte[8192];
while (true)
{
int read = await source.ReadAsync(buffer.AsMemory(), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return destination.ToArray();
}
if (destination.Length + read > ContractLimits.BrowserResponseMaxBytes)
{
throw new InvalidDataException("The service response exceeded the SDK limit.");
}
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
.ConfigureAwait(false);
}
}
private TimeSpan GetRetryDelay(int attempt, int? retryAfterSeconds)
{
TimeSpan basis = retryAfterSeconds.HasValue
? TimeSpan.FromSeconds(Math.Max(0, retryAfterSeconds.Value))
: TimeSpan.FromMilliseconds(
_options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt));
double bounded = Math.Min(basis.TotalMilliseconds, _options.MaximumRetryDelay.TotalMilliseconds);
if (_options.JitterRatio == 0 || bounded == 0)
{
return TimeSpan.FromMilliseconds(bounded);
}
byte[] random = new byte[1];
RandomNumberGenerator.Fill(random);
double unit = random[0] / 255d;
double multiplier = 1 - _options.JitterRatio + (2 * _options.JitterRatio * unit);
return TimeSpan.FromMilliseconds(Math.Min(
bounded * multiplier,
_options.MaximumRetryDelay.TotalMilliseconds));
}
private static bool IsTransient(RendezvousErrorCode code) => code is
RendezvousErrorCode.RateLimited
or RendezvousErrorCode.CapacityExceeded
or RendezvousErrorCode.ServiceUnavailable;
private static bool IsTransientTransportFailure(
Exception exception,
CancellationToken callerCancellation) =>
exception is HttpRequestException
or IOException
|| exception is OperationCanceledException && !callerCancellation.IsCancellationRequested;
private static int? GetRetryAfterSeconds(RetryConditionHeaderValue? retryAfter) =>
retryAfter?.Delta is TimeSpan delta
? Math.Max(0, (int)Math.Ceiling(delta.TotalSeconds))
: null;
private static ApiError FallbackError(HttpStatusCode statusCode) => new()
{
Code = statusCode switch
{
HttpStatusCode.BadRequest => RendezvousErrorCode.InvalidRequest,
HttpStatusCode.Unauthorized => RendezvousErrorCode.AuthenticationRequired,
HttpStatusCode.Forbidden => RendezvousErrorCode.Forbidden,
HttpStatusCode.NotFound => RendezvousErrorCode.NotFound,
HttpStatusCode.Conflict => RendezvousErrorCode.Conflict,
HttpStatusCode.Gone => RendezvousErrorCode.Expired,
HttpStatusCode.TooManyRequests => RendezvousErrorCode.RateLimited,
HttpStatusCode.RequestTimeout => RendezvousErrorCode.ServiceUnavailable,
HttpStatusCode.BadGateway => RendezvousErrorCode.ServiceUnavailable,
HttpStatusCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
HttpStatusCode.GatewayTimeout => RendezvousErrorCode.ServiceUnavailable,
_ => RendezvousErrorCode.InternalError,
},
Message = "The service returned an error without a valid Rendezvous envelope.",
};
}
@@ -0,0 +1,151 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
{
private readonly RendezvousHttpTransport _transport;
private readonly IRendezvousDelay _delay;
public RendezvousPublisherClient(
HttpClient httpClient,
RendezvousClientOptions? options = null,
IRendezvousDelay? delay = null)
{
_delay = delay ?? new SystemRendezvousDelay();
_transport = new(httpClient, options, _delay);
}
public async Task<RendezvousClientResult<PublishedSession>> RegisterAsync(
RegisterSessionRequest request,
string publisherCredential,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
RegisterSessionRequest body = CopyRegistration(request);
RendezvousClientResult<RegisterSessionResponse> result = await _transport.SendSafeAsync<RegisterSessionResponse>(
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/sessions", body, publisherCredential),
cancellationToken).ConfigureAwait(false);
return result.IsSuccess && result.Value is not null
? RendezvousClientResult.Success(new PublishedSession(result.Value))
: RendezvousClientResult.Failure<PublishedSession>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
public async Task<RendezvousClientResult<RenewLeaseResponse>> RenewAsync(
PublishedSession session,
string publisherCredential,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
RendezvousClientResult<RenewLeaseResponse> result = await _transport.SendSafeAsync<RenewLeaseResponse>(
() => RendezvousHttpTransport.JsonRequest(
HttpMethod.Post,
$"v1/sessions/{session.ListingId}/renew",
new RenewLeaseRequest { LeaseToken = session.LeaseToken },
publisherCredential),
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess && result.Value is not null)
{
session.ExpiresAt = result.Value.ExpiresAt;
session.LeaseRenewAfterSeconds = result.Value.RenewAfterSeconds;
}
return result;
}
public Task<RendezvousClientResult<bool>> UpdateAsync(
PublishedSession session,
UpdateSessionRequest request,
string publisherCredential,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
UpdateSessionRequest body = new()
{
ContractVersion = request.ContractVersion,
LeaseToken = session.LeaseToken,
BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName,
Capacity = CopyCapacity(request.Capacity),
Metadata = CopyMetadata(request.Metadata),
};
return _transport.SendSafeAsync<bool>(
() => RendezvousHttpTransport.JsonRequest(
HttpMethod.Put,
$"v1/sessions/{session.ListingId}",
body,
publisherCredential),
cancellationToken);
}
public Task<RendezvousClientResult<bool>> DeregisterAsync(
PublishedSession session,
string publisherCredential,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
return _transport.SendSafeAsync<bool>(
() => RendezvousHttpTransport.JsonRequest(
HttpMethod.Delete,
$"v1/sessions/{session.ListingId}",
new DeleteSessionRequest { LeaseToken = session.LeaseToken },
publisherCredential),
cancellationToken);
}
public SessionLeaseMaintainer CreateLeaseMaintainer(
PublishedSession session,
string publisherCredential) => new(
this,
session ?? throw new ArgumentNullException(nameof(session)),
RendezvousHttpTransport.RequireCredential(publisherCredential),
_delay);
private static RegisterSessionRequest CopyRegistration(RegisterSessionRequest request) => new()
{
ContractVersion = request.ContractVersion,
IdempotencyKey = request.IdempotencyKey,
GameId = request.GameId,
EnvironmentId = request.EnvironmentId,
RegionId = request.RegionId,
ProtocolVersion = request.ProtocolVersion,
BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName,
Visibility = request.Visibility,
Capacity = CopyCapacity(request.Capacity),
Metadata = CopyMetadata(request.Metadata),
};
private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new()
{
CurrentPlayers = capacity.CurrentPlayers,
MaximumPlayers = capacity.MaximumPlayers,
};
private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) =>
new(metadata, StringComparer.Ordinal);
}
@@ -0,0 +1,110 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserClient
{
private readonly RendezvousHttpTransport _transport;
public RendezvousSessionBrowserClient(
HttpClient httpClient,
RendezvousClientOptions? options = null,
IRendezvousDelay? delay = null)
{
_transport = new(httpClient, options, delay);
}
public Task<RendezvousClientResult<BrowseSessionsResponse>> BrowseAsync(
BrowseSessionsRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
string query = $"v1/sessions?contractVersion={request.ContractVersion}"
+ $"&gameId={Escape(request.GameId.Value)}"
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
+ $"&protocolVersion={request.ProtocolVersion}"
+ $"&pageSize={request.PageSize}"
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
+ (request.RegionId.HasValue ? $"&regionId={Escape(request.RegionId.Value.Value)}" : string.Empty)
+ (request.Cursor is not null ? $"&cursor={Escape(request.Cursor)}" : string.Empty);
return _transport.SendSafeAsync<BrowseSessionsResponse>(
() => new HttpRequestMessage(HttpMethod.Get, query),
cancellationToken);
}
public async Task<RendezvousClientResult<IReadOnlyList<SessionListing>>> BrowseAllAsync(
BrowseSessionsRequest request,
int maximumPages = 100,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (maximumPages is < 1 or > 1000)
{
throw new ArgumentOutOfRangeException(nameof(maximumPages));
}
List<SessionListing> items = [];
string? cursor = request.Cursor;
for (int page = 0; page < maximumPages; page++)
{
BrowseSessionsRequest pageRequest = new()
{
ContractVersion = request.ContractVersion,
GameId = request.GameId,
EnvironmentId = request.EnvironmentId,
ProtocolVersion = request.ProtocolVersion,
RegionId = request.RegionId,
PageSize = request.PageSize,
ExcludeFull = request.ExcludeFull,
Cursor = cursor,
};
RendezvousClientResult<BrowseSessionsResponse> result = await BrowseAsync(
pageRequest,
cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
items.AddRange(result.Value.Items);
cursor = result.Value.NextCursor;
if (string.IsNullOrEmpty(cursor))
{
return RendezvousClientResult.Success<IReadOnlyList<SessionListing>>(items.AsReadOnly());
}
}
return RendezvousClientResult.Failure<IReadOnlyList<SessionListing>>(
RendezvousErrorCode.CapacityExceeded,
$"Browsing exceeded the configured {maximumPages}-page limit.");
}
public Task<RendezvousClientResult<GetSessionResponse>> GetAsync(
SessionListingId listingId,
GameId gameId,
EnvironmentId environmentId,
uint protocolVersion,
CancellationToken cancellationToken = default)
{
string query = $"v1/sessions/{listingId}?contractVersion={ContractLimits.ContractVersion}"
+ $"&gameId={Escape(gameId.Value)}"
+ $"&environmentId={Escape(environmentId.Value)}"
+ $"&protocolVersion={protocolVersion}";
return _transport.SendSafeAsync<GetSessionResponse>(
() => new HttpRequestMessage(HttpMethod.Get, query),
cancellationToken);
}
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
}
@@ -0,0 +1,150 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public enum LeaseMaintenanceStopReason
{
Cancelled = 1,
Disposed = 2,
LeaseLost = 3,
Failed = 4,
}
public sealed class LeaseMaintenanceResult
{
internal LeaseMaintenanceResult(LeaseMaintenanceStopReason reason, RendezvousErrorCode error)
{
Reason = reason;
Error = error;
}
public LeaseMaintenanceStopReason Reason { get; }
public RendezvousErrorCode Error { get; }
}
public sealed class SessionLeaseMaintainer : IAsyncDisposable
{
private readonly object _gate = new();
private readonly IRendezvousPublisherClient _publisher;
private readonly PublishedSession _session;
private readonly string _publisherCredential;
private readonly IRendezvousDelay _delay;
private readonly CancellationTokenSource _disposeCancellation = new();
private Task<LeaseMaintenanceResult>? _activeRun;
private Task? _disposeTask;
private bool _disposed;
internal SessionLeaseMaintainer(
IRendezvousPublisherClient publisher,
PublishedSession session,
string publisherCredential,
IRendezvousDelay? delay = null)
{
_publisher = publisher;
_session = session;
_publisherCredential = publisherCredential;
_delay = delay ?? new SystemRendezvousDelay();
}
public event EventHandler? LeaseLost;
public Task<LeaseMaintenanceResult> RunAsync(CancellationToken cancellationToken = default)
{
lock (_gate)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SessionLeaseMaintainer));
}
if (_activeRun is not null)
{
throw new InvalidOperationException("Lease maintenance is already running.");
}
_activeRun = RunCoreAsync(cancellationToken);
return _activeRun;
}
}
public ValueTask DisposeAsync()
{
lock (_gate)
{
if (_disposeTask is not null)
{
return new(_disposeTask);
}
_disposed = true;
_disposeCancellation.Cancel();
_disposeTask = FinishDisposeAsync(_activeRun);
return new(_disposeTask);
}
}
private async Task FinishDisposeAsync(Task<LeaseMaintenanceResult>? active)
{
try
{
if (active is not null)
{
await active.ConfigureAwait(false);
}
}
finally
{
_disposeCancellation.Dispose();
}
}
private async Task<LeaseMaintenanceResult> RunCoreAsync(CancellationToken cancellationToken)
{
await Task.Yield();
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
_disposeCancellation.Token);
try
{
while (true)
{
await _delay.DelayAsync(
TimeSpan.FromSeconds(Math.Max(1, _session.LeaseRenewAfterSeconds)),
linked.Token).ConfigureAwait(false);
RendezvousClientResult<RenewLeaseResponse> renewed = await _publisher.RenewAsync(
_session,
_publisherCredential,
linked.Token).ConfigureAwait(false);
if (renewed.IsSuccess)
{
continue;
}
if (renewed.Error is RendezvousErrorCode.NotFound
or RendezvousErrorCode.Expired
or RendezvousErrorCode.Forbidden
or RendezvousErrorCode.AuthenticationRequired)
{
LeaseLost?.Invoke(this, EventArgs.Empty);
return new(LeaseMaintenanceStopReason.LeaseLost, renewed.Error);
}
return new(LeaseMaintenanceStopReason.Failed, renewed.Error);
}
}
catch (OperationCanceledException) when (linked.IsCancellationRequested)
{
return new(
_disposeCancellation.IsCancellationRequested
? LeaseMaintenanceStopReason.Disposed
: LeaseMaintenanceStopReason.Cancelled,
RendezvousErrorCode.None);
}
finally
{
lock (_gate)
{
_activeRun = null;
}
}
}
}
@@ -23,6 +23,8 @@ public static class ContractLimits
public const int OpaqueHttpCredentialMaxCharacters = 1_024; public const int OpaqueHttpCredentialMaxCharacters = 1_024;
public const int UdpCapabilityMaxCharacters = 192; public const int UdpCapabilityMaxCharacters = 192;
public const int ConnectionTicketMaxCharacters = 192; public const int ConnectionTicketMaxCharacters = 192;
public const int DerivedCredentialCharacters = 43;
public const int NatPunchRequestTokenCharacters = 192;
public const int LiteNetLibNatTokenMaxCharacters = 256; public const int LiteNetLibNatTokenMaxCharacters = 256;
public const int SessionCapacityMaxPlayers = 10_000; public const int SessionCapacityMaxPlayers = 10_000;
} }
@@ -174,6 +174,8 @@ public sealed class BrowseSessionsRequest
public RegionId? RegionId { get; set; } public RegionId? RegionId { get; set; }
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems; public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
public bool ExcludeFull { get; set; }
public string? Cursor { get; set; } public string? Cursor { get; set; }
} }
@@ -0,0 +1,131 @@
namespace FinalFactory.Rendezvous.Contracts;
public enum NatPunchPeerRole
{
HostPresence = 1,
Host = 2,
Client = 3,
}
public sealed class NatPunchRequestToken
{
public NatPunchPeerRole Role { get; set; }
public MediationHandle MediationHandle { get; set; }
public string Capability { get; set; } = string.Empty;
public override string ToString() => "[NatPunchRequestToken: capability redacted]";
}
public static class NatPunchRequestTokenCodec
{
public const int EncodedLength = ContractLimits.NatPunchRequestTokenCharacters;
private const string VersionPrefix = "rv1:";
private const int HandleLength = 32;
private const int CapabilityLength = ContractLimits.DerivedCredentialCharacters;
private const char Separator = ':';
private const char Padding = '.';
public static string Encode(
NatPunchPeerRole role,
MediationHandle mediationHandle,
string capability)
{
if (!TryGetRoleCode(role, out char roleCode)
|| mediationHandle.Value == Guid.Empty
|| capability is null
|| capability.Length != CapabilityLength
|| !ContractValidation.IsCapabilityValid(capability))
{
throw new ArgumentException("The NAT punch request token fields are invalid.");
}
string payload = string.Concat(
VersionPrefix,
roleCode,
Separator,
mediationHandle.Value.ToString("N"),
Separator,
capability);
return payload.PadRight(EncodedLength, Padding);
}
public static bool TryDecode(string? encoded, out NatPunchRequestToken? token)
{
token = null;
if (encoded is null
|| encoded.Length != EncodedLength
|| !encoded.StartsWith(VersionPrefix, StringComparison.Ordinal)
|| !TryParseRole(encoded[VersionPrefix.Length], out NatPunchPeerRole role))
{
return false;
}
int roleSeparator = VersionPrefix.Length + 1;
int handleOffset = roleSeparator + 1;
int capabilitySeparator = handleOffset + HandleLength;
int capabilityOffset = capabilitySeparator + 1;
int paddingOffset = capabilityOffset + CapabilityLength;
string handleText = encoded.Substring(handleOffset, HandleLength);
if (encoded[roleSeparator] != Separator
|| encoded[capabilitySeparator] != Separator
|| !Guid.TryParseExact(handleText, "N", out Guid handle)
|| handle == Guid.Empty
|| !string.Equals(handleText, handle.ToString("N"), StringComparison.Ordinal)
|| !ContainsOnlyPadding(encoded, paddingOffset))
{
return false;
}
string capability = encoded.Substring(capabilityOffset, CapabilityLength);
if (!ContractValidation.IsCapabilityValid(capability))
{
return false;
}
token = new NatPunchRequestToken
{
Role = role,
MediationHandle = new MediationHandle(handle),
Capability = capability,
};
return true;
}
private static bool TryGetRoleCode(NatPunchPeerRole role, out char code)
{
code = role switch
{
NatPunchPeerRole.HostPresence => 'p',
NatPunchPeerRole.Host => 'h',
NatPunchPeerRole.Client => 'c',
_ => default,
};
return code != default;
}
private static bool TryParseRole(char code, out NatPunchPeerRole role)
{
role = code switch
{
'p' => NatPunchPeerRole.HostPresence,
'h' => NatPunchPeerRole.Host,
'c' => NatPunchPeerRole.Client,
_ => default,
};
return role != default;
}
private static bool ContainsOnlyPadding(string value, int offset)
{
for (int index = offset; index < value.Length; index++)
{
if (value[index] != Padding)
{
return false;
}
}
return true;
}
}
@@ -0,0 +1,103 @@
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed class EphemeralCursorProtector : IDisposable
{
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
private bool _disposed;
public string Protect(string prefix, ReadOnlySpan<byte> payload)
{
ObjectDisposedException.ThrowIf(_disposed, this);
string content = $"{prefix}.{EncodeBytes(payload)}";
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
try
{
string cursor = $"{content}.{EncodeBytes(signature)}";
return ContractValidation.IsCursorValid(cursor)
? cursor
: throw new InvalidOperationException("The protected cursor exceeds its contract limit.");
}
finally
{
CryptographicOperations.ZeroMemory(signature);
}
}
public bool TryUnprotect(string prefix, string? cursor, out byte[] payload)
{
payload = [];
if (_disposed || !ContractValidation.IsCursorValid(cursor))
{
return false;
}
string[] segments = cursor!.Split('.');
if (segments.Length != 3 || !string.Equals(segments[0], prefix, StringComparison.Ordinal))
{
return false;
}
byte[] expected = HMACSHA256.HashData(
_key,
Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"));
if (!TryDecodeBytes(segments[2], out byte[] supplied))
{
CryptographicOperations.ZeroMemory(expected);
return false;
}
bool validSignature = supplied.Length == expected.Length
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
CryptographicOperations.ZeroMemory(supplied);
CryptographicOperations.ZeroMemory(expected);
return validSignature && TryDecodeBytes(segments[1], out payload);
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
CryptographicOperations.ZeroMemory(_key);
}
}
public override string ToString() => "[EphemeralCursorProtector: key redacted]";
private static string EncodeBytes(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
private static bool TryDecodeBytes(string value, out byte[] bytes)
{
bytes = [];
if (string.IsNullOrEmpty(value)
|| value.Any(static character =>
character is not (>= 'A' and <= 'Z')
and not (>= 'a' and <= 'z')
and not (>= '0' and <= '9')
and not '-'
and not '_'))
{
return false;
}
string padded = value.Replace('-', '+').Replace('_', '/');
padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" };
try
{
bytes = Convert.FromBase64String(padded);
return true;
}
catch (FormatException)
{
return false;
}
}
}
@@ -0,0 +1,115 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed class SessionBrowserCursorCodec : IDisposable
{
private const string Prefix = "rvc1";
private readonly EphemeralCursorProtector _protector = new();
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
{
BrowserCursorPayload payload = new()
{
GameId = query.Scope.GameId.Value,
EnvironmentId = query.Scope.EnvironmentId.Value,
ProtocolVersion = query.ProtocolVersion,
RegionId = query.RegionId?.Value,
ExcludeFull = query.ExcludeFull,
AfterListingId = after.ToString(),
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
};
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
try
{
return _protector.Protect(Prefix, encoded);
}
finally
{
CryptographicOperations.ZeroMemory(encoded);
}
}
public bool TryDecode(
string? cursor,
TenantScope scope,
uint protocolVersion,
RegionId? regionId,
bool excludeFull,
DateTimeOffset now,
out SessionListingId? after)
{
after = null;
if (cursor is null)
{
return true;
}
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
{
return false;
}
BrowserCursorPayload? payload;
try
{
payload = JsonSerializer.Deserialize<BrowserCursorPayload>(
encodedPayload,
ContractJson.Options);
}
catch (JsonException)
{
payload = null;
}
finally
{
CryptographicOperations.ZeroMemory(encodedPayload);
}
if (payload is null
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|| !string.Equals(payload.GameId, scope.GameId.Value, StringComparison.Ordinal)
|| !string.Equals(payload.EnvironmentId, scope.EnvironmentId.Value, StringComparison.Ordinal)
|| payload.ProtocolVersion != protocolVersion
|| !string.Equals(payload.RegionId, regionId?.Value, StringComparison.Ordinal)
|| payload.ExcludeFull != excludeFull
|| !SessionListingId.TryParse(payload.AfterListingId, out SessionListingId listingId))
{
return false;
}
after = listingId;
return true;
}
public void Dispose() => _protector.Dispose();
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]";
}
internal sealed class BrowserCursorPayload
{
[JsonRequired]
public string GameId { get; set; } = string.Empty;
[JsonRequired]
public string EnvironmentId { get; set; } = string.Empty;
[JsonRequired]
public uint ProtocolVersion { get; set; }
public string? RegionId { get; set; }
[JsonRequired]
public bool ExcludeFull { get; set; }
[JsonRequired]
public string AfterListingId { get; set; } = string.Empty;
[JsonRequired]
public long ExpiresAtUnixSeconds { get; set; }
}
@@ -0,0 +1,157 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
{
public bool Succeeded => Error == RendezvousErrorCode.None;
}
internal sealed class SessionBrowserService(
IEphemeralRendezvousStore store,
SessionBrowserCursorCodec cursors,
IWallClock clock)
{
public BrowserServiceResult<BrowseSessionsResponse> Browse(
BrowseSessionsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = Validate(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
TenantScope scope = new(request.GameId, request.EnvironmentId);
if (!cursors.TryDecode(
request.Cursor,
scope,
request.ProtocolVersion,
request.RegionId,
request.ExcludeFull,
clock.UtcNow,
out SessionListingId? after))
{
return new(RendezvousErrorCode.InvalidRequest);
}
VisibleListingQuery query = new(
scope,
request.ProtocolVersion,
request.RegionId,
request.PageSize + 1,
after,
request.ExcludeFull);
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
query,
cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return new(found.Code == StoreResultCode.ServiceUnavailable
? RendezvousErrorCode.ServiceUnavailable
: RendezvousErrorCode.InternalError);
}
List<SessionListing> items = found.Value
.Take(request.PageSize)
.Select(ToContract)
.ToList();
bool hasMore = found.Value.Count > request.PageSize;
while (items.Count > 0)
{
string? nextCursor = hasMore
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
: null;
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
<= ContractLimits.BrowserResponseMaxBytes)
{
return new(RendezvousErrorCode.None, response);
}
items.RemoveAt(items.Count - 1);
hasMore = true;
}
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
}
public BrowserServiceResult<GetSessionResponse> Get(
SessionListingId listingId,
GameId gameId,
EnvironmentId environmentId,
uint protocolVersion,
CancellationToken cancellationToken = default)
{
if (listingId.Value == Guid.Empty
|| string.IsNullOrEmpty(gameId.Value)
|| string.IsNullOrEmpty(environmentId.Value)
|| protocolVersion == 0)
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<StoredListing> found = store.GetListing(listingId, true, cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return new(found.Code == StoreResultCode.ServiceUnavailable
? RendezvousErrorCode.ServiceUnavailable
: RendezvousErrorCode.NotFound);
}
StoredListing listing = found.Value;
if (listing.Definition.Scope != new TenantScope(gameId, environmentId)
|| listing.Definition.ProtocolVersion != protocolVersion)
{
return new(RendezvousErrorCode.NotFound);
}
return new(RendezvousErrorCode.None, new GetSessionResponse
{
Session = ToContract(listing),
});
}
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return string.IsNullOrEmpty(request.GameId.Value)
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|| request.ProtocolVersion == 0
|| (request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value))
|| !ContractValidation.IsPageSizeValid(request.PageSize)
|| !ContractValidation.IsCursorValid(request.Cursor)
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private static SessionListing ToContract(StoredListing stored) => new()
{
ListingId = stored.Definition.ListingId,
GameId = stored.Definition.Scope.GameId,
EnvironmentId = stored.Definition.Scope.EnvironmentId,
RegionId = stored.Definition.RegionId,
ProtocolVersion = stored.Definition.ProtocolVersion,
BuildVersion = stored.Definition.BuildVersion,
DisplayName = stored.Definition.DisplayName,
Visibility = stored.Definition.Visibility,
PublisherTrustMode = stored.Definition.TrustMode,
Capacity = new()
{
CurrentPlayers = stored.Definition.CurrentPlayers,
MaximumPlayers = stored.Definition.MaximumPlayers,
},
Metadata = stored.Definition.Metadata.ToDictionary(
static item => item.Key,
static item => item.Value,
StringComparer.Ordinal),
};
}
@@ -1,4 +1,7 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
@@ -55,15 +58,20 @@ internal static class ContractEndpoints
.WithName("DeleteSession"); .WithName("DeleteSession");
sessions.MapGet("/", BrowseSessions) sessions.MapGet("/", BrowseSessions)
.Produces<BrowseSessionsResponse>() .Produces<BrowseSessionsResponse>()
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseSessions"); .WithName("BrowseSessions");
sessions.MapGet("/{listingId}", GetSession) sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>() .Produces<GetSessionResponse>()
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("GetSession"); .WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts) sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>() .Produces<BrowseHostJoinAttemptsResponse>()
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseHostJoinAttempts"); .WithName("BrowseHostJoinAttempts");
RouteGroupBuilder attempts = endpoints RouteGroupBuilder attempts = endpoints
@@ -72,12 +80,22 @@ internal static class ContractEndpoints
attempts.MapPost("/", CreateJoinAttempt) attempts.MapPost("/", CreateJoinAttempt)
.Accepts<CreateJoinAttemptRequest>("application/json") .Accepts<CreateJoinAttemptRequest>("application/json")
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created) .Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CreateJoinAttempt"); .WithName("CreateJoinAttempt");
attempts.MapDelete("/{attemptId}", CancelJoinAttempt)
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CancelJoinAttempt");
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome) attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
.Accepts<ReportConnectionOutcomeRequest>("application/json") .Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>() .Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(NotImplementedStatus) .Produces<ApiError>(StatusCodes.Status501NotImplemented)
.WithName("ReportConnectionOutcome"); .WithName("ReportConnectionOutcome");
return endpoints; return endpoints;
@@ -200,19 +218,119 @@ internal static class ContractEndpoints
[FromQuery] uint protocolVersion, [FromQuery] uint protocolVersion,
[FromQuery] string? regionId, [FromQuery] string? regionId,
[FromQuery] int? pageSize, [FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented(); [FromQuery] bool? excludeFull,
[FromQuery] string? cursor,
[FromServices] SessionBrowserService browser,
CancellationToken cancellationToken)
{
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|| (regionId is not null && !RegionId.TryParse(regionId, out _)))
{
return Error(RendezvousErrorCode.InvalidRequest);
}
private static IResult GetSession(SessionListingId listingId) => NotImplemented(); BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
{
ContractVersion = contractVersion,
GameId = parsedGameId,
EnvironmentId = parsedEnvironmentId,
ProtocolVersion = protocolVersion,
RegionId = regionId is null ? null : new RegionId(regionId),
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
ExcludeFull = excludeFull ?? false,
Cursor = cursor,
}, cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult GetSession(
SessionListingId listingId,
[FromQuery] int contractVersion,
[FromQuery] string gameId,
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromServices] SessionBrowserService browser,
CancellationToken cancellationToken)
{
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
{
return Error(RendezvousErrorCode.UnsupportedContractVersion);
}
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId))
{
return Error(RendezvousErrorCode.InvalidRequest);
}
BrowserServiceResult<GetSessionResponse> result = browser.Get(
listingId,
parsedGameId,
parsedEnvironmentId,
protocolVersion,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult BrowseHostJoinAttempts( private static IResult BrowseHostJoinAttempts(
SessionListingId listingId, SessionListingId listingId,
[FromQuery] int contractVersion, [FromQuery] int contractVersion,
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken, [FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
[FromQuery] int? pageSize, [FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented(); [FromQuery] string? cursor,
[FromServices] JoinAttemptService attempts,
CancellationToken cancellationToken)
{
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
listingId,
contractVersion,
leaseToken,
pageSize ?? ContractLimits.BrowserPageMaxItems,
cursor,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult CreateJoinAttempt([FromBody] CreateJoinAttemptRequest request) => private static IResult CreateJoinAttempt(
NotImplemented(); [FromBody] CreateJoinAttemptRequest request,
[FromServices] JoinAttemptService attempts,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (httpContext.Connection.RemoteIpAddress is not IPAddress remoteAddress)
{
return Error(RendezvousErrorCode.InvalidRequest);
}
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
clientSubject,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
: Error(result.Error);
}
private static IResult CancelJoinAttempt(
JoinAttemptId attemptId,
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
[FromServices] JoinAttemptService attempts,
CancellationToken cancellationToken)
{
JoinAttemptServiceResult<bool> result = attempts.Cancel(
attemptId,
clientPunchCapability,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
private static IResult ReportConnectionOutcome( private static IResult ReportConnectionOutcome(
JoinAttemptId attemptId, JoinAttemptId attemptId,
@@ -0,0 +1,96 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
internal sealed class JoinAttemptCursorCodec : IDisposable
{
private const string Prefix = "rvj1";
private readonly EphemeralCursorProtector _protector = new();
public string Encode(
SessionListingId listingId,
JoinAttemptId after,
DateTimeOffset now)
{
JoinAttemptCursorPayload payload = new()
{
ListingId = listingId.ToString(),
AfterAttemptId = after.ToString(),
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
};
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
try
{
return _protector.Protect(Prefix, encoded);
}
finally
{
CryptographicOperations.ZeroMemory(encoded);
}
}
public bool TryDecode(
string? cursor,
SessionListingId listingId,
DateTimeOffset now,
out JoinAttemptId? after)
{
after = null;
if (cursor is null)
{
return true;
}
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
{
return false;
}
JoinAttemptCursorPayload? payload;
try
{
payload = JsonSerializer.Deserialize<JoinAttemptCursorPayload>(
encodedPayload,
ContractJson.Options);
}
catch (JsonException)
{
payload = null;
}
finally
{
CryptographicOperations.ZeroMemory(encodedPayload);
}
if (payload is null
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|| !string.Equals(payload.ListingId, listingId.ToString(), StringComparison.Ordinal)
|| !JoinAttemptId.TryParse(payload.AfterAttemptId, out JoinAttemptId attemptId))
{
return false;
}
after = attemptId;
return true;
}
public void Dispose() => _protector.Dispose();
public override string ToString() => "[JoinAttemptCursorCodec: key and cursors redacted]";
}
internal sealed class JoinAttemptCursorPayload
{
[JsonRequired]
public string ListingId { get; set; } = string.Empty;
[JsonRequired]
public string AfterAttemptId { get; set; } = string.Empty;
[JsonRequired]
public long ExpiresAtUnixSeconds { get; set; }
}
@@ -0,0 +1,330 @@
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
internal sealed record JoinAttemptServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
{
public bool Succeeded => Error == RendezvousErrorCode.None;
}
internal sealed record ConnectionTicketGrant(string Ticket, DateTimeOffset ExpiresAt)
{
public override string ToString() => "[ConnectionTicketGrant: ticket redacted]";
}
internal sealed class JoinAttemptService(
GamePolicyRegistry policies,
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities,
JoinAttemptCursorCodec cursors,
IWallClock clock)
{
public string CreateAnonymousClientSubject(IPAddress remoteAddress)
{
ArgumentNullException.ThrowIfNull(remoteAddress);
IPAddress normalized = remoteAddress.IsIPv4MappedToIPv6
? remoteAddress.MapToIPv4()
: remoteAddress;
return capabilities.DeriveOpaqueIdentifier("join-http-client", normalized.ToString());
}
public JoinAttemptServiceResult<CreateJoinAttemptResponse> Create(
string clientSubject,
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrWhiteSpace(clientSubject))
{
throw new ArgumentException("A bounded client subject is required.", nameof(clientSubject));
}
RendezvousErrorCode validation = ValidateCreate(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
if (!policies.TryGet(request.GameId, request.EnvironmentId, out GamePolicy? policy)
|| policy is null)
{
return new(RendezvousErrorCode.NotFound);
}
if (!policy.AllowsProtocol(request.ProtocolVersion))
{
return new(RendezvousErrorCode.IncompatibleProtocol);
}
string requestFingerprint = ComputeRequestFingerprint(request);
string derivationSalt = capabilities.CreateDerivationSalt();
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt);
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|| !capabilities.TryFingerprint(connectionTicket, out SecretFingerprint ticketFingerprint))
{
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
}
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
"join-attempt-id",
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
"join-mediation-handle",
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
StoreResult<StoredJoinAttempt> created = store.CreateJoinAttempt(new()
{
IdempotencyOwner = clientSubject,
IdempotencyKey = request.IdempotencyKey,
RequestFingerprint = requestFingerprint,
ClientSubject = clientSubject,
AttemptId = attemptId,
MediationHandle = mediationHandle,
Scope = new(request.GameId, request.EnvironmentId),
ListingId = request.ListingId,
ProtocolVersion = request.ProtocolVersion,
HostCapabilityFingerprint = hostFingerprint,
ClientCapabilityFingerprint = clientFingerprint,
ConnectionTicketFingerprint = ticketFingerprint,
CapabilityDerivationSalt = derivationSalt,
ScopeAttemptLimit = policy.MaxActiveJoinAttempts,
}, cancellationToken);
if (!created.Succeeded || created.Value is null)
{
return new(created.Code.ToContractError());
}
StoredJoinAttempt persisted = created.Value;
clientCapability = Derive(
"join-client-punch",
persisted.ClientSubject,
persisted.IdempotencyKey,
persisted.RequestFingerprint,
persisted.CapabilityDerivationSalt);
if (!capabilities.TryFingerprint(clientCapability, out SecretFingerprint persistedFingerprint)
|| persistedFingerprint != persisted.ClientCapabilityFingerprint)
{
throw new InvalidOperationException("Stored join state could not reproduce its client capability.");
}
return new(RendezvousErrorCode.None, new CreateJoinAttemptResponse
{
AttemptId = persisted.AttemptId,
MediationHandle = persisted.MediationHandle,
ClientPunchCapability = clientCapability,
ExpiresAt = persisted.ExpiresAt,
});
}
public JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> BrowseForHost(
SessionListingId listingId,
int contractVersion,
string? leaseToken,
int pageSize,
string? cursor,
CancellationToken cancellationToken = default)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
if (version != RendezvousErrorCode.None)
{
return new(version);
}
if (!ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|| !ContractValidation.IsPageSizeValid(pageSize)
|| !ContractValidation.IsCursorValid(cursor)
|| !capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint))
{
return new(RendezvousErrorCode.InvalidRequest);
}
if (!cursors.TryDecode(cursor, listingId, clock.UtcNow, out JoinAttemptId? after))
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<IReadOnlyList<StoredJoinAttempt>> found = store.BrowseHostJoinAttempts(new(
listingId,
leaseFingerprint,
pageSize + 1,
after), cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return new(found.Code.ToContractError());
}
bool hasMore = found.Value.Count > pageSize;
StoredJoinAttempt[] page = found.Value.Take(pageSize).ToArray();
BrowseHostJoinAttemptsResponse response = new()
{
Items = page.Select(CreateHostAttempt).ToList(),
NextCursor = hasMore && page.Length > 0
? cursors.Encode(listingId, page[^1].AttemptId, clock.UtcNow)
: null,
};
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
return ContractValidation.IsBrowserResponseSizeValid(encodedBytes)
? new(RendezvousErrorCode.None, response)
: new(RendezvousErrorCode.CapacityExceeded);
}
public JoinAttemptServiceResult<bool> Cancel(
JoinAttemptId attemptId,
string? clientPunchCapability,
CancellationToken cancellationToken = default)
{
if (!ContractValidation.IsCapabilityValid(clientPunchCapability)
|| !capabilities.TryFingerprint(clientPunchCapability, out SecretFingerprint fingerprint))
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<bool> cancelled = store.CancelJoinAttempt(new(attemptId, fingerprint), cancellationToken);
return cancelled.Succeeded
? new(RendezvousErrorCode.None, true)
: new(cancelled.Code.ToContractError());
}
public JoinAttemptServiceResult<ConnectionTicketGrant> IssueConnectionTicket(
StoredJoinAttempt attempt)
{
ArgumentNullException.ThrowIfNull(attempt);
if (!attempt.IntroductionConsumed)
{
return new(RendezvousErrorCode.Conflict);
}
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
{
return new(RendezvousErrorCode.Expired);
}
string ticket = Derive(
"connection-ticket",
attempt.ClientSubject,
attempt.IdempotencyKey,
attempt.RequestFingerprint,
attempt.CapabilityDerivationSalt);
if (!ContractValidation.IsConnectionTicketValid(ticket)
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|| fingerprint != attempt.ConnectionTicketFingerprint)
{
throw new InvalidOperationException("Stored join state could not reproduce its connection ticket.");
}
return new(RendezvousErrorCode.None, new(ticket, attempt.ConnectionTicketExpiresAt));
}
private HostJoinAttempt CreateHostAttempt(StoredJoinAttempt attempt)
{
string capability = Derive(
"join-host-punch",
attempt.ClientSubject,
attempt.IdempotencyKey,
attempt.RequestFingerprint,
attempt.CapabilityDerivationSalt);
if (!ContractValidation.IsCapabilityValid(capability)
|| !capabilities.TryFingerprint(capability, out SecretFingerprint fingerprint)
|| fingerprint != attempt.HostCapabilityFingerprint)
{
throw new InvalidOperationException("Stored join state could not reproduce its host capability.");
}
return new()
{
AttemptId = attempt.AttemptId,
MediationHandle = attempt.MediationHandle,
HostPunchCapability = capability,
ExpiresAt = attempt.ExpiresAt,
};
}
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|| string.IsNullOrEmpty(request.GameId.Value)
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|| request.ListingId.Value == Guid.Empty
|| request.ProtocolVersion == 0
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private static string ComputeRequestFingerprint(CreateJoinAttemptRequest request)
{
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(request, ContractJson.Options);
byte[] digest = SHA256.HashData(encoded);
CryptographicOperations.ZeroMemory(encoded);
try
{
return Encode(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
private string Derive(
string purpose,
string clientSubject,
CreateJoinAttemptRequest request,
string requestFingerprint,
string derivationSalt) => Derive(
purpose,
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt);
private string Derive(
string purpose,
string clientSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt) => capabilities.DeriveCapability(
purpose,
clientSubject,
idempotencyKey,
requestFingerprint,
derivationSalt);
private static bool CredentialLengthsAreValid(
string hostCapability,
string clientCapability,
string ticket) =>
ContractValidation.IsCapabilityValid(hostCapability)
&& ContractValidation.IsCapabilityValid(clientCapability)
&& ContractValidation.IsConnectionTicketValid(ticket)
&& hostCapability.Length == ContractLimits.DerivedCredentialCharacters
&& clientCapability.Length == ContractLimits.DerivedCredentialCharacters
&& ticket.Length == ContractLimits.DerivedCredentialCharacters
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
+15 -2
View File
@@ -1,6 +1,8 @@
using System.Net; using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
@@ -119,6 +121,10 @@ else
builder.Services.AddSingleton<ISessionCapabilityService>(sessionCapabilities); builder.Services.AddSingleton<ISessionCapabilityService>(sessionCapabilities);
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>(); builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
builder.Services.AddSingleton(new ProvisioningReadiness(true)); builder.Services.AddSingleton(new ProvisioningReadiness(true));
} }
@@ -127,12 +133,19 @@ builder.Services
.BindConfiguration(UdpMediatorOptions.SectionName) .BindConfiguration(UdpMediatorOptions.SectionName)
.ValidateDataAnnotations() .ValidateDataAnnotations()
.Validate( .Validate(
options => IPAddress.TryParse(options.ListenAddress, out _), options => IPAddress.TryParse(options.ListenAddress, out IPAddress? address)
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.") && address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork,
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IPv4 address.")
.Validate(
options => string.IsNullOrWhiteSpace(options.Ipv6ListenAddress)
|| (IPAddress.TryParse(options.Ipv6ListenAddress, out IPAddress? address)
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6),
$"{UdpMediatorOptions.SectionName}:Ipv6ListenAddress must be an IPv6 address when configured.")
.ValidateOnStart(); .ValidateOnStart();
builder.Services.AddSingleton<UdpMediatorService>(); builder.Services.AddSingleton<UdpMediatorService>();
if (!isOpenApiGeneration) if (!isOpenApiGeneration)
{ {
builder.Services.AddSingleton<NatMediationProcessor>();
builder.Services.AddHostedService(static services => builder.Services.AddHostedService(static services =>
services.GetRequiredService<UdpMediatorService>()); services.GetRequiredService<UdpMediatorService>());
} }
@@ -122,7 +122,7 @@ internal sealed class PrincipalCredentialService
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature)) if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
{ {
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed); return CredentialValidationResult.Invalid(CredentialValidationError.SignatureInvalid);
} }
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}"; string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
@@ -441,8 +441,15 @@ internal static class Base64Url
try try
{ {
bytes = Convert.FromBase64String(padded); bytes = Convert.FromBase64String(padded);
if (string.Equals(Encode(bytes), value, StringComparison.Ordinal))
{
return true; return true;
} }
CryptographicOperations.ZeroMemory(bytes);
bytes = [];
return false;
}
catch (FormatException) catch (FormatException)
{ {
return false; return false;
@@ -20,6 +20,7 @@ internal interface ISessionCapabilityService
string idempotencyKey, string idempotencyKey,
string requestFingerprint, string requestFingerprint,
string derivationSalt); string derivationSalt);
string DeriveOpaqueIdentifier(string purpose, string value);
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint); bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
} }
@@ -91,6 +92,19 @@ internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDi
} }
} }
public string DeriveOpaqueIdentifier(string purpose, string value)
{
byte[] digest = Derive(purpose, value);
try
{
return Encode(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint) public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
{ {
fingerprint = default; fingerprint = default;
@@ -127,7 +127,7 @@ internal sealed class SessionLeaseService(
ownerLimit), cancellationToken); ownerLimit), cancellationToken);
if (!created.Succeeded || created.Value is null) if (!created.Succeeded || created.Value is null)
{ {
return new(MapStore(created.Code)); return new(created.Code.ToContractError());
} }
ListingDefinition persisted = created.Value.Definition; ListingDefinition persisted = created.Value.Definition;
@@ -205,7 +205,7 @@ internal sealed class SessionLeaseService(
ExpiresAt = renewed.Value.LeaseExpiresAt, ExpiresAt = renewed.Value.LeaseExpiresAt,
RenewAfterSeconds = timing.LeaseRenewAfterSeconds, RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
}) })
: new(MapStore(renewed.Code)); : new(renewed.Code.ToContractError());
} }
public SessionServiceResult<bool> Update( public SessionServiceResult<bool> Update(
@@ -253,7 +253,7 @@ internal sealed class SessionLeaseService(
request.Metadata), cancellationToken); request.Metadata), cancellationToken);
return updated.Succeeded return updated.Succeeded
? new(RendezvousErrorCode.None, true) ? new(RendezvousErrorCode.None, true)
: new(MapStore(updated.Code)); : new(updated.Code.ToContractError());
} }
public SessionServiceResult<bool> Delete( public SessionServiceResult<bool> Delete(
@@ -291,7 +291,7 @@ internal sealed class SessionLeaseService(
publisher.Subject), cancellationToken); publisher.Subject), cancellationToken);
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
? new(RendezvousErrorCode.None, true) ? new(RendezvousErrorCode.None, true)
: new(MapStore(deleted.Code)); : new(deleted.Code.ToContractError());
} }
private RendezvousErrorCode GetAuthorizedListing( private RendezvousErrorCode GetAuthorizedListing(
@@ -315,7 +315,7 @@ internal sealed class SessionLeaseService(
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken); StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
if (!found.Succeeded || found.Value is null) if (!found.Succeeded || found.Value is null)
{ {
return MapStore(found.Code); return found.Code.ToContractError();
} }
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal) if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
@@ -403,18 +403,6 @@ internal sealed class SessionLeaseService(
_ => RendezvousErrorCode.Forbidden, _ => RendezvousErrorCode.Forbidden,
}; };
private static RendezvousErrorCode MapStore(StoreResultCode code) => code switch
{
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
StoreResultCode.Expired => RendezvousErrorCode.Expired,
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
_ => RendezvousErrorCode.InternalError,
};
private static string ComputeRegistrationFingerprint(RegisterSessionRequest request) private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
{ {
RegisterSessionRequest canonical = new() RegisterSessionRequest canonical = new()
@@ -35,6 +35,7 @@ internal sealed record EphemeralStoreOptions
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60); public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20); public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30); public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan ConnectionTicketLifetime { get; init; } = TimeSpan.FromSeconds(20);
public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30); public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2); public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30); public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
@@ -50,9 +51,17 @@ internal sealed record EphemeralStoreOptions
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime)); RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime)); RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime)); RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime));
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime)); RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime)); RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime)); RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
if (ConnectionTicketLifetime > JoinAttemptLifetime)
{
throw new ArgumentOutOfRangeException(
nameof(ConnectionTicketLifetime),
"Connection tickets cannot outlive their join attempt.");
}
if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime) if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime)
{ {
throw new ArgumentOutOfRangeException( throw new ArgumentOutOfRangeException(
@@ -223,7 +232,9 @@ internal sealed record VisibleListingQuery(
TenantScope Scope, TenantScope Scope,
uint ProtocolVersion, uint ProtocolVersion,
RegionId? RegionId, RegionId? RegionId,
int MaximumResults = ContractLimits.BrowserPageMaxItems); int MaximumResults = ContractLimits.BrowserPageMaxItems,
SessionListingId? AfterListingId = null,
bool ExcludeFull = false);
internal enum AttemptPeerRole internal enum AttemptPeerRole
{ {
@@ -244,7 +255,11 @@ internal sealed record CreateJoinAttemptCommand
public required uint ProtocolVersion { get; init; } public required uint ProtocolVersion { get; init; }
public required SecretFingerprint HostCapabilityFingerprint { get; init; } public required SecretFingerprint HostCapabilityFingerprint { get; init; }
public required SecretFingerprint ClientCapabilityFingerprint { get; init; } public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
public required string CapabilityDerivationSalt { get; init; }
public int ScopeAttemptLimit { get; init; } = int.MaxValue; public int ScopeAttemptLimit { get; init; } = int.MaxValue;
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
} }
internal sealed record AttemptEndpointBinding( internal sealed record AttemptEndpointBinding(
@@ -259,12 +274,28 @@ internal sealed record StoredJoinAttempt
public required SessionListingId ListingId { get; init; } public required SessionListingId ListingId { get; init; }
public required string ClientSubject { get; init; } public required string ClientSubject { get; init; }
public required uint ProtocolVersion { get; init; } public required uint ProtocolVersion { get; init; }
public required string IdempotencyKey { get; init; }
public required string RequestFingerprint { get; init; }
public required string CapabilityDerivationSalt { get; init; }
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
public required DateTimeOffset ExpiresAt { get; init; } public required DateTimeOffset ExpiresAt { get; init; }
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
public AttemptEndpointBinding? HostEndpoint { get; init; } public AttemptEndpointBinding? HostEndpoint { get; init; }
public AttemptEndpointBinding? ClientEndpoint { get; init; } public AttemptEndpointBinding? ClientEndpoint { get; init; }
public required bool IntroductionConsumed { get; init; } public required bool IntroductionConsumed { get; init; }
public required bool ConnectionTicketConsumed { get; init; }
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
} }
internal sealed record HostJoinAttemptQuery(
SessionListingId ListingId,
SecretFingerprint LeaseFingerprint,
int MaximumResults,
JoinAttemptId? AfterAttemptId = null);
internal sealed record BindAttemptEndpointCommand( internal sealed record BindAttemptEndpointCommand(
MediationHandle Handle, MediationHandle Handle,
AttemptPeerRole Role, AttemptPeerRole Role,
@@ -273,9 +304,20 @@ internal sealed record BindAttemptEndpointCommand(
ObservedEndpoint? LocalEndpoint); ObservedEndpoint? LocalEndpoint);
internal sealed record IntroductionEndpoints( internal sealed record IntroductionEndpoints(
JoinAttemptId AttemptId, StoredJoinAttempt Attempt,
AttemptEndpointBinding Host, AttemptEndpointBinding Host,
AttemptEndpointBinding Client); AttemptEndpointBinding Client)
{
public JoinAttemptId AttemptId => Attempt.AttemptId;
}
internal sealed record CancelJoinAttemptCommand(
JoinAttemptId AttemptId,
SecretFingerprint ClientCapabilityFingerprint);
internal sealed record ConsumeConnectionTicketCommand(
JoinAttemptId AttemptId,
SecretFingerprint ConnectionTicketFingerprint);
internal sealed record ReplayConsumption( internal sealed record ReplayConsumption(
string Namespace, string Namespace,
@@ -314,8 +356,11 @@ internal interface IEphemeralRendezvousStore
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default); StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default); StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default); StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default); StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default); StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default);
StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default); StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default); StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default); StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
@@ -4,6 +4,7 @@ namespace FinalFactory.Rendezvous.Server.State;
internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousStore internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousStore
{ {
private static readonly TimeSpan UdpMaintenanceInterval = TimeSpan.FromSeconds(1);
private readonly object _gate = new(); private readonly object _gate = new();
private readonly EphemeralStoreOptions _options; private readonly EphemeralStoreOptions _options;
private readonly IMonotonicClock _monotonicClock; private readonly IMonotonicClock _monotonicClock;
@@ -19,6 +20,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal); private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal); private readonly Dictionary<string, TimeSpan> _revocations = new(StringComparer.Ordinal);
private TimeSpan? _drainDeadline; private TimeSpan? _drainDeadline;
private TimeSpan _nextUdpMaintenance;
private long _maintenanceSweepCount;
private bool _available = true; private bool _available = true;
public InMemoryEphemeralRendezvousStore( public InMemoryEphemeralRendezvousStore(
@@ -38,6 +41,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
public Guid InstanceId { get; } public Guid InstanceId { get; }
internal long MaintenanceSweepCount => Interlocked.Read(ref _maintenanceSweepCount);
public bool IsAvailable public bool IsAvailable
{ {
@@ -270,6 +274,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
if (!_presenceHandles.TryGetValue(command.Handle, out SessionListingId listingId) if (!_presenceHandles.TryGetValue(command.Handle, out SessionListingId listingId)
|| !_listings.TryGetValue(listingId, out ListingEntry? entry) || !_listings.TryGetValue(listingId, out ListingEntry? entry)
|| entry.LeaseDeadline <= now
|| entry.Definition.HostPresenceFingerprint != command.CapabilityFingerprint) || entry.Definition.HostPresenceFingerprint != command.CapabilityFingerprint)
{ {
return new(StoreResultCode.NotFound); return new(StoreResultCode.NotFound);
@@ -285,7 +290,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
command.LocalEndpoint, command.LocalEndpoint,
now + _options.PresenceLifetime); now + _options.PresenceLifetime);
return new(StoreResultCode.Success, Snapshot(entry)); return new(StoreResultCode.Success, Snapshot(entry));
}, cancellationToken); }, cancellationToken, eagerCleanup: false);
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings( public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
VisibleListingQuery query, VisibleListingQuery query,
@@ -301,7 +306,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| query.ProtocolVersion == 0 || query.ProtocolVersion == 0
|| (query.RegionId.HasValue && string.IsNullOrEmpty(query.RegionId.Value.Value)) || (query.RegionId.HasValue && string.IsNullOrEmpty(query.RegionId.Value.Value))
|| query.MaximumResults <= 0 || query.MaximumResults <= 0
|| query.MaximumResults > ContractLimits.BrowserPageMaxItems) || query.MaximumResults > ContractLimits.BrowserPageMaxItems + 1)
{ {
throw new ArgumentOutOfRangeException(nameof(query)); throw new ArgumentOutOfRangeException(nameof(query));
} }
@@ -311,6 +316,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
&& entry.Definition.ProtocolVersion == query.ProtocolVersion && entry.Definition.ProtocolVersion == query.ProtocolVersion
&& entry.Definition.Visibility == ListingVisibility.Public && entry.Definition.Visibility == ListingVisibility.Public
&& (!query.RegionId.HasValue || entry.Definition.RegionId == query.RegionId.Value) && (!query.RegionId.HasValue || entry.Definition.RegionId == query.RegionId.Value)
&& (!query.AfterListingId.HasValue
|| entry.Definition.ListingId.Value.CompareTo(query.AfterListingId.Value.Value) > 0)
&& (!query.ExcludeFull
|| entry.Definition.CurrentPlayers < entry.Definition.MaximumPlayers)
&& _presence.ContainsKey(entry.Definition.HostPresenceHandle)) && _presence.ContainsKey(entry.Definition.HostPresenceHandle))
.OrderBy(static entry => entry.Definition.ListingId.Value) .OrderBy(static entry => entry.Definition.ListingId.Value)
.Take(query.MaximumResults) .Take(query.MaximumResults)
@@ -386,9 +395,74 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.Success, Snapshot(attempt)); return new(StoreResultCode.Success, Snapshot(attempt));
}, cancellationToken); }, cancellationToken);
public StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(
HostJoinAttemptQuery query,
CancellationToken cancellationToken = default) => Atomic<IReadOnlyList<StoredJoinAttempt>>(_ =>
{
ArgumentNullException.ThrowIfNull(query);
if (query.ListingId.Value == Guid.Empty
|| !query.LeaseFingerprint.IsValid
|| query.MaximumResults is < 1 or > ContractLimits.BrowserPageMaxItems + 1)
{
throw new ArgumentException("Host attempt query invariants are invalid.", nameof(query));
}
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!_listings.TryGetValue(query.ListingId, out ListingEntry? listing)
|| listing.Definition.LeaseFingerprint != query.LeaseFingerprint)
{
return new(StoreResultCode.NotFound);
}
IReadOnlyList<StoredJoinAttempt> attempts = _attempts.Values
.Where(entry => entry.Command.ListingId == query.ListingId
&& !entry.IntroductionConsumed
&& (!query.AfterAttemptId.HasValue
|| entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0))
.OrderBy(static entry => entry.Command.AttemptId.Value)
.Take(query.MaximumResults)
.Select(Snapshot)
.ToArray();
return new(StoreResultCode.Success, attempts);
}, cancellationToken);
public StoreResult<bool> CancelJoinAttempt(
CancelJoinAttemptCommand command,
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
{
ArgumentNullException.ThrowIfNull(command);
if (command.AttemptId.Value == Guid.Empty || !command.ClientCapabilityFingerprint.IsValid)
{
throw new ArgumentException("Join cancellation invariants are invalid.", nameof(command));
}
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|| attempt.ClientCapabilityFingerprint != command.ClientCapabilityFingerprint)
{
return new(StoreResultCode.NotFound);
}
if (attempt.IntroductionConsumed)
{
return new(StoreResultCode.Conflict);
}
RemoveAttempt(command.AttemptId);
return new(StoreResultCode.Success, true);
}, cancellationToken);
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint( public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
BindAttemptEndpointCommand command, BindAttemptEndpointCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ => CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(now =>
{ {
ArgumentNullException.ThrowIfNull(command); ArgumentNullException.ThrowIfNull(command);
if (command.Handle.Value == Guid.Empty if (command.Handle.Value == Guid.Empty
@@ -405,7 +479,13 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId) if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId)
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)) || !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|| attempt.Deadline <= now)
{
return new(StoreResultCode.NotFound);
}
if (!HasFreshHostPresence(attempt, now))
{ {
return new(StoreResultCode.NotFound); return new(StoreResultCode.NotFound);
} }
@@ -439,11 +519,11 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
return new(StoreResultCode.Success, Snapshot(attempt)); return new(StoreResultCode.Success, Snapshot(attempt));
}, cancellationToken); }, cancellationToken, eagerCleanup: false);
public StoreResult<IntroductionEndpoints> ConsumeIntroduction( public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
MediationHandle handle, MediationHandle handle,
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(_ => CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(now =>
{ {
if (!_available) if (!_available)
{ {
@@ -451,7 +531,13 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId) if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId)
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)) || !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|| attempt.Deadline <= now)
{
return new(StoreResultCode.NotFound);
}
if (!HasFreshHostPresence(attempt, now))
{ {
return new(StoreResultCode.NotFound); return new(StoreResultCode.NotFound);
} }
@@ -467,10 +553,55 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
attempt.IntroductionConsumed = true; attempt.IntroductionConsumed = true;
TimeSpan ticketLifetime = TimeSpan.FromTicks(Math.Min(
_options.ConnectionTicketLifetime.Ticks,
(attempt.Deadline - now).Ticks));
attempt.TicketDeadline = now + ticketLifetime;
attempt.TicketWallExpiresAt = WallDeadline(now, ticketLifetime);
return new(StoreResultCode.Success, new( return new(StoreResultCode.Success, new(
attempt.Command.AttemptId, Snapshot(attempt),
attempt.HostEndpoint, attempt.HostEndpoint,
attempt.ClientEndpoint)); attempt.ClientEndpoint));
}, cancellationToken, eagerCleanup: false);
public StoreResult<bool> ConsumeConnectionTicket(
ConsumeConnectionTicketCommand command,
CancellationToken cancellationToken = default) => Atomic<bool>(now =>
{
ArgumentNullException.ThrowIfNull(command);
if (command.AttemptId.Value == Guid.Empty || !command.ConnectionTicketFingerprint.IsValid)
{
throw new ArgumentException("Connection ticket invariants are invalid.", nameof(command));
}
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|| attempt.ConnectionTicketFingerprint != command.ConnectionTicketFingerprint)
{
return new(StoreResultCode.NotFound);
}
if (!attempt.IntroductionConsumed)
{
return new(StoreResultCode.Conflict);
}
if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now)
{
return new(StoreResultCode.Expired);
}
if (attempt.ConnectionTicketConsumed)
{
return new(StoreResultCode.ReplayRejected);
}
attempt.ConnectionTicketConsumed = true;
return new(StoreResultCode.Success, true);
}, cancellationToken); }, cancellationToken);
public StoreResult<bool> ConsumeReplay( public StoreResult<bool> ConsumeReplay(
@@ -578,18 +709,38 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
} }
private StoreResult<T> Atomic<T>(Func<TimeSpan, StoreResult<T>> operation, CancellationToken cancellationToken) private StoreResult<T> Atomic<T>(
Func<TimeSpan, StoreResult<T>> operation,
CancellationToken cancellationToken,
bool eagerCleanup = true)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
lock (_gate) lock (_gate)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
TimeSpan now = _monotonicClock.Elapsed; TimeSpan now = _monotonicClock.Elapsed;
// Authenticated UDP duplicates need O(1) store work. Their operations
// check exact resource deadlines and amortize physical expiry removal.
bool drainExpired = _drainDeadline is TimeSpan drainDeadline
&& now >= drainDeadline;
if (drainExpired || eagerCleanup || now >= _nextUdpMaintenance)
{
Cleanup(now); Cleanup(now);
_nextUdpMaintenance = now + UdpMaintenanceInterval;
}
return operation(now); return operation(now);
} }
} }
private bool HasFreshHostPresence(AttemptEntry attempt, TimeSpan now) =>
_listings.TryGetValue(attempt.Command.ListingId, out ListingEntry? listing)
&& listing.LeaseDeadline > now
&& _presence.TryGetValue(
listing.Definition.HostPresenceHandle,
out PresenceEntry? presence)
&& presence.Deadline > now;
private StoreResult<T>? CheckNewWorkAdmission<T>(string subject) private StoreResult<T>? CheckNewWorkAdmission<T>(string subject)
{ {
if (!_available) if (!_available)
@@ -609,6 +760,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private void Cleanup(TimeSpan now) private void Cleanup(TimeSpan now)
{ {
_maintenanceSweepCount++;
if (_drainDeadline is TimeSpan drainDeadline && now >= drainDeadline) if (_drainDeadline is TimeSpan drainDeadline && now >= drainDeadline)
{ {
ClearActiveState(); ClearActiveState();
@@ -710,10 +862,18 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
ListingId = entry.Command.ListingId, ListingId = entry.Command.ListingId,
ClientSubject = entry.Command.ClientSubject, ClientSubject = entry.Command.ClientSubject,
ProtocolVersion = entry.Command.ProtocolVersion, ProtocolVersion = entry.Command.ProtocolVersion,
IdempotencyKey = entry.Command.IdempotencyKey,
RequestFingerprint = entry.Command.RequestFingerprint,
CapabilityDerivationSalt = entry.Command.CapabilityDerivationSalt,
HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint,
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
ExpiresAt = entry.WallExpiresAt, ExpiresAt = entry.WallExpiresAt,
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
HostEndpoint = entry.HostEndpoint, HostEndpoint = entry.HostEndpoint,
ClientEndpoint = entry.ClientEndpoint, ClientEndpoint = entry.ClientEndpoint,
IntroductionConsumed = entry.IntroductionConsumed, IntroductionConsumed = entry.IntroductionConsumed,
ConnectionTicketConsumed = entry.ConnectionTicketConsumed,
}; };
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now) private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
@@ -785,6 +945,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| command.ProtocolVersion == 0 || command.ProtocolVersion == 0
|| !command.HostCapabilityFingerprint.IsValid || !command.HostCapabilityFingerprint.IsValid
|| !command.ClientCapabilityFingerprint.IsValid || !command.ClientCapabilityFingerprint.IsValid
|| !command.ConnectionTicketFingerprint.IsValid
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|| command.ScopeAttemptLimit <= 0) || command.ScopeAttemptLimit <= 0)
{ {
throw new ArgumentException("Join attempt invariants are invalid.", nameof(command)); throw new ArgumentException("Join attempt invariants are invalid.", nameof(command));
@@ -849,11 +1011,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
public CreateJoinAttemptCommand Command { get; } = command; public CreateJoinAttemptCommand Command { get; } = command;
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint; public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint; public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
public TimeSpan Deadline { get; } = deadline; public TimeSpan Deadline { get; } = deadline;
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt; public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
public TimeSpan? TicketDeadline { get; set; }
public DateTimeOffset? TicketWallExpiresAt { get; set; }
public AttemptEndpointBinding? HostEndpoint { get; set; } public AttemptEndpointBinding? HostEndpoint { get; set; }
public AttemptEndpointBinding? ClientEndpoint { get; set; } public AttemptEndpointBinding? ClientEndpoint { get; set; }
public bool IntroductionConsumed { get; set; } public bool IntroductionConsumed { get; set; }
public bool ConnectionTicketConsumed { get; set; }
} }
private sealed record IdempotencyEntry( private sealed record IdempotencyEntry(
@@ -0,0 +1,20 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.State;
internal static class StoreResultMapping
{
public static RendezvousErrorCode ToContractError(this StoreResultCode code) => code switch
{
StoreResultCode.Success => RendezvousErrorCode.None,
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
StoreResultCode.Expired => RendezvousErrorCode.Expired,
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
RendezvousErrorCode.ServiceUnavailable,
_ => RendezvousErrorCode.InternalError,
};
}
@@ -0,0 +1,73 @@
using System.Buffers.Binary;
using System.Net;
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Transport;
internal static class LiteNetNatRequestCodec
{
private const byte NatMessageProperty = 17;
private const int TypeIdentifierLength = 8;
private const int TokenLengthPrefix = NatPunchRequestTokenCodec.EncodedLength + 1;
// LiteNetLib 2.1.4's private NatIntroduceRequest type ID. The native socket
// integration test deliberately fails if a package upgrade changes this wire value.
private static ReadOnlySpan<byte> RequestTypeIdentifier =>
[0x88, 0xbe, 0x10, 0x26, 0xbf, 0xb1, 0x66, 0x9c];
public static bool TryDecode(
ReadOnlySpan<byte> datagram,
out IPEndPoint? claimedLocalEndpoint,
out string? token)
{
claimedLocalEndpoint = null;
token = null;
if (datagram.Length < 1 + TypeIdentifierLength + 1 + 4 + 2 + 2
+ NatPunchRequestTokenCodec.EncodedLength
|| datagram[0] != NatMessageProperty
|| !datagram.Slice(1, TypeIdentifierLength).SequenceEqual(RequestTypeIdentifier))
{
return false;
}
int offset = 1 + TypeIdentifierLength;
int addressLength = datagram[offset++] switch
{
0 => 4,
1 => 16,
_ => 0,
};
int expectedLength = offset + addressLength + 2 + 2
+ NatPunchRequestTokenCodec.EncodedLength;
if (addressLength == 0 || datagram.Length != expectedLength)
{
return false;
}
IPAddress localAddress = new(datagram.Slice(offset, addressLength));
offset += addressLength;
int localPort = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
offset += 2;
int encodedTokenLength = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
offset += 2;
if (localPort == 0 || encodedTokenLength != TokenLengthPrefix)
{
return false;
}
ReadOnlySpan<byte> tokenBytes = datagram.Slice(
offset,
NatPunchRequestTokenCodec.EncodedLength);
for (int index = 0; index < tokenBytes.Length; index++)
{
if (tokenBytes[index] > 0x7f)
{
return false;
}
}
claimedLocalEndpoint = new(localAddress, localPort);
token = Encoding.ASCII.GetString(tokenBytes);
return true;
}
}
@@ -0,0 +1,323 @@
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Transport;
internal interface INatIntroductionSink
{
void Introduce(NatIntroductionPlan plan);
}
internal sealed record NatIntroductionPlan(
IPEndPoint HostLocal,
IPEndPoint HostPublic,
IPEndPoint ClientLocal,
IPEndPoint ClientPublic,
string ConnectionTicket)
{
public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]";
}
internal enum NatMediationResult
{
Dropped = 0,
HostPresenceAccepted = 1,
HostPresenceRejected = 2,
WaitingForPeer = 3,
Introduced = 4,
Duplicate = 5,
Rejected = 6,
}
internal sealed class NatMediationProcessor(
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities,
JoinAttemptService joinAttempts)
{
public NatMediationResult ProcessDatagram(
ReadOnlySpan<byte> encoded,
IPEndPoint observedPublicEndpoint,
INatIntroductionSink introductionSink,
CancellationToken cancellationToken = default)
{
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|| datagram is null
|| datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters
|| !IPAddress.TryParse(datagram.LocalAddress, out IPAddress? localAddress))
{
return NatMediationResult.Dropped;
}
IPEndPoint claimedLocalEndpoint = new(localAddress, datagram.LocalPort);
NatPunchPeerRole role = datagram.MessageType == UdpPresenceMessageType.ClientPresence
? NatPunchPeerRole.Client
: NatPunchPeerRole.HostPresence;
bool observedIpv6 = observedPublicEndpoint.AddressFamily == AddressFamily.InterNetworkV6
&& !observedPublicEndpoint.Address.IsIPv4MappedToIPv6;
if (role == NatPunchPeerRole.Client && observedIpv6)
{
return NatMediationResult.Dropped;
}
NatMediationResult result = ProcessRequest(
claimedLocalEndpoint,
observedPublicEndpoint,
NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability),
introductionSink,
cancellationToken);
if (role != NatPunchPeerRole.HostPresence
|| result != NatMediationResult.HostPresenceRejected
|| observedIpv6)
{
return result;
}
return ProcessRequest(
claimedLocalEndpoint,
observedPublicEndpoint,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Host,
datagram.MediationHandle,
datagram.Capability),
introductionSink,
cancellationToken);
}
public NatMediationResult ProcessRequest(
IPEndPoint claimedLocalEndpoint,
IPEndPoint observedPublicEndpoint,
string token,
INatIntroductionSink introductionSink,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(claimedLocalEndpoint);
ArgumentNullException.ThrowIfNull(observedPublicEndpoint);
ArgumentNullException.ThrowIfNull(introductionSink);
if (!NatPunchRequestTokenCodec.TryDecode(token, out NatPunchRequestToken? request)
|| request is null
|| !TryCreateObservedEndpoint(observedPublicEndpoint, out ObservedEndpoint publicEndpoint)
|| !capabilities.TryFingerprint(request.Capability, out SecretFingerprint fingerprint))
{
return NatMediationResult.Dropped;
}
ObservedEndpoint? localEndpoint = TryCreatePrivateCandidate(
claimedLocalEndpoint,
publicEndpoint.AddressFamily,
out ObservedEndpoint candidate)
? candidate
: null;
if (request.Role == NatPunchPeerRole.HostPresence)
{
StoreResult<StoredListing> presence = store.BindHostPresence(new(
request.MediationHandle,
fingerprint,
publicEndpoint,
localEndpoint), cancellationToken);
return presence.Succeeded
? NatMediationResult.HostPresenceAccepted
: NatMediationResult.HostPresenceRejected;
}
AttemptPeerRole role = request.Role switch
{
NatPunchPeerRole.Host => AttemptPeerRole.Host,
NatPunchPeerRole.Client => AttemptPeerRole.Client,
_ => default,
};
if (role == default)
{
return NatMediationResult.Dropped;
}
StoreResult<StoredJoinAttempt> bound = store.BindAttemptEndpoint(new(
request.MediationHandle,
role,
fingerprint,
publicEndpoint,
localEndpoint), cancellationToken);
if (!bound.Succeeded || bound.Value is null)
{
return bound.Code == StoreResultCode.ReplayRejected
? NatMediationResult.Rejected
: NatMediationResult.Dropped;
}
StoredJoinAttempt attempt = bound.Value;
if (attempt.IntroductionConsumed)
{
return NatMediationResult.Duplicate;
}
if (attempt.HostEndpoint is null || attempt.ClientEndpoint is null)
{
return NatMediationResult.WaitingForPeer;
}
if (attempt.HostEndpoint.PublicEndpoint.AddressFamily
!= attempt.ClientEndpoint.PublicEndpoint.AddressFamily)
{
return NatMediationResult.Rejected;
}
StoreResult<IntroductionEndpoints> consumed = store.ConsumeIntroduction(
request.MediationHandle,
cancellationToken);
if (!consumed.Succeeded || consumed.Value is null)
{
return consumed.Code == StoreResultCode.ReplayRejected
? NatMediationResult.Duplicate
: NatMediationResult.Rejected;
}
JoinAttemptServiceResult<ConnectionTicketGrant> ticket = joinAttempts.IssueConnectionTicket(
consumed.Value.Attempt);
if (!ticket.Succeeded || ticket.Value is null)
{
return NatMediationResult.Rejected;
}
try
{
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value.Ticket));
return NatMediationResult.Introduced;
}
catch (Exception exception) when (exception is SocketException
or InvalidOperationException
or ArgumentException)
{
return NatMediationResult.Rejected;
}
}
private static NatIntroductionPlan CreatePlan(
IntroductionEndpoints endpoints,
string connectionTicket)
{
IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint);
IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint);
bool sameNat = hostPublic.Address.Equals(clientPublic.Address);
IPEndPoint hostLocal = sameNat && endpoints.Host.LocalEndpoint is { } hostCandidate
? ToIpEndpoint(hostCandidate)
: hostPublic;
IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate
? ToIpEndpoint(clientCandidate)
: clientPublic;
return new(hostLocal, hostPublic, clientLocal, clientPublic, connectionTicket);
}
private static bool TryCreateObservedEndpoint(
IPEndPoint source,
out ObservedEndpoint endpoint)
{
endpoint = default;
if (source.Port is < 1 or > 65_535)
{
return false;
}
IPAddress address = source.Address.IsIPv4MappedToIPv6
? source.Address.MapToIPv4()
: source.Address;
if (address.Equals(IPAddress.Any)
|| address.Equals(IPAddress.IPv6Any)
|| address.IsIPv6Multicast
|| IsIpv4MulticastOrBroadcast(address)
|| (address.AddressFamily == AddressFamily.InterNetworkV6
&& !IsGlobalIpv6(address)))
{
return false;
}
AddressFamilyKind family = address.AddressFamily switch
{
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
_ => default,
};
if (family == default)
{
return false;
}
endpoint = new(family, address.ToString(), source.Port);
return true;
}
private static bool TryCreatePrivateCandidate(
IPEndPoint source,
AddressFamilyKind publicFamily,
out ObservedEndpoint endpoint)
{
endpoint = default;
if (source.Port is < 1 or > 65_535)
{
return false;
}
IPAddress address = source.Address.IsIPv4MappedToIPv6
? source.Address.MapToIPv4()
: source.Address;
AddressFamilyKind family = address.AddressFamily switch
{
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
_ => default,
};
if (family != publicFamily || !IsPrivateUnicast(address))
{
return false;
}
endpoint = new(family, address.ToString(), source.Port);
return true;
}
private static bool IsPrivateUnicast(IPAddress address)
{
byte[] bytes = address.GetAddressBytes();
return address.AddressFamily switch
{
AddressFamily.InterNetwork => bytes[0] == 10
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 192 && bytes[1] == 168),
AddressFamily.InterNetworkV6 => (bytes[0] & 0xfe) == 0xfc,
_ => false,
};
}
private static bool IsGlobalIpv6(IPAddress address) =>
!address.Equals(IPAddress.IPv6Loopback)
&& !address.Equals(IPAddress.IPv6Any)
&& !address.IsIPv6LinkLocal
&& !address.IsIPv6Multicast
&& !address.IsIPv6SiteLocal
&& !IsPrivateUnicast(address)
&& !IsDocumentationIpv6(address);
private static bool IsIpv4MulticastOrBroadcast(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetwork)
{
return false;
}
byte[] bytes = address.GetAddressBytes();
return bytes[0] >= 224 || bytes.All(static value => value == byte.MaxValue);
}
private static bool IsDocumentationIpv6(IPAddress address)
{
byte[] bytes = address.GetAddressBytes();
return bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8;
}
private static IPEndPoint ToIpEndpoint(ObservedEndpoint endpoint) =>
new(IPAddress.Parse(endpoint.Address), endpoint.Port);
}
@@ -18,9 +18,17 @@ public sealed class UdpMediatorOptions
[Required] [Required]
public string ListenAddress { get; set; } = "0.0.0.0"; public string ListenAddress { get; set; } = "0.0.0.0";
public string? Ipv6ListenAddress { get; set; }
/// <summary> /// <summary>
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests. /// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
/// </summary> /// </summary>
[Range(0, 65_535)] [Range(0, 65_535)]
public int Port { get; set; } = 9050; public int Port { get; set; } = 9050;
[Range(1, 4_096)]
public int MaxDatagramsPerPoll { get; set; } = 256;
[Range(1, 100)]
public int PollIntervalMilliseconds { get; set; } = 2;
} }
@@ -1,161 +1,136 @@
using System.Diagnostics;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Sessions; using LiteNetLib;
using FinalFactory.Rendezvous.Server.State; using LiteNetLib.Layers;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Transport; namespace FinalFactory.Rendezvous.Server.Transport;
/// <summary>
/// Owns the cancellable UDP socket used by the future NAT mediator.
/// </summary>
internal sealed partial class UdpMediatorService : BackgroundService internal sealed partial class UdpMediatorService : BackgroundService
{ {
private readonly ILogger<UdpMediatorService> _logger; private readonly ILogger<UdpMediatorService> _logger;
private readonly UdpMediatorOptions _options; private readonly UdpMediatorOptions _options;
private readonly IEphemeralRendezvousStore _store; private readonly NatMediationProcessor _processor;
private readonly ISessionCapabilityService _capabilities; private LiteNetManager? _manager;
private UdpClient? _udpClient; private LiteNetIntroductionSink? _introductionSink;
/// <summary>
/// Initializes a new UDP mediator service.
/// </summary>
public UdpMediatorService( public UdpMediatorService(
IOptions<UdpMediatorOptions> options, IOptions<UdpMediatorOptions> options,
ILogger<UdpMediatorService> logger, ILogger<UdpMediatorService> logger,
IEphemeralRendezvousStore store, NatMediationProcessor processor)
ISessionCapabilityService capabilities)
{ {
_options = options.Value; _options = options.Value;
_logger = logger; _logger = logger;
_store = store; _processor = processor;
_capabilities = capabilities;
} }
/// <summary>
/// Gets the bound endpoint after startup completes.
/// </summary>
public IPEndPoint? LocalEndpoint { get; private set; } public IPEndPoint? LocalEndpoint { get; private set; }
public IPEndPoint? LocalIpv6Endpoint { get; private set; }
/// <inheritdoc />
public override Task StartAsync(CancellationToken cancellationToken) public override Task StartAsync(CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (_manager is not null)
if (_udpClient is not null)
{ {
throw new InvalidOperationException("The UDP mediator is already running."); throw new InvalidOperationException("The UDP mediator is already running.");
} }
IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress); IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress);
UdpClient udpClient = new(new IPEndPoint(listenAddress, _options.Port)); if (listenAddress.AddressFamily != AddressFamily.InterNetwork)
_udpClient = udpClient; {
IPEndPoint localEndpoint = throw new InvalidOperationException("The required UDP listen address must be IPv4.");
(IPEndPoint?)udpClient.Client.LocalEndPoint }
?? throw new InvalidOperationException("The UDP socket did not expose its bound endpoint.");
LocalEndpoint = localEndpoint;
LogMediatorListening(_logger, localEndpoint.Address, localEndpoint.Port); IPAddress? ipv6ListenAddress = string.IsNullOrWhiteSpace(_options.Ipv6ListenAddress)
? null
: IPAddress.Parse(_options.Ipv6ListenAddress);
if (ipv6ListenAddress is not null
&& ipv6ListenAddress.AddressFamily != AddressFamily.InterNetworkV6)
{
throw new InvalidOperationException("The optional UDP IPv6 listen address must be IPv6.");
}
EventBasedLiteNetListener listener = new();
RendezvousPacketLayer packetLayer = new(_processor);
LiteNetManager manager = new(listener, packetLayer)
{
NatPunchEnabled = true,
IPv6Enabled = ipv6ListenAddress is not null,
UnsyncedEvents = true,
MaxPacketPerManualReceive = _options.MaxDatagramsPerPoll,
};
manager.NatPunchModule.UnsyncedEvents = true;
_introductionSink = new(manager.NatPunchModule);
packetLayer.Attach(_introductionSink);
if (!manager.StartInManualMode(
listenAddress,
ipv6ListenAddress ?? IPAddress.IPv6Any,
_options.Port))
{
_introductionSink = null;
manager.Stop();
throw new InvalidOperationException("The UDP mediator could not bind its LiteNetLib socket.");
}
_manager = manager;
LocalEndpoint = new(listenAddress, manager.LocalPort);
LocalIpv6Endpoint = ipv6ListenAddress is null
? null
: new(ipv6ListenAddress, manager.LocalPort);
LogMediatorListening(_logger, listenAddress, manager.LocalPort);
return base.StartAsync(cancellationToken); return base.StartAsync(cancellationToken);
} }
/// <inheritdoc />
public override async Task StopAsync(CancellationToken cancellationToken) public override async Task StopAsync(CancellationToken cancellationToken)
{ {
await base.StopAsync(cancellationToken).ConfigureAwait(false); await base.StopAsync(cancellationToken).ConfigureAwait(false);
_udpClient?.Dispose(); StopManager();
_udpClient = null;
LocalEndpoint = null;
LogMediatorStopped(_logger); LogMediatorStopped(_logger);
} }
/// <inheritdoc />
public override void Dispose() public override void Dispose()
{ {
_udpClient?.Dispose(); StopManager();
_udpClient = null;
LocalEndpoint = null;
base.Dispose(); base.Dispose();
} }
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
UdpClient udpClient = _udpClient LiteNetManager manager = _manager
?? throw new InvalidOperationException("The UDP mediator socket was not initialized."); ?? throw new InvalidOperationException("The UDP mediator socket was not initialized.");
long previous = Stopwatch.GetTimestamp();
try try
{ {
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
UdpReceiveResult received = await udpClient manager.PollEvents();
.ReceiveAsync(stoppingToken) manager.NatPunchModule.PollEvents();
.ConfigureAwait(false); long current = Stopwatch.GetTimestamp();
ProcessDatagram(received.Buffer, received.RemoteEndPoint, stoppingToken); manager.ManualUpdate((float)Stopwatch.GetElapsedTime(previous, current).TotalMilliseconds);
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11. previous = current;
await Task.Delay(_options.PollIntervalMilliseconds, stoppingToken).ConfigureAwait(false);
} }
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
// Expected during normal shutdown.
}
catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested)
{
// Disposing the socket is the fallback that releases a blocked receive.
} }
finally finally
{ {
LocalEndpoint = null; LocalEndpoint = null;
LocalIpv6Endpoint = null;
} }
} }
internal UdpPresenceProcessingResult ProcessDatagram( private void StopManager()
ReadOnlySpan<byte> encoded,
IPEndPoint observedSource,
CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(observedSource); LiteNetManager? manager = Interlocked.Exchange(ref _manager, null);
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _) _introductionSink = null;
|| datagram is null LocalEndpoint = null;
|| !_capabilities.TryFingerprint(datagram.Capability, out SecretFingerprint fingerprint)) LocalIpv6Endpoint = null;
{ manager?.Stop();
return UdpPresenceProcessingResult.Dropped;
}
if (datagram.MessageType != UdpPresenceMessageType.HostPresence)
{
return UdpPresenceProcessingResult.ClientPresenceDeferred;
}
AddressFamilyKind publicFamily = observedSource.AddressFamily switch
{
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
_ => 0,
};
if (publicFamily == 0)
{
return UdpPresenceProcessingResult.Dropped;
}
ObservedEndpoint publicEndpoint = new(
publicFamily,
observedSource.Address.ToString(),
observedSource.Port);
ObservedEndpoint localEndpoint = new(
datagram.AddressFamily,
datagram.LocalAddress,
datagram.LocalPort);
StoreResult<StoredListing> bound = _store.BindHostPresence(new(
datagram.MediationHandle,
fingerprint,
publicEndpoint,
localEndpoint), cancellationToken);
return bound.Succeeded
? UdpPresenceProcessingResult.HostPresenceAccepted
: UdpPresenceProcessingResult.HostPresenceRejected;
} }
[LoggerMessage( [LoggerMessage(
@@ -172,12 +147,62 @@ internal sealed partial class UdpMediatorService : BackgroundService
Level = LogLevel.Information, Level = LogLevel.Information,
Message = "UDP mediator stopped")] Message = "UDP mediator stopped")]
private static partial void LogMediatorStopped(ILogger logger); private static partial void LogMediatorStopped(ILogger logger);
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
{
public void Introduce(NatIntroductionPlan plan) => module.NatIntroduce(
plan.HostLocal,
plan.HostPublic,
plan.ClientLocal,
plan.ClientPublic,
plan.ConnectionTicket);
} }
internal enum UdpPresenceProcessingResult private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
{ {
Dropped = 0, private INatIntroductionSink? _sink;
HostPresenceAccepted = 1,
HostPresenceRejected = 2, public void Attach(INatIntroductionSink sink) => _sink = sink;
ClientPresenceDeferred = 3,
public override void ProcessInboundPacket(
ref IPEndPoint endPoint,
ref byte[] data,
ref int length)
{
bool isFrozenEnvelope = length >= 2
&& data[0] == RendezvousUdpCodec.MagicFirst
&& data[1] == RendezvousUdpCodec.MagicSecond;
INatIntroductionSink? sink = _sink;
if (isFrozenEnvelope)
{
if (sink is not null)
{
_ = processor.ProcessDatagram(data.AsSpan(0, length), endPoint, sink);
}
}
else if (sink is not null
&& LiteNetNatRequestCodec.TryDecode(
data.AsSpan(0, length),
out IPEndPoint? claimedLocalEndpoint,
out string? token)
&& claimedLocalEndpoint is not null
&& token is not null)
{
_ = processor.ProcessRequest(claimedLocalEndpoint, endPoint, token, sink);
}
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
Drop(ref length);
}
public override void ProcessOutBoundPacket(
ref IPEndPoint endPoint,
ref byte[] data,
ref int offset,
ref int length)
{
}
private static void Drop(ref int length) => length = 0;
}
} }
@@ -2,7 +2,9 @@
"Rendezvous": { "Rendezvous": {
"Udp": { "Udp": {
"ListenAddress": "0.0.0.0", "ListenAddress": "0.0.0.0",
"Port": 9050 "Port": 9050,
"MaxDatagramsPerPoll": 256,
"PollIntervalMilliseconds": 2
} }
}, },
"Logging": { "Logging": {
@@ -0,0 +1,153 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.Browser;
public sealed class SessionBrowserServiceTests
{
[Fact]
public void ListEnforcesTenantProtocolPresenceVisibilityAndAvailabilityFilters()
{
using SessionBrowserFixture fixture = new();
StoredListing eligible = fixture.Add();
fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId));
fixture.Add(scope: new(fixture.Scope.GameId, new("other-env")));
fixture.Add(protocolVersion: 8);
fixture.Add(regionId: new("us-east"));
fixture.Add(visibility: ListingVisibility.Unlisted);
fixture.Add(fresh: false);
fixture.Add(currentPlayers: 8, maximumPlayers: 8);
BrowseSessionsRequest request = fixture.Request();
request.ExcludeFull = true;
BrowserServiceResult<BrowseSessionsResponse> result = fixture.Browser.Browse(request);
Assert.True(result.Succeeded);
Assert.Collection(result.Value!.Items, item => Assert.Equal(eligible.Definition.ListingId, item.ListingId));
}
[Fact]
public void UnguessableIdRetrievalAllowsFreshUnlistedOnlyWithinExactScope()
{
using SessionBrowserFixture fixture = new();
StoredListing unlisted = fixture.Add(visibility: ListingVisibility.Unlisted);
Assert.True(fixture.Browser.Get(
unlisted.Definition.ListingId,
fixture.Scope.GameId,
fixture.Scope.EnvironmentId,
7).Succeeded);
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
unlisted.Definition.ListingId,
new("other-game"),
fixture.Scope.EnvironmentId,
7).Error);
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
unlisted.Definition.ListingId,
fixture.Scope.GameId,
fixture.Scope.EnvironmentId,
8).Error);
fixture.Clock.Advance(TimeSpan.FromSeconds(20));
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
unlisted.Definition.ListingId,
fixture.Scope.GameId,
fixture.Scope.EnvironmentId,
7).Error);
}
[Fact]
public void KeysetCursorReturnsStableRecordsOnceAndRejectsTamperingOrRescoping()
{
using SessionBrowserFixture fixture = new();
for (int index = 0; index < 7; index++)
{
fixture.Add();
}
BrowseSessionsRequest request = fixture.Request(pageSize: 2);
List<SessionListingId> seen = [];
do
{
BrowseSessionsResponse page = fixture.Browser.Browse(request).Value!;
seen.AddRange(page.Items.Select(static item => item.ListingId));
request.Cursor = page.NextCursor;
}
while (request.Cursor is not null);
Assert.Equal(7, seen.Count);
Assert.Equal(7, seen.Distinct().Count());
Assert.Equal(seen.OrderBy(static id => id.Value), seen);
BrowseSessionsRequest tampered = fixture.Request(pageSize: 2);
tampered.Cursor = fixture.Browser.Browse(tampered).Value!.NextCursor + "A";
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(tampered).Error);
BrowseSessionsRequest rescoped = fixture.Request(pageSize: 2);
rescoped.Cursor = fixture.Browser.Browse(fixture.Request(pageSize: 2)).Value!.NextCursor;
rescoped.ExcludeFull = true;
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(rescoped).Error);
BrowseSessionsRequest expired = fixture.Request(pageSize: 2);
expired.Cursor = fixture.Browser.Browse(expired).Value!.NextCursor;
fixture.Clock.Advance(TimeSpan.FromMinutes(5));
Assert.Equal(RendezvousErrorCode.InvalidRequest, fixture.Browser.Browse(expired).Error);
}
[Fact]
public void ResponseByteBudgetTrimsLargePagesAndContinuesWithCursor()
{
using SessionBrowserFixture fixture = new();
Dictionary<string, string> metadata = Enumerable.Range(0, 14).ToDictionary(
static index => $"key-{index}",
static index => new string((char)('a' + index % 26), 256),
EqualityComparer<string>.Default);
for (int index = 0; index < 100; index++)
{
fixture.Add(metadata: metadata);
}
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
Assert.InRange(encodedBytes, 1, ContractLimits.BrowserResponseMaxBytes);
Assert.NotEmpty(response.Items);
Assert.NotNull(response.NextCursor);
Assert.True(response.Items.Count < 100);
}
[Fact]
public void PresentationMetadataIsJsonEscapedAndResponseHasNoConnectionSecrets()
{
using SessionBrowserFixture fixture = new();
fixture.Add(metadata: new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "<script>alert(1)</script>",
});
BrowseSessionsResponse response = fixture.Browser.Browse(fixture.Request()).Value!;
string json = JsonSerializer.Serialize(response, ContractJson.Options);
Assert.DoesNotContain("<script>", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("token", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("capability", json, StringComparison.OrdinalIgnoreCase);
Assert.Equal("<script>alert(1)</script>", Assert.Single(response.Items).Metadata["map"]);
}
[Fact]
public void RevokedListingDisappearsBeforeAnotherReadPathCanObserveIt()
{
using SessionBrowserFixture fixture = new();
StoredListing listing = fixture.Add();
fixture.Store.RevokeListing(listing.Definition.ListingId);
Assert.Empty(fixture.Browser.Browse(fixture.Request()).Value!.Items);
Assert.Equal(RendezvousErrorCode.NotFound, fixture.Browser.Get(
listing.Definition.ListingId,
fixture.Scope.GameId,
fixture.Scope.EnvironmentId,
7).Error);
}
}
@@ -0,0 +1,71 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.State;
namespace FinalFactory.Rendezvous.Tests.Browser;
internal sealed class SessionBrowserFixture : IDisposable
{
private readonly EphemeralStateFixture _state = new();
public SessionBrowserFixture()
{
Cursors = new();
Browser = new(_state.Store, Cursors, _state.Clock);
}
public InMemoryEphemeralRendezvousStore Store => _state.Store;
public ManualRendezvousClock Clock => _state.Clock;
public SessionBrowserCursorCodec Cursors { get; }
public SessionBrowserService Browser { get; }
public TenantScope Scope => _state.Scope;
public StoredListing Add(
TenantScope? scope = null,
uint protocolVersion = 7,
RegionId? regionId = null,
ListingVisibility visibility = ListingVisibility.Public,
bool fresh = true,
int currentPlayers = 1,
int maximumPlayers = 8,
IReadOnlyDictionary<string, string>? metadata = null)
{
CreateListingCommand seed = _state.ListingCommand();
CreateListingCommand command = seed with
{
Listing = seed.Listing with
{
Scope = scope ?? Scope,
ProtocolVersion = protocolVersion,
RegionId = regionId ?? seed.Listing.RegionId,
Visibility = visibility,
CurrentPlayers = currentPlayers,
MaximumPlayers = maximumPlayers,
Metadata = metadata ?? seed.Listing.Metadata,
},
};
StoredListing listing = Store.CreateListing(command).Value!;
if (fresh)
{
listing = Store.BindHostPresence(new(
command.Listing.HostPresenceHandle,
command.Listing.HostPresenceFingerprint,
EphemeralStateFixture.PublicEndpoint(40_000),
null)).Value!;
}
return listing;
}
public BrowseSessionsRequest Request(int pageSize = 100) => new()
{
GameId = Scope.GameId,
EnvironmentId = Scope.EnvironmentId,
ProtocolVersion = 7,
RegionId = new("eu-central"),
PageSize = pageSize,
};
public void Dispose() => Cursors.Dispose();
}
@@ -0,0 +1,102 @@
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class ConnectionTicketValidatorTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
[Fact]
public void AuthorizedTicketIsAcceptedExactlyOnce()
{
ManualConnectionTicketClock clock = new();
using ConnectionTicketValidator validator = new(1_024, clock);
JoinAttemptId attempt = NewAttempt();
string ticket = Ticket('A');
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
Assert.Equal(
ConnectionTicketConsumptionResult.Accepted,
validator.Consume(attempt, ticket));
Assert.Equal(
ConnectionTicketConsumptionResult.AlreadyConsumed,
validator.Consume(attempt, ticket));
}
[Fact]
public void AlteredCrossAttemptExpiredAndRevokedTicketsAreRejected()
{
ManualConnectionTicketClock clock = new();
using ConnectionTicketValidator validator = new(1_024, clock);
JoinAttemptId first = NewAttempt();
JoinAttemptId second = NewAttempt();
Assert.True(validator.TryAuthorize(first, Ticket('A'), Now.AddSeconds(20)));
Assert.True(validator.TryAuthorize(second, Ticket('B'), Now.AddSeconds(40)));
Assert.Equal(
ConnectionTicketConsumptionResult.Rejected,
validator.Consume(first, Ticket('B')));
clock.Advance(TimeSpan.FromSeconds(20));
Assert.Equal(
ConnectionTicketConsumptionResult.Expired,
validator.Consume(first, Ticket('A')));
Assert.True(validator.Revoke(second));
Assert.Equal(
ConnectionTicketConsumptionResult.Revoked,
validator.Consume(second, Ticket('B')));
}
[Fact]
public async Task ConcurrentConsumptionHasOneWinner()
{
ManualConnectionTicketClock clock = new();
using ConnectionTicketValidator validator = new(1_024, clock);
JoinAttemptId attempt = NewAttempt();
string ticket = Ticket('C');
Assert.True(validator.TryAuthorize(attempt, ticket, Now.AddSeconds(20)));
using ManualResetEventSlim start = new(false);
Task<ConnectionTicketConsumptionResult> left = Task.Run(() =>
{
start.Wait();
return validator.Consume(attempt, ticket);
});
Task<ConnectionTicketConsumptionResult> right = Task.Run(() =>
{
start.Wait();
return validator.Consume(attempt, ticket);
});
start.Set();
ConnectionTicketConsumptionResult[] results = await Task.WhenAll(left, right);
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.Accepted);
Assert.Single(results, static result => result == ConnectionTicketConsumptionResult.AlreadyConsumed);
}
[Fact]
public void ValidatorIsBoundedDisposableAndRedacted()
{
ManualConnectionTicketClock clock = new();
ConnectionTicketValidator validator = new(1, clock);
Assert.True(validator.TryAuthorize(NewAttempt(), Ticket('A'), Now.AddSeconds(20)));
Assert.False(validator.TryAuthorize(NewAttempt(), Ticket('B'), Now.AddSeconds(20)));
Assert.DoesNotContain(Ticket('A'), validator.ToString(), StringComparison.Ordinal);
validator.Dispose();
Assert.Throws<ObjectDisposedException>(() => validator.Revoke(NewAttempt()));
Assert.Throws<ObjectDisposedException>(() => validator.Consume(default, string.Empty));
}
private static JoinAttemptId NewAttempt() => new(Guid.NewGuid());
private static string Ticket(char value) => new(value, 43);
private sealed class ManualConnectionTicketClock : IConnectionTicketClock
{
public DateTimeOffset UtcNow { get; set; } = Now;
public void Advance(TimeSpan duration) => UtcNow += duration;
}
}
@@ -0,0 +1,342 @@
using System.Net;
using System.Text;
using System.Text.Json;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousClientBehaviorTests
{
[Fact]
public async Task RegistrationRetriesWithTheSameIdempotentPayloadAndDisposesResponses()
{
TrackingContent unavailable = JsonContent(new ApiError
{
Code = RendezvousErrorCode.ServiceUnavailable,
Message = "try later",
RetryAfterSeconds = 1,
});
TrackingContent created = JsonContent(CreateRegistrationResponse());
ScriptedHandler handler = new(
Response(HttpStatusCode.ServiceUnavailable, unavailable),
Response(HttpStatusCode.Created, created),
new HttpResponseMessage(HttpStatusCode.NoContent));
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RegisterSessionRequest request = CreateRegistrationRequest("stable-idempotency-key");
RecordingDelay delay = new(() => request.DisplayName = "mutated during retry delay");
RendezvousPublisherClient publisher = new(
httpClient,
new RendezvousClientOptions { JitterRatio = 0 },
delay);
RendezvousClientResult<PublishedSession> result = await publisher.RegisterAsync(
request,
"publisher-credential");
Assert.True(result.IsSuccess);
Assert.Equal(2, handler.RequestBodies.Count);
Assert.Equal(handler.RequestBodies[0], handler.RequestBodies[1]);
Assert.Contains("stable-idempotency-key", handler.RequestBodies[0], StringComparison.Ordinal);
Assert.Equal(TimeSpan.FromSeconds(1), Assert.Single(delay.Delays));
Assert.True(unavailable.IsDisposed);
Assert.True(created.IsDisposed);
using HttpResponseMessage stillOwnedByCaller = await httpClient.GetAsync("health");
Assert.Equal(HttpStatusCode.NoContent, stillOwnedByCaller.StatusCode);
}
[Fact]
public async Task UpdateUsesTheLeaseWithoutMutatingTheCallersRequest()
{
ScriptedHandler handler = new(
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())),
new HttpResponseMessage(HttpStatusCode.NoContent));
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousPublisherClient publisher = new(httpClient);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistrationRequest("update-idempotency-key"),
"publisher-credential"));
UpdateSessionRequest update = new()
{
LeaseToken = "caller-placeholder",
BuildVersion = "2.0.0",
DisplayName = "updated",
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 4 },
Metadata = new() { ["mode"] = "online-coop" },
};
RendezvousClientResult<bool> result = await publisher.UpdateAsync(
session,
update,
"publisher-credential");
Assert.True(result.IsSuccess);
Assert.Equal("caller-placeholder", update.LeaseToken);
Assert.Contains("lease-token", handler.RequestBodies[1], StringComparison.Ordinal);
Assert.DoesNotContain("caller-placeholder", handler.RequestBodies[1], StringComparison.Ordinal);
}
[Fact]
public async Task GatewayFailureIsRetriedForSafeBrowserReads()
{
ScriptedHandler handler = new(
new HttpResponseMessage(HttpStatusCode.BadGateway),
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse())));
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RecordingDelay delay = new();
RendezvousSessionBrowserClient browser = new(
httpClient,
new RendezvousClientOptions { JitterRatio = 0 },
delay);
RendezvousClientResult<BrowseSessionsResponse> result = await browser.BrowseAsync(new()
{
GameId = new("space-game"),
EnvironmentId = new("production"),
ProtocolVersion = 7,
});
Assert.True(result.IsSuccess, result.Message);
Assert.Equal(2, handler.RequestUris.Count);
Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays));
}
[Fact]
public void SuccessResultRequiresAValue()
{
Assert.Throws<ArgumentNullException>(() => RendezvousClientResult.Success<string>(null!));
}
[Fact]
public async Task BrowseAllFollowsCursorsWithoutMutatingTheCallersRequest()
{
ScriptedHandler handler = new(
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse
{
Items = [CreateListing("00000000-0000-0000-0000-000000000001")],
NextCursor = "next page+token",
})),
Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse
{
Items = [CreateListing("00000000-0000-0000-0000-000000000002")],
})));
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousSessionBrowserClient browser = new(httpClient);
BrowseSessionsRequest request = new()
{
GameId = new("space-game"),
EnvironmentId = new("production"),
ProtocolVersion = 7,
PageSize = 1,
};
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(request);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value!.Count);
Assert.Null(request.Cursor);
Assert.DoesNotContain("cursor=", handler.RequestUris[0].Query, StringComparison.Ordinal);
Assert.Contains("cursor=next%20page%2Btoken", handler.RequestUris[1].Query, StringComparison.Ordinal);
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
}
[Fact]
public async Task LeaseMaintainerReportsLeaseLoss()
{
ScriptedHandler handler = new(
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())),
Response(HttpStatusCode.Gone, JsonContent(new ApiError
{
Code = RendezvousErrorCode.Expired,
Message = "lease expired",
})));
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RecordingDelay delay = new();
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistrationRequest("lease-loss-key"),
"publisher-credential"));
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
session,
"publisher-credential");
bool eventRaised = false;
maintainer.LeaseLost += (_, _) => eventRaised = true;
LeaseMaintenanceResult result = await maintainer.RunAsync();
Assert.Equal(LeaseMaintenanceStopReason.LeaseLost, result.Reason);
Assert.Equal(RendezvousErrorCode.Expired, result.Error);
Assert.True(eventRaised);
Assert.Equal(TimeSpan.FromSeconds(15), Assert.Single(delay.Delays));
}
[Fact]
public async Task DisposingLeaseMaintainerCancelsItsWaitAndDoesNotRenew()
{
ScriptedHandler handler = new(
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())));
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
BlockingDelay delay = new();
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistrationRequest("dispose-key"),
"publisher-credential"));
SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
session,
"publisher-credential");
Task<LeaseMaintenanceResult> active = maintainer.RunAsync();
await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
await maintainer.DisposeAsync();
LeaseMaintenanceResult result = await active;
Assert.Equal(LeaseMaintenanceStopReason.Disposed, result.Reason);
Assert.Single(handler.RequestUris);
await Assert.ThrowsAsync<ObjectDisposedException>(() => maintainer.RunAsync());
}
[Fact]
public async Task CallerCancellationStopsLeaseMaintenanceWithoutRenewing()
{
ScriptedHandler handler = new(
Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())));
using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") };
BlockingDelay delay = new();
RendezvousPublisherClient publisher = new(httpClient, delay: delay);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistrationRequest("cancel-key"),
"publisher-credential"));
await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer(
session,
"publisher-credential");
using CancellationTokenSource cancellation = new();
Task<LeaseMaintenanceResult> active = maintainer.RunAsync(cancellation.Token);
await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
await cancellation.CancelAsync();
LeaseMaintenanceResult result = await active;
Assert.Equal(LeaseMaintenanceStopReason.Cancelled, result.Reason);
Assert.Single(handler.RequestUris);
}
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
{
Assert.True(result.IsSuccess, result.Message);
return Assert.IsType<T>(result.Value);
}
private static RegisterSessionRequest CreateRegistrationRequest(string idempotencyKey) => new()
{
IdempotencyKey = idempotencyKey,
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.0.0",
DisplayName = "SDK host",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 },
};
private static RegisterSessionResponse CreateRegistrationResponse() => new()
{
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000010")),
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000011")),
LeaseToken = "lease-token",
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000012")),
HostPresenceCapability = "presence-capability",
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
LeaseRenewAfterSeconds = 15,
HostPresenceRefreshAfterSeconds = 10,
};
private static SessionListing CreateListing(string id) => new()
{
ListingId = new(Guid.Parse(id)),
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.0.0",
DisplayName = "host",
Visibility = ListingVisibility.Public,
PublisherTrustMode = PublisherTrustMode.ManagedDedicated,
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 },
};
private static TrackingContent JsonContent<T>(T value) => new(
JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options));
private static HttpResponseMessage Response(HttpStatusCode status, HttpContent content) => new(status)
{
Content = content,
};
private sealed class ScriptedHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
internal List<string> RequestBodies { get; } = [];
internal List<Uri> RequestUris { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestUris.Add(request.RequestUri!);
RequestBodies.Add(request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken));
return _responses.Count > 0
? _responses.Dequeue()
: throw new InvalidOperationException("No scripted response remains.");
}
}
private sealed class TrackingContent(byte[] bytes) : HttpContent
{
internal bool IsDisposed { get; private set; }
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
stream.WriteAsync(bytes).AsTask();
protected override bool TryComputeLength(out long length)
{
length = bytes.Length;
return true;
}
protected override void Dispose(bool disposing)
{
IsDisposed = true;
base.Dispose(disposing);
}
}
private sealed class RecordingDelay(Action? onDelay = null) : IRendezvousDelay
{
internal List<TimeSpan> Delays { get; } = [];
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Delays.Add(delay);
onDelay?.Invoke();
return Task.CompletedTask;
}
}
private sealed class BlockingDelay : IRendezvousDelay
{
internal TaskCompletionSource Started { get; } = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
Started.TrySetResult();
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
}
}
@@ -0,0 +1,195 @@
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
using FinalFactory.Rendezvous.Tests.State;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousClientIntegrationTests
{
[Fact]
public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging()
{
await using ClientTestHost host = await ClientTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient);
RendezvousSessionBrowserClient browser = new(host.HttpClient);
List<PublishedSession> sessions = [];
for (int index = 0; index < 3; index++)
{
RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAsync(
CreateRegistration(index),
host.PublisherCredential);
PublishedSession session = AssertSuccess(registered);
sessions.Add(session);
Assert.True(host.Capabilities.TryFingerprint(
session.HostPresenceCapability,
out SecretFingerprint fingerprint));
StoreResult<StoredListing> bound = host.Store.BindHostPresence(new(
session.HostPresenceHandle,
fingerprint,
new(AddressFamilyKind.Ipv4, $"203.0.113.{80 + index}", 41_000 + index),
null));
Assert.Equal(StoreResultCode.Success, bound.Code);
}
PublishedSession first = sessions[0];
RendezvousClientResult<RenewLeaseResponse> renewed = await publisher.RenewAsync(
first,
host.PublisherCredential);
Assert.True(renewed.IsSuccess, renewed.Message);
Assert.Equal(renewed.Value!.ExpiresAt, first.ExpiresAt);
UpdateSessionRequest update = new()
{
BuildVersion = "2.0.0",
DisplayName = "SDK host updated",
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
Metadata = new() { ["mode"] = "online-coop" },
};
RendezvousClientResult<bool> updated = await publisher.UpdateAsync(
first,
update,
host.PublisherCredential);
Assert.True(updated.IsSuccess, updated.Message);
BrowseSessionsRequest browseRequest = new()
{
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
PageSize = 1,
ExcludeFull = true,
};
IReadOnlyList<SessionListing> listings = AssertSuccess(
await browser.BrowseAllAsync(browseRequest));
Assert.Equal(3, listings.Count);
Assert.Equal("SDK host updated", listings.Single(item => item.ListingId == first.ListingId).DisplayName);
GetSessionResponse direct = AssertSuccess(await browser.GetAsync(
first.ListingId,
new("space-game"),
new("production"),
7));
Assert.Equal("2.0.0", direct.Session.BuildVersion);
foreach (PublishedSession session in sessions)
{
RendezvousClientResult<bool> deregistered = await publisher.DeregisterAsync(
session,
host.PublisherCredential);
Assert.True(deregistered.IsSuccess, deregistered.Message);
Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(session.ListingId, false).Code);
}
}
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
{
Assert.True(result.IsSuccess, result.Message);
return Assert.IsAssignableFrom<T>(result.Value);
}
private static RegisterSessionRequest CreateRegistration(int index) => new()
{
IdempotencyKey = $"sdk-integration-{index}",
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.0.0",
DisplayName = $"SDK host {index}",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
Metadata = new() { ["mode"] = "online-coop" },
};
private sealed class ClientTestHost : IAsyncDisposable
{
private readonly WebApplication _application;
private ClientTestHost(
WebApplication application,
HttpClient httpClient,
InMemoryEphemeralRendezvousStore store,
EphemeralCapabilityIssuer capabilities,
string publisherCredential)
{
_application = application;
HttpClient = httpClient;
Store = store;
Capabilities = capabilities;
PublisherCredential = publisherCredential;
}
internal HttpClient HttpClient { get; }
internal InMemoryEphemeralRendezvousStore Store { get; }
internal EphemeralCapabilityIssuer Capabilities { get; }
internal string PublisherCredential { get; }
internal static async Task<ClientTestHost> StartAsync()
{
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(),
ProvisioningTestData.CreateSecrets("secret-1"),
clock.UtcNow);
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions));
builder.Services.Configure<RouteHandlerOptions>(static options =>
options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
builder.Services.AddSingleton<IWallClock>(clock);
builder.Services.AddSingleton(capabilities);
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>();
WebApplication app = builder.Build();
app.UseExceptionHandler();
app.MapRendezvousContractEndpoints();
await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>();
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
return new(
app,
new HttpClient { BaseAddress = new Uri(address) },
store,
capabilities,
credential);
}
public async ValueTask DisposeAsync()
{
HttpClient.Dispose();
await _application.StopAsync();
await _application.DisposeAsync();
}
}
}
@@ -0,0 +1,61 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class NatPunchRequestTokenCodecTests
{
[Theory]
[InlineData(NatPunchPeerRole.HostPresence)]
[InlineData(NatPunchPeerRole.Host)]
[InlineData(NatPunchPeerRole.Client)]
public void FixedSizeTokensRoundTripBelowLiteNetLibLimit(NatPunchPeerRole role)
{
MediationHandle handle = new(Guid.NewGuid());
const string capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
string encoded = NatPunchRequestTokenCodec.Encode(role, handle, capability);
Assert.Equal(NatPunchRequestTokenCodec.EncodedLength, encoded.Length);
Assert.True(encoded.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters);
Assert.True(NatPunchRequestTokenCodec.TryDecode(encoded, out NatPunchRequestToken? decoded));
Assert.NotNull(decoded);
Assert.Equal(role, decoded.Role);
Assert.Equal(handle, decoded.MediationHandle);
Assert.Equal(capability, decoded.Capability);
Assert.DoesNotContain(capability, decoded.ToString(), StringComparison.Ordinal);
}
[Fact]
public void MalformedAndNonCanonicalTokensAreRejected()
{
string valid = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Client,
new MediationHandle(Guid.Parse("00112233-4455-6677-8899-aabbccddeeff")),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
string uppercaseHandle = valid[..6]
+ valid.Substring(6, 32).ToUpperInvariant()
+ valid[38..];
Assert.False(NatPunchRequestTokenCodec.TryDecode(null, out _));
Assert.False(NatPunchRequestTokenCodec.TryDecode(valid[..^1], out _));
Assert.False(NatPunchRequestTokenCodec.TryDecode("x" + valid[1..], out _));
Assert.False(NatPunchRequestTokenCodec.TryDecode(valid[..^1] + "x", out _));
Assert.False(NatPunchRequestTokenCodec.TryDecode(uppercaseHandle, out _));
Assert.Throws<ArgumentException>(() => NatPunchRequestTokenCodec.Encode(
(NatPunchPeerRole)99,
new MediationHandle(Guid.NewGuid()),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
}
[Fact]
public void FixedWireLengthHasADedicatedLiteNetSafeContractLimit()
{
Assert.Equal(192, ContractLimits.NatPunchRequestTokenCharacters);
Assert.Equal(
ContractLimits.NatPunchRequestTokenCharacters,
NatPunchRequestTokenCodec.EncodedLength);
Assert.True(
ContractLimits.NatPunchRequestTokenCharacters
<= ContractLimits.LiteNetLibNatTokenMaxCharacters);
}
}
@@ -9,6 +9,7 @@ public sealed class OpenApiCompatibilityTests
"/health/live", "/health/live",
"/health/ready", "/health/ready",
"/v1/join-attempts", "/v1/join-attempts",
"/v1/join-attempts/{attemptId}",
"/v1/join-attempts/{attemptId}/outcome", "/v1/join-attempts/{attemptId}/outcome",
"/v1/sessions", "/v1/sessions",
"/v1/sessions/{listingId}", "/v1/sessions/{listingId}",
@@ -85,5 +86,23 @@ public sealed class OpenApiCompatibilityTests
.GetProperty("security"); .GetProperty("security");
Assert.True(security[0].TryGetProperty("PublisherBearer", out _)); Assert.True(security[0].TryGetProperty("PublisherBearer", out _));
} }
JsonElement cancelParameters = root.GetProperty("paths")
.GetProperty("/v1/join-attempts/{attemptId}")
.GetProperty("delete")
.GetProperty("parameters");
JsonElement cancelCapability = Assert.Single(cancelParameters.EnumerateArray(), static parameter =>
parameter.GetProperty("in").GetString() == "header"
&& parameter.GetProperty("name").GetString()
== "X-Rendezvous-Client-Punch-Capability");
Assert.True(cancelCapability.GetProperty("required").GetBoolean());
JsonElement hostPollParameters = root.GetProperty("paths")
.GetProperty("/v1/sessions/{listingId}/join-attempts")
.GetProperty("get")
.GetProperty("parameters");
JsonElement leaseToken = Assert.Single(hostPollParameters.EnumerateArray(), static parameter =>
parameter.GetProperty("in").GetString() == "header"
&& parameter.GetProperty("name").GetString() == "X-Rendezvous-Lease-Token");
Assert.True(leaseToken.GetProperty("required").GetBoolean());
} }
} }
@@ -1,4 +1,5 @@
using System.Reflection; using System.Reflection;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts; namespace FinalFactory.Rendezvous.Tests.Contracts;
@@ -23,6 +24,24 @@ public sealed class PublicApiCompatibilityTests
Assert.Equal(expected, snapshot); Assert.Equal(expected, snapshot);
} }
[Fact]
public void ClientPublicApiMatchesTheV1Snapshot()
{
string snapshot = CreateSnapshot(typeof(RendezvousPublisherClient).Assembly);
string expected = ContractTestFiles.Read("client-public-api.txt");
if (expected == "SNAPSHOT_PENDING"
&& Environment.GetEnvironmentVariable("RENDEZVOUS_UPDATE_CONTRACT_SNAPSHOT") == "1")
{
string snapshotPath = Path.Combine(
ContractTestFiles.Directory,
"client-public-api.txt");
File.WriteAllText(snapshotPath, snapshot + Environment.NewLine);
expected = snapshot;
}
Assert.Equal(expected, snapshot);
}
private static string CreateSnapshot(Assembly assembly) private static string CreateSnapshot(Assembly assembly)
{ {
List<string> lines = []; List<string> lines = [];
@@ -58,10 +77,16 @@ public sealed class PublicApiCompatibilityTests
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.OrderBy(static property => property.Name, StringComparer.Ordinal)) .OrderBy(static property => property.Name, StringComparer.Ordinal))
{ {
string accessors = $"{(property.CanRead ? "get;" : string.Empty)}{(property.CanWrite ? "set;" : string.Empty)}"; string accessors = $"{(property.GetMethod?.IsPublic == true ? "get;" : string.Empty)}{(property.SetMethod?.IsPublic == true ? "set;" : string.Empty)}";
lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}"); lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}");
} }
foreach (EventInfo eventInfo in type.GetEvents(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.OrderBy(static eventInfo => eventInfo.Name, StringComparer.Ordinal))
{
lines.Add($" EVENT {FormatType(eventInfo.EventHandlerType!)} {eventInfo.Name}");
}
foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Where(static method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal)) .Where(static method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal))
.OrderBy(static method => method.Name, StringComparer.Ordinal) .OrderBy(static method => method.Name, StringComparer.Ordinal)
@@ -0,0 +1,204 @@
using System.Net;
using System.Net.Http.Json;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
using FinalFactory.Rendezvous.Tests.State;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
public sealed class JoinAttemptHttpEndpointTests
{
[Fact]
public async Task ClientCreatesHostPollsAndCapabilityCancelsAnAttemptOverHttp()
{
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistration(),
host.PublisherCredential));
Assert.True(host.Capabilities.TryFingerprint(
session.HostPresenceCapability,
out SecretFingerprint presenceFingerprint));
Assert.True(host.Store.BindHostPresence(new(
session.HostPresenceHandle,
presenceFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
null)).Succeeded);
CreateJoinAttemptRequest request = new()
{
IdempotencyKey = "http-join-1",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = session.ListingId,
ProtocolVersion = 7,
};
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
"v1/join-attempts",
request,
ContractJson.Options);
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
using HttpRequestMessage pollRequest = new(
HttpMethod.Get,
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
pollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage pollResponse = await host.HttpClient.SendAsync(pollRequest);
Assert.Equal(HttpStatusCode.OK, pollResponse.StatusCode);
BrowseHostJoinAttemptsResponse polled = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await pollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
HostJoinAttempt hostAttempt = Assert.Single(polled.Items);
Assert.Equal(created.AttemptId, hostAttempt.AttemptId);
Assert.NotEqual(created.ClientPunchCapability, hostAttempt.HostPunchCapability);
using HttpResponseMessage missingCapability = await host.HttpClient.DeleteAsync(
$"v1/join-attempts/{created.AttemptId}");
Assert.Equal(HttpStatusCode.BadRequest, missingCapability.StatusCode);
ApiError missingCapabilityError = Assert.IsType<ApiError>(
await missingCapability.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
Assert.Equal(RendezvousErrorCode.InvalidRequest, missingCapabilityError.Code);
using HttpRequestMessage unauthorizedCancel = new(
HttpMethod.Delete,
$"v1/join-attempts/{created.AttemptId}");
unauthorizedCancel.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedCancel);
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
using HttpRequestMessage cancelRequest = new(
HttpMethod.Delete,
$"v1/join-attempts/{created.AttemptId}");
cancelRequest.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
created.ClientPunchCapability);
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
using HttpRequestMessage emptyPollRequest = new(
HttpMethod.Get,
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest);
BrowseHostJoinAttemptsResponse empty = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
Assert.Empty(empty.Items);
}
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
{
Assert.True(result.IsSuccess, result.Message);
return Assert.IsAssignableFrom<T>(result.Value);
}
private static RegisterSessionRequest CreateRegistration() => new()
{
IdempotencyKey = "join-http-host",
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.0.0",
DisplayName = "Join HTTP host",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
Metadata = new() { ["mode"] = "online-coop" },
};
private sealed class JoinHttpTestHost : IAsyncDisposable
{
private readonly WebApplication _application;
private JoinHttpTestHost(
WebApplication application,
HttpClient httpClient,
InMemoryEphemeralRendezvousStore store,
EphemeralCapabilityIssuer capabilities,
string publisherCredential)
{
_application = application;
HttpClient = httpClient;
Store = store;
Capabilities = capabilities;
PublisherCredential = publisherCredential;
}
internal HttpClient HttpClient { get; }
internal InMemoryEphemeralRendezvousStore Store { get; }
internal EphemeralCapabilityIssuer Capabilities { get; }
internal string PublisherCredential { get; }
internal static async Task<JoinHttpTestHost> StartAsync()
{
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(),
ProvisioningTestData.CreateSecrets("secret-1"),
clock.UtcNow);
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions));
builder.Services.Configure<RouteHandlerOptions>(static options =>
options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Policies);
builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
builder.Services.AddSingleton<IWallClock>(clock);
builder.Services.AddSingleton(capabilities);
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
WebApplication app = builder.Build();
app.UseExceptionHandler();
app.MapRendezvousContractEndpoints();
await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>();
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
return new(
app,
new HttpClient { BaseAddress = new Uri(address) },
store,
capabilities,
credential);
}
public async ValueTask DisposeAsync()
{
HttpClient.Dispose();
await _application.StopAsync();
await _application.DisposeAsync();
}
}
}
@@ -0,0 +1,286 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
public sealed class JoinAttemptServiceTests
{
[Fact]
public void CreateIsIdempotentAndScopesDistinctRoleCredentials()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "stable-join-key");
JoinAttemptServiceResult<CreateJoinAttemptResponse> first = fixture.Service.Create(
fixture.ClientSubject,
request);
JoinAttemptServiceResult<CreateJoinAttemptResponse> replay = fixture.Service.Create(
fixture.ClientSubject,
request);
Assert.True(first.Succeeded);
Assert.True(replay.Succeeded);
Assert.Equal(first.Value!.AttemptId, replay.Value!.AttemptId);
Assert.Equal(first.Value.MediationHandle, replay.Value.MediationHandle);
Assert.Equal(first.Value.ClientPunchCapability, replay.Value.ClientPunchCapability);
Assert.True(ContractValidation.IsCapabilityValid(first.Value.ClientPunchCapability));
Assert.InRange(
first.Value.ClientPunchCapability.Length,
1,
ContractLimits.LiteNetLibNatTokenMaxCharacters);
HostJoinAttempt host = Assert.Single(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
10,
null).Value!.Items);
Assert.NotEqual(host.HostPunchCapability, first.Value.ClientPunchCapability);
Assert.DoesNotContain(first.Value.ClientPunchCapability, fixture.Sessions.Store.ToString(), StringComparison.Ordinal);
}
[Fact]
public void SameIdempotencyKeyWithDifferentRequestConflicts()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
(RegisterSessionResponse other, _) = fixture.CreateHost();
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "reused-key");
Assert.True(fixture.Service.Create(fixture.ClientSubject, request).Succeeded);
request.ListingId = other.ListingId;
JoinAttemptServiceResult<CreateJoinAttemptResponse> conflict = fixture.Service.Create(
fixture.ClientSubject,
request);
Assert.Equal(RendezvousErrorCode.Conflict, conflict.Error);
}
[Fact]
public void CreationRejectsStaleIncompatibleAndCrossTenantListings()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
(RegisterSessionResponse active, _) = fixture.CreateHost();
CreateJoinAttemptRequest incompatible = fixture.Request(active.ListingId);
incompatible.ProtocolVersion = 8;
Assert.Equal(
RendezvousErrorCode.IncompatibleProtocol,
fixture.Service.Create(fixture.ClientSubject, incompatible).Error);
CreateJoinAttemptRequest otherTenant = fixture.Request(active.ListingId);
otherTenant.GameId = new("other-game");
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
}
[Fact]
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
fixture.Create(registration.ListingId);
fixture.Create(registration.ListingId);
fixture.Create(registration.ListingId);
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> first = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
1,
null);
Assert.True(first.Succeeded);
Assert.Single(first.Value!.Items);
Assert.NotNull(first.Value.NextCursor);
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> second = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
1,
first.Value.NextCursor);
Assert.True(second.Succeeded);
Assert.NotEqual(first.Value.Items[0].AttemptId, second.Value!.Items[0].AttemptId);
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1,
null).Error);
Assert.Equal(
RendezvousErrorCode.InvalidRequest,
fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
1,
first.Value.NextCursor + "x").Error);
(RegisterSessionResponse other, _) = fixture.CreateHost();
Assert.Equal(
RendezvousErrorCode.InvalidRequest,
fixture.Service.BrowseForHost(
other.ListingId,
ContractLimits.ContractVersion,
other.LeaseToken,
1,
first.Value.NextCursor).Error);
}
[Fact]
public void CancellationRequiresTheAttemptsClientCapabilityAndRevokesState()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.Cancel(
created.AttemptId,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Error);
Assert.True(fixture.Service.Cancel(
created.AttemptId,
created.ClientPunchCapability).Succeeded);
Assert.Empty(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
10,
null).Value!.Items);
}
[Fact]
public void RoleAndAttemptCapabilitiesCannotCrossWireConcurrentAttempts()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
second.ClientPunchCapability,
out SecretFingerprint secondClientFingerprint));
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
first.ClientPunchCapability,
out SecretFingerprint firstClientFingerprint));
Assert.Equal(
StoreResultCode.NotFound,
fixture.Sessions.Store.BindAttemptEndpoint(new(
firstStored.MediationHandle,
AttemptPeerRole.Client,
secondClientFingerprint,
new(AddressFamilyKind.Ipv4, "198.51.100.20", 42_000),
null)).Code);
Assert.Equal(
StoreResultCode.NotFound,
fixture.Sessions.Store.BindAttemptEndpoint(new(
firstStored.MediationHandle,
AttemptPeerRole.Host,
firstClientFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
null)).Code);
}
[Fact]
public async Task ConnectionTicketIsDistinctExpiringAndAtomicallySingleUse()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
JoinAttemptServiceResult<ConnectionTicketGrant> issued = fixture.Service.IssueConnectionTicket(
introduction.Attempt);
Assert.True(issued.Succeeded);
Assert.True(ContractValidation.IsConnectionTicketValid(issued.Value!.Ticket));
Assert.NotEqual(created.ClientPunchCapability, issued.Value.Ticket);
Assert.DoesNotContain(issued.Value.Ticket, issued.Value.ToString(), StringComparison.Ordinal);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
issued.Value.Ticket,
out SecretFingerprint ticketFingerprint));
ConsumeConnectionTicketCommand command = new(created.AttemptId, ticketFingerprint);
using ManualResetEventSlim start = new(false);
Task<StoreResult<bool>> left = Task.Run(() =>
{
start.Wait();
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
});
Task<StoreResult<bool>> right = Task.Run(() =>
{
start.Wait();
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
});
start.Set();
StoreResult<bool>[] results = await Task.WhenAll(left, right);
Assert.Single(results, static result => result.Succeeded);
Assert.Single(results, static result => result.Code == StoreResultCode.ReplayRejected);
}
[Fact]
public void TicketRejectsAlteredCrossAttemptPreIntroductionAndExpiry()
{
EphemeralStoreOptions options = new()
{
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
};
using JoinAttemptFixture fixture = new(options);
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
StoredJoinAttempt secondStored = fixture.GetAttempt(registration, second.AttemptId);
Assert.Equal(
StoreResultCode.Conflict,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
first.AttemptId,
firstStored.ConnectionTicketFingerprint)).Code);
Assert.Equal(
StoreResultCode.NotFound,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
second.AttemptId,
firstStored.ConnectionTicketFingerprint)).Code);
fixture.Introduce(registration, first);
fixture.Sessions.Clock.Advance(options.ConnectionTicketLifetime);
Assert.Equal(
StoreResultCode.Expired,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
first.AttemptId,
firstStored.ConnectionTicketFingerprint)).Code);
Assert.False(secondStored.ConnectionTicketConsumed);
}
[Fact]
public void TicketWindowBeginsAtIntroductionAndNeverOutlivesTheAttempt()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(15));
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
ConnectionTicketGrant ticket = Assert.IsType<ConnectionTicketGrant>(
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
Assert.Equal(created.ExpiresAt, ticket.ExpiresAt);
Assert.Equal(TimeSpan.FromSeconds(15), ticket.ExpiresAt - fixture.Sessions.Clock.UtcNow);
Assert.DoesNotContain(
introduction.Attempt.CapabilityDerivationSalt,
introduction.Attempt.ToString(),
StringComparison.Ordinal);
}
}
@@ -0,0 +1,117 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
using FinalFactory.Rendezvous.Tests.Sessions;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
internal sealed class JoinAttemptFixture : IDisposable
{
private int _sequence;
public JoinAttemptFixture(EphemeralStoreOptions? options = null)
{
Sessions = new(options);
Cursors = new();
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40"));
}
public SessionLeaseFixture Sessions { get; }
public JoinAttemptCursorCodec Cursors { get; }
public JoinAttemptService Service { get; }
public string ClientSubject { get; }
public (RegisterSessionResponse Registration, StoredListing Listing) CreateHost(bool bindPresence = true)
{
RegisterSessionResponse registration = Sessions.Register();
if (bindPresence)
{
Assert.True(Sessions.BindPresence(registration).Succeeded);
}
StoredListing listing = Sessions.Store.GetListing(registration.ListingId, false).Value!;
return (registration, listing);
}
public CreateJoinAttemptRequest Request(
SessionListingId listingId,
string? idempotencyKey = null) => new()
{
IdempotencyKey = idempotencyKey ?? $"join-{Interlocked.Increment(ref _sequence)}",
GameId = Sessions.Scope.GameId,
EnvironmentId = Sessions.Scope.EnvironmentId,
ListingId = listingId,
ProtocolVersion = 7,
};
public CreateJoinAttemptResponse Create(
SessionListingId listingId,
string? idempotencyKey = null)
{
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = Service.Create(
ClientSubject,
Request(listingId, idempotencyKey));
Assert.True(result.Succeeded);
return Assert.IsType<CreateJoinAttemptResponse>(result.Value);
}
public StoredJoinAttempt GetAttempt(
RegisterSessionResponse registration,
JoinAttemptId attemptId)
{
Assert.True(Sessions.Capabilities.TryFingerprint(
registration.LeaseToken,
out SecretFingerprint leaseFingerprint));
IReadOnlyList<StoredJoinAttempt> attempts = Sessions.Store.BrowseHostJoinAttempts(new(
registration.ListingId,
leaseFingerprint,
ContractLimits.BrowserPageMaxItems)).Value!;
return attempts.Single(attempt => attempt.AttemptId == attemptId);
}
public IntroductionEndpoints Introduce(
RegisterSessionResponse registration,
CreateJoinAttemptResponse created)
{
StoredJoinAttempt attempt = GetAttempt(registration, created.AttemptId);
HostJoinAttempt host = Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
Assert.True(Sessions.Capabilities.TryFingerprint(
host.HostPunchCapability,
out SecretFingerprint hostFingerprint));
Assert.True(Sessions.Capabilities.TryFingerprint(
created.ClientPunchCapability,
out SecretFingerprint clientFingerprint));
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
attempt.MediationHandle,
AttemptPeerRole.Host,
hostFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
null)).Succeeded);
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
attempt.MediationHandle,
AttemptPeerRole.Client,
clientFingerprint,
new(AddressFamilyKind.Ipv4, "198.51.100.40", 42_000),
null)).Succeeded);
StoreResult<IntroductionEndpoints> introduced = Sessions.Store.ConsumeIntroduction(
attempt.MediationHandle);
Assert.True(introduced.Succeeded);
return introduced.Value!;
}
public void Dispose()
{
Cursors.Dispose();
Sessions.Dispose();
}
}
@@ -5,6 +5,15 @@ namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PrincipalCredentialTests public sealed class PrincipalCredentialTests
{ {
[Fact]
public void Base64UrlDecoderRejectsNonCanonicalTrailingBits()
{
Assert.True(Base64Url.TryDecode("AA", out byte[] canonical));
Assert.Equal(new byte[] { 0 }, canonical);
Assert.False(Base64Url.TryDecode("AB", out byte[] nonCanonical));
Assert.Empty(nonCanonical);
}
[Fact] [Fact]
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals() public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
{ {
@@ -0,0 +1,404 @@
using System.Collections.Concurrent;
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.JoinAttempts;
namespace FinalFactory.Rendezvous.Tests.Server;
public sealed class NatMediationProcessorTests
{
[Fact]
public void AuthenticatedHostPresenceUsesTheObservedGameplaySocket()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost(bindPresence: false);
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
string token = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
registration.HostPresenceHandle,
registration.HostPresenceCapability);
Assert.Equal(
NatMediationResult.HostPresenceAccepted,
processor.ProcessRequest(
Endpoint("192.168.1.50", 40_000),
Endpoint("203.0.113.77", 51_234),
token,
sink));
Assert.Equal(registration.ListingId, Assert.Single(fixture.Sessions.Browse()).Definition.ListingId);
Assert.Empty(sink.Plans);
}
[Fact]
public void MatchedPeersReceiveOneIntroductionAndSameNatPrivateCandidates()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "same-nat");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
NatIntroductionPlan plan = Assert.Single(sink.Plans);
Assert.Equal(Endpoint("192.168.1.10", 41_000), plan.HostLocal);
Assert.Equal(Endpoint("192.168.1.11", 42_000), plan.ClientLocal);
Assert.Equal(Endpoint("203.0.113.20", 51_000), plan.HostPublic);
Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic);
Assert.Equal(43, plan.ConnectionTicket.Length);
Assert.DoesNotContain(plan.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
plan.ConnectionTicket,
out SecretFingerprint ticketFingerprint));
Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new(
attempt.AttemptId,
ticketFingerprint)).Succeeded);
Assert.Equal(
NatMediationResult.Duplicate,
Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
Assert.Single(sink.Plans);
}
[Fact]
public void DifferentNatsAndInvalidLocalClaimsExposeOnlyObservedPublicEndpoints()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "different-nats");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
_ = Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("8.8.8.8", 42_000), Endpoint("198.51.100.40", 52_000));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
NatIntroductionPlan plan = Assert.Single(sink.Plans);
Assert.Equal(plan.HostPublic, plan.HostLocal);
Assert.Equal(plan.ClientPublic, plan.ClientLocal);
Assert.NotEqual(IPAddress.Parse("8.8.8.8"), plan.ClientLocal.Address);
}
[Fact]
public void RoleAndEndpointSubstitutionAreRejectedWithoutChangingTheFirstBinding()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "substitution");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
string crossRole = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Host,
attempt.Handle,
attempt.ClientCapability);
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessRequest(
Endpoint("192.168.1.10", 41_000),
Endpoint("203.0.113.20", 51_000),
crossRole,
sink));
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
Assert.Equal(
NatMediationResult.Rejected,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.99", 41_999), Endpoint("203.0.113.99", 51_999)));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.2.10", 42_000), Endpoint("198.51.100.40", 52_000)));
Assert.Equal(Endpoint("203.0.113.20", 51_000), Assert.Single(sink.Plans).HostPublic);
}
[Fact]
public void ConcurrentAttemptsForOneSessionNeverCrossWire()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials first = CreateAttempt(fixture, registration, "parallel-1");
AttemptCredentials second = CreateAttempt(fixture, registration, "parallel-2");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
_ = Process(processor, sink, first, NatPunchPeerRole.Host,
Endpoint("10.0.0.10", 41_001), Endpoint("203.0.113.10", 51_001));
_ = Process(processor, sink, second, NatPunchPeerRole.Host,
Endpoint("10.0.0.20", 41_002), Endpoint("203.0.113.20", 51_002));
_ = Process(processor, sink, second, NatPunchPeerRole.Client,
Endpoint("10.0.0.21", 42_002), Endpoint("198.51.100.20", 52_002));
_ = Process(processor, sink, first, NatPunchPeerRole.Client,
Endpoint("10.0.0.11", 42_001), Endpoint("198.51.100.10", 52_001));
Assert.Equal(2, sink.Plans.Count);
Assert.Contains(sink.Plans, plan =>
plan.HostPublic.Equals(Endpoint("203.0.113.10", 51_001))
&& plan.ClientPublic.Equals(Endpoint("198.51.100.10", 52_001)));
Assert.Contains(sink.Plans, plan =>
plan.HostPublic.Equals(Endpoint("203.0.113.20", 51_002))
&& plan.ClientPublic.Equals(Endpoint("198.51.100.20", 52_002)));
}
[Fact]
public async Task ConcurrentDuplicateCompletionEmitsExactlyOneIntroduction()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "completion-race");
NatMediationProcessor processor = CreateProcessor(fixture);
ConcurrentIntroductionSink sink = new();
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
using Barrier barrier = new(2);
Task<NatMediationResult>[] completions = Enumerable.Range(0, 2)
.Select(_ => Task.Run(() =>
{
barrier.SignalAndWait();
return Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000));
}))
.ToArray();
NatMediationResult[] results = await Task.WhenAll(completions);
Assert.Single(results, result => result == NatMediationResult.Introduced);
Assert.Single(sink.Plans);
}
[Fact]
public async Task CancellationCannotReportSuccessAfterIntroductionIsConsumed()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "cancel-race");
NatMediationProcessor processor = CreateProcessor(fixture);
using BlockingIntroductionSink sink = new();
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
Task<NatMediationResult> completion = Task.Run(() => Process(
processor,
sink,
attempt,
NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000),
Endpoint("198.51.100.40", 52_000)));
Assert.True(sink.WaitUntilEntered(TimeSpan.FromSeconds(2)));
JoinAttemptServiceResult<bool> cancelled = fixture.Service.Cancel(
attempt.AttemptId,
attempt.ClientCapability);
Assert.Equal(RendezvousErrorCode.Conflict, cancelled.Error);
sink.Release();
Assert.Equal(NatMediationResult.Introduced, await completion);
}
[Fact]
public void DuplicateFloodAmortizesGlobalExpiryMaintenance()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "maintenance-budget");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
long before = fixture.Sessions.Store.MaintenanceSweepCount;
for (int index = 0; index < 256; index++)
{
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
}
Assert.InRange(fixture.Sessions.Store.MaintenanceSweepCount - before, 0, 1);
Assert.Empty(sink.Plans);
}
[Fact]
public void MissingStaleCancelledAndMalformedRequestsNeverIntroduce()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials stale = CreateAttempt(fixture, registration, "stale");
AttemptCredentials cancelled = CreateAttempt(fixture, registration, "cancelled");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, stale, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000)));
Assert.True(fixture.Service.Cancel(cancelled.AttemptId, cancelled.ClientCapability).Succeeded);
Assert.Equal(
NatMediationResult.Dropped,
Process(processor, sink, cancelled, NatPunchPeerRole.Client,
Endpoint("192.168.1.12", 42_001), Endpoint("198.51.100.41", 52_001)));
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(21));
Assert.Equal(
NatMediationResult.Dropped,
Process(processor, sink, stale, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessRequest(
Endpoint("192.168.1.10", 41_000),
Endpoint("203.0.113.20", 51_000),
"malformed",
sink));
Assert.Empty(sink.Plans);
}
[Fact]
public void AddressFamiliesMustMatchAndOnlyGlobalIpv6SourcesAreAccepted()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials mismatch = CreateAttempt(fixture, registration, "family-mismatch");
AttemptCredentials ipv6 = CreateAttempt(fixture, registration, "ipv6");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
byte[] shortFrozenIpv6 = RendezvousUdpCodec.Encode(new PresenceDatagram
{
MessageType = UdpPresenceMessageType.ClientPresence,
MediationHandle = ipv6.Handle,
AddressFamily = AddressFamilyKind.Ipv6,
LocalAddress = "fd00::11",
LocalPort = 42_000,
Capability = ipv6.ClientCapability,
});
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessDatagram(
shortFrozenIpv6,
Endpoint("2606:4700:4700::1001", 52_000),
sink));
_ = Process(processor, sink, mismatch, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
Assert.Equal(
NatMediationResult.Rejected,
Process(processor, sink, mismatch, NatPunchPeerRole.Client,
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1111", 52_000)));
Assert.Equal(
NatMediationResult.Dropped,
Process(processor, sink, ipv6, NatPunchPeerRole.Host,
Endpoint("fd00::10", 41_000), Endpoint("2001:db8::10", 51_000)));
_ = Process(processor, sink, ipv6, NatPunchPeerRole.Host,
Endpoint("fd00::10", 41_000), Endpoint("2606:4700:4700::1000", 51_000));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, ipv6, NatPunchPeerRole.Client,
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1001", 52_000)));
Assert.Single(sink.Plans);
}
private static NatMediationProcessor CreateProcessor(JoinAttemptFixture fixture) => new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
private static AttemptCredentials CreateAttempt(
JoinAttemptFixture fixture,
RegisterSessionResponse registration,
string idempotencyKey)
{
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, idempotencyKey);
HostJoinAttempt host = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
return new(
created.AttemptId,
created.MediationHandle,
host.HostPunchCapability,
created.ClientPunchCapability);
}
private static NatMediationResult Process(
NatMediationProcessor processor,
INatIntroductionSink sink,
AttemptCredentials attempt,
NatPunchPeerRole role,
IPEndPoint local,
IPEndPoint observed) => processor.ProcessRequest(
local,
observed,
NatPunchRequestTokenCodec.Encode(
role,
attempt.Handle,
role == NatPunchPeerRole.Client
? attempt.ClientCapability
: attempt.HostCapability),
sink);
private static IPEndPoint Endpoint(string address, int port) =>
new(IPAddress.Parse(address), port);
private sealed record AttemptCredentials(
JoinAttemptId AttemptId,
MediationHandle Handle,
string HostCapability,
string ClientCapability);
private sealed class CaptureIntroductionSink : INatIntroductionSink
{
public List<NatIntroductionPlan> Plans { get; } = [];
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
}
private sealed class ConcurrentIntroductionSink : INatIntroductionSink
{
public ConcurrentBag<NatIntroductionPlan> Plans { get; } = [];
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
}
private sealed class BlockingIntroductionSink : INatIntroductionSink, IDisposable
{
private readonly ManualResetEventSlim _entered = new();
private readonly ManualResetEventSlim _release = new();
public void Introduce(NatIntroductionPlan plan)
{
_entered.Set();
_release.Wait(TimeSpan.FromSeconds(2));
}
public bool WaitUntilEntered(TimeSpan timeout) => _entered.Wait(timeout);
public void Release() => _release.Set();
public void Dispose()
{
_entered.Dispose();
_release.Dispose();
}
}
}
@@ -1,10 +1,9 @@
using System.Net; using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport; using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.Sessions; using FinalFactory.Rendezvous.Tests.JoinAttempts;
using FinalFactory.Rendezvous.Tests.State; using LiteNetLib;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -13,55 +12,24 @@ namespace FinalFactory.Rendezvous.Tests.Server;
public sealed class UdpMediatorServiceTests public sealed class UdpMediatorServiceTests
{ {
[Fact] [Fact]
public void AuthenticatedHostDatagramGatesVisibilityUsingObservedGameplaySocket() public async Task ServiceBindsAnEphemeralLiteNetLibPortAndStopsCleanly()
{
using SessionLeaseFixture fixture = new();
RegisterSessionResponse registration = fixture.Register();
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions { ListenAddress = "127.0.0.1", Port = 0 }),
NullLogger<UdpMediatorService>.Instance,
fixture.Store,
fixture.Capabilities);
PresenceDatagram presence = new()
{
MessageType = UdpPresenceMessageType.HostPresence,
MediationHandle = registration.HostPresenceHandle,
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.168.1.50",
LocalPort = 40_000,
Capability = registration.HostPresenceCapability,
};
IPEndPoint observedGameplaySocket = new(IPAddress.Parse("203.0.113.77"), 51_234);
presence.Capability = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
Assert.Equal(
UdpPresenceProcessingResult.HostPresenceRejected,
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
Assert.Empty(fixture.Browse());
presence.Capability = registration.HostPresenceCapability;
Assert.Equal(
UdpPresenceProcessingResult.HostPresenceAccepted,
service.ProcessDatagram(RendezvousUdpCodec.Encode(presence), observedGameplaySocket));
Assert.Equal(registration.ListingId, Assert.Single(fixture.Browse()).Definition.ListingId);
}
[Fact]
public async Task ServiceBindsAnEphemeralUdpPortAndStopsCleanly()
{ {
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5)); using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
UdpMediatorOptions options = new() using JoinAttemptFixture fixture = new();
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions
{ {
ListenAddress = IPAddress.Loopback.ToString(), ListenAddress = IPAddress.Loopback.ToString(),
Port = 0, Port = 0,
}; MaxDatagramsPerPoll = 8,
ManualRendezvousClock clock = new(); PollIntervalMilliseconds = 1,
InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock); }),
using EphemeralCapabilityIssuer capabilities = new();
using UdpMediatorService service = new(
Options.Create(options),
NullLogger<UdpMediatorService>.Instance, NullLogger<UdpMediatorService>.Instance,
store, processor);
capabilities);
await service.StartAsync(timeout.Token); await service.StartAsync(timeout.Token);
@@ -69,9 +37,300 @@ public sealed class UdpMediatorServiceTests
Assert.NotNull(boundEndpoint); Assert.NotNull(boundEndpoint);
Assert.Equal(IPAddress.Loopback, boundEndpoint.Address); Assert.Equal(IPAddress.Loopback, boundEndpoint.Address);
Assert.InRange(boundEndpoint.Port, 1, 65_535); Assert.InRange(boundEndpoint.Port, 1, 65_535);
Assert.Null(service.LocalIpv6Endpoint);
await service.StopAsync(timeout.Token); await service.StopAsync(timeout.Token);
Assert.Null(service.LocalEndpoint); Assert.Null(service.LocalEndpoint);
} }
[Fact]
public async Task OptionalIpv6BindingNeverWidensTheRequiredIpv4Binding()
{
if (!Socket.OSSupportsIPv6)
{
return;
}
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new();
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Ipv6ListenAddress = IPAddress.IPv6Loopback.ToString(),
Port = 0,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await service.StartAsync(timeout.Token);
Assert.Equal(IPAddress.Loopback, service.LocalEndpoint!.Address);
Assert.Equal(IPAddress.IPv6Loopback, service.LocalIpv6Endpoint!.Address);
Assert.Equal(service.LocalEndpoint.Port, service.LocalIpv6Endpoint.Port);
IPAddress? otherIpv4 = Dns.GetHostAddresses(Dns.GetHostName())
.FirstOrDefault(address =>
address.AddressFamily == AddressFamily.InterNetwork
&& !IPAddress.IsLoopback(address));
if (otherIpv4 is not null)
{
using UdpClient scopeProbe = new(new IPEndPoint(otherIpv4, service.LocalEndpoint.Port));
Assert.Equal(otherIpv4, ((IPEndPoint)scopeProbe.Client.LocalEndPoint!).Address);
}
await service.StopAsync(timeout.Token);
}
[Fact]
public async Task NativeLiteNetLibRequestsIntroduceTheAuthorizedPair()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "native-litenet");
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await service.StartAsync(timeout.Token);
EventBasedNetListener hostListener = new();
EventBasedNetListener clientListener = new();
NetManager host = new(hostListener) { NatPunchEnabled = true };
NetManager client = new(clientListener) { NatPunchEnabled = true };
EventBasedNatPunchListener hostPunch = new();
EventBasedNatPunchListener clientPunch = new();
List<string> hostTickets = [];
List<string> clientTickets = [];
hostPunch.NatIntroductionSuccess += (_, _, ticket) => hostTickets.Add(ticket);
clientPunch.NatIntroductionSuccess += (_, _, ticket) => clientTickets.Add(ticket);
host.NatPunchModule.Init(hostPunch);
client.NatPunchModule.Init(clientPunch);
try
{
Assert.True(host.Start(0));
Assert.True(client.Start(0));
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
host.NatPunchModule.SendNatIntroduceRequest(
mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Host,
created.MediationHandle,
hostAttempt.HostPunchCapability));
client.NatPunchModule.SendNatIntroduceRequest(
mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Client,
created.MediationHandle,
created.ClientPunchCapability));
while ((hostTickets.Count == 0 || clientTickets.Count == 0)
&& !timeout.IsCancellationRequested)
{
host.PollEvents();
host.NatPunchModule.PollEvents();
client.PollEvents();
client.NatPunchModule.PollEvents();
await Task.Delay(5, timeout.Token);
}
string hostTicket = Assert.Single(hostTickets.Distinct(StringComparer.Ordinal));
string clientTicket = Assert.Single(clientTickets.Distinct(StringComparer.Ordinal));
Assert.Equal(hostTicket, clientTicket);
Assert.Equal(43, hostTicket.Length);
}
finally
{
host.Stop();
client.Stop();
await service.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task FrozenV1EnvelopeIsConsumedOnTheLiteNetSocketWithinAmplificationBudget()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "v1-envelope");
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await service.StartAsync(timeout.Token);
using UdpClient host = new(new IPEndPoint(IPAddress.Loopback, 0));
using UdpClient client = new(new IPEndPoint(IPAddress.Loopback, 0));
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
byte[] hostDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
{
MessageType = UdpPresenceMessageType.HostPresence,
MediationHandle = created.MediationHandle,
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.168.1.10",
LocalPort = 41_000,
Capability = hostAttempt.HostPunchCapability,
});
byte[] clientDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
{
MessageType = UdpPresenceMessageType.ClientPresence,
MediationHandle = created.MediationHandle,
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.168.1.11",
LocalPort = 42_000,
Capability = created.ClientPunchCapability,
});
try
{
await host.SendAsync(hostDatagram, mediator, timeout.Token);
await client.SendAsync(clientDatagram, mediator, timeout.Token);
UdpReceiveResult hostIntroduction = await host.ReceiveAsync(timeout.Token);
UdpReceiveResult clientIntroduction = await client.ReceiveAsync(timeout.Token);
Assert.True(
hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length
<= clientDatagram.Length * 2,
"The completing authenticated contribution exceeded the 2.0 response-byte budget.");
}
finally
{
await service.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task OversizedMalformedAndGameplayDatagramsReceiveNoResponse()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new();
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await service.StartAsync(timeout.Token);
using UdpClient sender = new(new IPEndPoint(IPAddress.Loopback, 0));
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
byte[] oversized = new byte[ContractLimits.UdpDatagramMaxBytes + 1];
oversized[0] = RendezvousUdpCodec.MagicFirst;
oversized[1] = RendezvousUdpCodec.MagicSecond;
byte[] gameplayPayload = [0x01, 0x02, 0x03, 0x04];
byte[] malformedNative = [17, 0];
try
{
await sender.SendAsync(oversized, mediator, timeout.Token);
await sender.SendAsync(gameplayPayload, mediator, timeout.Token);
await sender.SendAsync(malformedNative, mediator, timeout.Token);
using CancellationTokenSource noResponse = new(TimeSpan.FromMilliseconds(150));
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
await sender.ReceiveAsync(noResponse.Token));
}
finally
{
await service.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task ForgedNativeIntroductionResponseCannotReflectToPayloadEndpoint()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new();
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await service.StartAsync(timeout.Token);
using UdpClient reflectedTarget = new(new IPEndPoint(IPAddress.Loopback, 0));
using UdpClient responseCapture = new(new IPEndPoint(IPAddress.Loopback, 0));
using UdpClient attacker = new(new IPEndPoint(IPAddress.Loopback, 0));
LiteNetManager generator = new(new EventBasedLiteNetListener()) { NatPunchEnabled = true };
try
{
Assert.True(generator.Start(0));
IPEndPoint target = (IPEndPoint)reflectedTarget.Client.LocalEndPoint!;
IPEndPoint capture = (IPEndPoint)responseCapture.Client.LocalEndPoint!;
generator.NatPunchModule.NatIntroduce(
target,
new IPEndPoint(IPAddress.Loopback, 9),
capture,
capture,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
byte[] forgedResponse = (await responseCapture.ReceiveAsync(timeout.Token)).Buffer;
await attacker.SendAsync(
forgedResponse,
Assert.IsType<IPEndPoint>(service.LocalEndpoint),
timeout.Token);
using CancellationTokenSource noReflection = new(TimeSpan.FromMilliseconds(150));
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
await reflectedTarget.ReceiveAsync(noReflection.Token));
}
finally
{
generator.Stop();
await service.StopAsync(CancellationToken.None);
}
}
} }
@@ -4,6 +4,7 @@ using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.Sessions;
@@ -52,6 +53,8 @@ public sealed class SessionHttpEndpointTests
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities); builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>(); builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>();
await using WebApplication app = builder.Build(); await using WebApplication app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.MapRendezvousContractEndpoints(); app.MapRendezvousContractEndpoints();
@@ -107,6 +110,23 @@ public sealed class SessionHttpEndpointTests
ContractJson.Options); ContractJson.Options);
Assert.NotNull(session); Assert.NotNull(session);
Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString); Assert.Equal($"/v1/sessions/{session.ListingId}", created.Headers.Location!.OriginalString);
Assert.True(capabilities.TryFingerprint(
session.HostPresenceCapability,
out SecretFingerprint presenceFingerprint));
store.BindHostPresence(new(
session.HostPresenceHandle,
presenceFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
null));
BrowseSessionsResponse? browser = await client.GetFromJsonAsync<BrowseSessionsResponse>(
"/v1/sessions?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7&regionId=eu-central&pageSize=10&excludeFull=true",
ContractJson.Options);
Assert.Equal(session.ListingId, Assert.Single(browser!.Items).ListingId);
GetSessionResponse? direct = await client.GetFromJsonAsync<GetSessionResponse>(
$"/v1/sessions/{session.ListingId}?contractVersion=1&gameId=space-game&environmentId=production&protocolVersion=7",
ContractJson.Options);
Assert.Equal(session.ListingId, direct!.Session.ListingId);
HttpResponseMessage renewed = await client.PostAsJsonAsync( HttpResponseMessage renewed = await client.PostAsJsonAsync(
$"/v1/sessions/{session.ListingId}/renew", $"/v1/sessions/{session.ListingId}/renew",
@@ -61,6 +61,7 @@ public sealed class SessionLeaseServiceTests
{ {
LeaseLifetime = TimeSpan.FromSeconds(5), LeaseLifetime = TimeSpan.FromSeconds(5),
JoinAttemptLifetime = TimeSpan.FromSeconds(5), JoinAttemptLifetime = TimeSpan.FromSeconds(5),
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
IdempotencyLifetime = TimeSpan.FromSeconds(6), IdempotencyLifetime = TimeSpan.FromSeconds(6),
}; };
using SessionLeaseFixture fixture = new(options); using SessionLeaseFixture fixture = new(options);
@@ -95,6 +95,8 @@ internal sealed class EphemeralStateFixture
ProtocolVersion = listing.Definition.ProtocolVersion, ProtocolVersion = listing.Definition.ProtocolVersion,
HostCapabilityFingerprint = Fingerprint($"host-{sequence}"), HostCapabilityFingerprint = Fingerprint($"host-{sequence}"),
ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"), ClientCapabilityFingerprint = Fingerprint($"client-{sequence}"),
ConnectionTicketFingerprint = Fingerprint($"ticket-{sequence}"),
CapabilityDerivationSalt = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
}; };
} }
@@ -0,0 +1,30 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.State;
public sealed class StoreResultMappingTests
{
[Fact]
public void EveryStoreResultHasOneSharedContractErrorMapping()
{
Dictionary<StoreResultCode, RendezvousErrorCode> expected = new()
{
[StoreResultCode.Success] = RendezvousErrorCode.None,
[StoreResultCode.NotFound] = RendezvousErrorCode.NotFound,
[StoreResultCode.Expired] = RendezvousErrorCode.Expired,
[StoreResultCode.Revoked] = RendezvousErrorCode.Forbidden,
[StoreResultCode.Conflict] = RendezvousErrorCode.Conflict,
[StoreResultCode.CapacityExceeded] = RendezvousErrorCode.CapacityExceeded,
[StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable,
[StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected,
[StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable,
};
Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count);
foreach (KeyValuePair<StoreResultCode, RendezvousErrorCode> item in expected)
{
Assert.Equal(item.Value, item.Key.ToContractError());
}
}
}
@@ -0,0 +1,74 @@
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult
ENUM Accepted=1
ENUM NotFound=2
ENUM Expired=3
ENUM Rejected=4
ENUM AlreadyConsumed=5
ENUM Revoked=6
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator
CTOR (System.Int32 maximumAuthorizedTickets)
METHOD FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult Consume(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
METHOD System.Void Dispose()
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
METHOD System.String ToString()
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.RenewLeaseResponse>> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.IRendezvousSessionBrowserClient
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {get;}
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason
ENUM Cancelled=1
ENUM Disposed=2
ENUM LeaseLost=3
ENUM Failed=4
TYPE FinalFactory.Rendezvous.Client.PublishedSession
PROP System.DateTimeOffset ExpiresAt {get;}
PROP System.String HostPresenceCapability {get;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;}
PROP System.Int32 HostPresenceRefreshAfterSeconds {get;}
PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;}
PROP System.Int32 LeaseRenewAfterSeconds {get;}
PROP System.String LeaseToken {get;}
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;}
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
CTOR ()
PROP System.TimeSpan InitialRetryDelay {get;set;}
PROP System.Double JitterRatio {get;set;}
PROP System.TimeSpan MaximumRetryDelay {get;set;}
PROP System.Int32 MaximumSafeRetries {get;set;}
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Failure(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.String message, System.Nullable<System.Int32> retryAfterSeconds)
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Success(T value)
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult<T>
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
PROP System.Boolean IsSuccess {get;}
PROP System.String Message {get;}
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;}
PROP T Value {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.RenewLeaseResponse>> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.RendezvousSessionBrowserClient
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer
EVENT System.EventHandler LeaseLost
METHOD System.Threading.Tasks.ValueTask DisposeAsync()
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.LeaseMaintenanceResult> RunAsync(System.Threading.CancellationToken cancellationToken)
@@ -18,6 +18,7 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest
PROP System.Int32 ContractVersion {get;set;} PROP System.Int32 ContractVersion {get;set;}
PROP System.String Cursor {get;set;} PROP System.String Cursor {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;} PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP System.Boolean ExcludeFull {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;} PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
PROP System.Int32 PageSize {get;set;} PROP System.Int32 PageSize {get;set;}
PROP System.UInt32 ProtocolVersion {get;set;} PROP System.UInt32 ProtocolVersion {get;set;}
@@ -48,6 +49,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
FIELD System.Int32 ConnectionTicketMaxCharacters=192 FIELD System.Int32 ConnectionTicketMaxCharacters=192
FIELD System.Int32 ContractVersion=1 FIELD System.Int32 ContractVersion=1
FIELD System.Int32 CursorMaxCharacters=512 FIELD System.Int32 CursorMaxCharacters=512
FIELD System.Int32 DerivedCredentialCharacters=43
FIELD System.Int32 DiagnosticCodeMaxCharacters=64 FIELD System.Int32 DiagnosticCodeMaxCharacters=64
FIELD System.Int32 DisplayNameMaxBytes=128 FIELD System.Int32 DisplayNameMaxBytes=128
FIELD System.Int32 EnvironmentIdMaxCharacters=32 FIELD System.Int32 EnvironmentIdMaxCharacters=32
@@ -60,6 +62,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
FIELD System.Int32 MetadataMaxBytes=4096 FIELD System.Int32 MetadataMaxBytes=4096
FIELD System.Int32 MetadataMaxKeys=32 FIELD System.Int32 MetadataMaxKeys=32
FIELD System.Int32 MetadataValueMaxBytes=256 FIELD System.Int32 MetadataValueMaxBytes=256
FIELD System.Int32 NatPunchRequestTokenCharacters=192
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024 FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
FIELD System.Int32 RegionIdMaxCharacters=32 FIELD System.Int32 RegionIdMaxCharacters=32
FIELD System.Int32 SessionCapacityMaxPlayers=10000 FIELD System.Int32 SessionCapacityMaxPlayers=10000
@@ -170,6 +173,20 @@ TYPE FinalFactory.Rendezvous.Contracts.MediationHandle
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id) METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right) METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right) METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
TYPE FinalFactory.Rendezvous.Contracts.NatPunchPeerRole
ENUM HostPresence=1
ENUM Host=2
ENUM Client=3
TYPE FinalFactory.Rendezvous.Contracts.NatPunchRequestToken
CTOR ()
PROP System.String Capability {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
PROP FinalFactory.Rendezvous.Contracts.NatPunchPeerRole Role {get;set;}
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Contracts.NatPunchRequestTokenCodec
FIELD System.Int32 EncodedLength=192
METHOD System.String Encode(FinalFactory.Rendezvous.Contracts.NatPunchPeerRole role, FinalFactory.Rendezvous.Contracts.MediationHandle mediationHandle, System.String capability)
METHOD System.Boolean TryDecode(System.String encoded, FinalFactory.Rendezvous.Contracts.NatPunchRequestToken& token)
TYPE FinalFactory.Rendezvous.Contracts.NetworkEndpoint TYPE FinalFactory.Rendezvous.Contracts.NetworkEndpoint
CTOR () CTOR ()
PROP System.String Address {get;set;} PROP System.String Address {get;set;}