Compare commits

..

4 Commits

Author SHA1 Message Date
KyuubiYoru b4b6072fe1 feat(client): add rendezvous traversal coordinators (#12)
quality-gate / quality (push) Successful in 56s
2026-07-16 08:39:05 +02:00
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
67 changed files with 8142 additions and 287 deletions
+137 -4
View File
@@ -686,8 +686,28 @@
}
}
},
"501": {
"description": "Not Implemented",
"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": {
"application/json": {
"schema": {
@@ -726,8 +746,109 @@
}
}
},
"501": {
"description": "Not Implemented",
"400": {
"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": {
"application/json": {
"schema": {
@@ -933,6 +1054,7 @@
"attemptId",
"mediationHandle",
"clientPunchCapability",
"connectionTicketDigest",
"expiresAt"
],
"type": "object",
@@ -950,6 +1072,9 @@
"clientPunchCapability": {
"type": "string"
},
"connectionTicketDigest": {
"type": "string"
},
"expiresAt": {
"type": "string",
"format": "date-time"
@@ -1025,6 +1150,8 @@
"attemptId",
"mediationHandle",
"hostPunchCapability",
"connectionTicketDigest",
"isCancelled",
"expiresAt"
],
"type": "object",
@@ -1038,6 +1165,12 @@
"hostPunchCapability": {
"type": "string"
},
"connectionTicketDigest": {
"type": "string"
},
"isCancelled": {
"type": "boolean"
},
"expiresAt": {
"type": "string",
"format": "date-time"
@@ -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 |
| UDP datagram accepted | 1,200 bytes; oversized or fragmented application payloads are dropped without response |
| 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 |
| Lease lifetime | 60 seconds; renewal accepted from 30 seconds; no client-selected extension |
| Host presence freshness | 20 seconds |
@@ -60,12 +60,14 @@ the supplied ID.
### UDP presence
Only a structurally valid `HostPresence` datagram with the issued capability can
refresh presence. The public endpoint is the UDP packet's observed source on the
host's gameplay socket; the HTTP API never accepts one. The bounded local candidate
comes from the authenticated datagram. Invalid, unknown, or client-presence packets
receive no response. Presence expiry demotes public visibility but keeps the lease,
so the same handle can restore visibility without changing session identity.
Only a structurally valid frozen `HostPresence` envelope or native LiteNetLib
host-presence request with the issued capability can refresh presence. The public
endpoint is the UDP packet's observed source on the host's gameplay socket; the
HTTP API never accepts one. The bounded local candidate comes from the authenticated
packet. Invalid or unknown inputs receive no response. ADR 0009 defines the later
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/local endpoints, lease tokens, presence capabilities, fingerprints, store
@@ -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,78 @@
# 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. The connection ticket uses half of that payload
for its attempt ID and half for an independently derived 128-bit authenticator, so
the SDK can correlate concurrent introductions without increasing UDP response
size. 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 marks the attempt
and retains a bounded tombstone until its original expiry. Host polling returns
that tombstone so a coordinator can revoke any local ticket authorization, while
endpoint binding, introduction, ticket issuance, and ticket consumption all
reject the cancelled 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 fixed-size ticket in the authenticated introduction. Issue
#12 extracts its embedded attempt ID, bounds the host's local authorization window
by both the host-polled attempt expiry and the configured ticket lifetime, then
wires one-time consumption into the caller-owned coordinator. Both peers receive
a digest of the exact expected ticket over HTTP and reject any syntactically valid
but unauthenticated introduction token. Embedding the ID prevents concurrent or
late introductions from cross-binding a valid ticket while preserving the
mediator's 2.0 response-byte amplification ceiling.
## 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.
+3
View File
@@ -9,6 +9,9 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [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 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)
- [Security promise and test matrix](../security/control-matrix.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/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
| `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. |
| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
| `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
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
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
client. It associates the authenticated mediation handle with the packet's
observed public source endpoint and the sender's reported local endpoint. It
does not carry gameplay packets.
The UDP mediator accepts the frozen bounded presence envelope below and native
LiteNetLib NAT-introduction requests. Both forms associate an authenticated
mediation handle with the packet's observed public source endpoint and the
sender's reported local endpoint. Neither form carries gameplay packets.
All multi-byte integers use network byte order. UUID bytes use the canonical
RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
@@ -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
prove authorization until the capability is checked. Invalid packets receive
no UDP response, preventing the mediator from becoming an amplification oracle.
Replay, expiry, pairing, and rate-limit policy are defined by later mediator
issues; the v1 envelope deliberately leaves no unbounded or reflected payload.
For the frozen envelope, `HostPresence` is resolved against either the listing's
host-presence capability or an attempt's host-role capability. `ClientPresence`
is resolved only against the attempt's client-role capability. Handles are
globally distinct in the active store, so this does not permit role confusion.
## Native LiteNetLib request token
A game using LiteNetLib sends `NatPunchModule.SendNatIntroduceRequest` from its
gameplay `NetManager`. The `additionalInfo` value is produced by
`NatPunchRequestTokenCodec` and is exactly 192 ASCII characters:
```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>
<IsPackable>true</IsPackable>
<PackageId>FinalFactory.Rendezvous.Client</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Godot-independent client SDK for Final Factory Rendezvous.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" />
<None Update="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>
@@ -0,0 +1,148 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousJoinClient : IRendezvousJoinClient
{
private const string LeaseTokenHeader = "X-Rendezvous-Lease-Token";
private const string ClientPunchCapabilityHeader = "X-Rendezvous-Client-Punch-Capability";
private readonly RendezvousHttpTransport _transport;
public RendezvousJoinClient(
HttpClient httpClient,
RendezvousClientOptions? options = null,
IRendezvousDelay? delay = null)
{
_transport = new(httpClient, options, delay);
}
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
CreateJoinAttemptRequest body = new()
{
ContractVersion = request.ContractVersion,
IdempotencyKey = request.IdempotencyKey,
GameId = request.GameId,
EnvironmentId = request.EnvironmentId,
ListingId = request.ListingId,
ProtocolVersion = request.ProtocolVersion,
};
return _transport.SendSafeAsync<CreateJoinAttemptResponse>(
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/join-attempts", body),
cancellationToken);
}
public Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default)
{
if (attempt is null)
{
throw new ArgumentNullException(nameof(attempt));
}
return _transport.SendSafeAsync<bool>(
() => HeaderRequest(
HttpMethod.Delete,
$"v1/join-attempts/{attempt.AttemptId}",
ClientPunchCapabilityHeader,
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt))),
cancellationToken);
}
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
if (pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
{
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
string query = $"v1/sessions/{session.ListingId}/join-attempts"
+ $"?contractVersion={ContractLimits.ContractVersion}"
+ $"&pageSize={pageSize}"
+ (cursor is null ? string.Empty : $"&cursor={Uri.EscapeDataString(cursor)}");
return _transport.SendSafeAsync<BrowseHostJoinAttemptsResponse>(
() => HeaderRequest(
HttpMethod.Get,
query,
LeaseTokenHeader,
RequireHeaderValue(session.LeaseToken, nameof(session))),
cancellationToken);
}
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
if (maximumPages is < 1 or > 1_000)
{
throw new ArgumentOutOfRangeException(nameof(maximumPages));
}
List<HostJoinAttempt> attempts = [];
string? cursor = null;
for (int page = 0; page < maximumPages; page++)
{
RendezvousClientResult<BrowseHostJoinAttemptsResponse> result =
await BrowseForHostAsync(
session,
ContractLimits.BrowserPageMaxItems,
cursor,
cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
attempts.AddRange(result.Value.Items);
cursor = result.Value.NextCursor;
if (string.IsNullOrEmpty(cursor))
{
return RendezvousClientResult.Success<IReadOnlyList<HostJoinAttempt>>(
attempts.AsReadOnly());
}
}
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
RendezvousErrorCode.CapacityExceeded,
$"Host invitation polling exceeded the configured {maximumPages}-page limit.");
}
private static HttpRequestMessage HeaderRequest(
HttpMethod method,
string uri,
string header,
string value)
{
HttpRequestMessage request = new(method, uri);
request.Headers.TryAddWithoutValidation(header, value);
return request;
}
private static string RequireHeaderValue(string value, string parameterName) =>
!string.IsNullOrWhiteSpace(value)
? value
: throw new ArgumentException("The required capability is missing.", parameterName);
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
@@ -0,0 +1,159 @@
# FinalFactory.Rendezvous.Client
Godot-independent .NET publisher, browser, join, and LiteNetLib traversal 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);
```
For direct connections, let the SDK drive those tokens from the same caller-owned
LiteNetLib socket that carries gameplay. Ask the routing listener to create the
bound manager, then configure and start that caller-owned manager yourself. The
factory does not open a socket, and synchronized events must remain enabled:
```csharp
RendezvousNetListener networkEvents = new();
NetManager gameplayNetManager = networkEvents.CreateManager();
if (!gameplayNetManager.Start(0))
{
throw new InvalidOperationException("The gameplay UDP socket could not start.");
}
```
The host polls join invitations asynchronously; that method only queues a
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
frame on the thread that owns the manager:
```csharp
RendezvousJoinClient joins = new(http);
using RendezvousHostCoordinator host = new(
gameplayNetManager,
networkEvents,
mediatorEndPoint,
session,
joins);
// Run periodically from the game's normal async scheduling path.
await host.RefreshJoinAttemptsAsync(cancellationToken);
// Godot _Process, Update, or the equivalent main-thread frame callback.
host.Poll();
```
Do not also call `gameplayNetManager.PollEvents()` or
`gameplayNetManager.NatPunchModule.PollEvents()` when a coordinator owns polling.
The host coordinator refreshes host presence, punches for queued invitations,
validates the introduction ticket, and accepts the direct request. Subscribe to
`AttemptCompleted`; a `Connected` result is raised only after LiteNetLib reports
the accepted peer as connected. Register ordinary gameplay callbacks on
`networkEvents.GameplayEvents`; the routing listener reserves Rendezvous direct
requests for ticket validation and forwards every other callback normally.
The joining game first creates the HTTP attempt, then uses its own already-started
gameplay manager in the same frame loop:
```csharp
CreateJoinAttemptResponse attempt = (await joins.CreateAsync(
createJoinRequest,
cancellationToken)).Value
?? throw new InvalidOperationException("Join issuance failed.");
using RendezvousClientCoordinator client = new(
gameplayNetManager,
networkEvents,
mediatorEndPoint,
attempt);
// Godot _Process, Update, or the equivalent main-thread frame callback.
client.Poll();
```
NAT introduction changes the client state to `Connecting`; it is not success.
Only `Connected` supplies `ConnectedPeer`. Call `Cancel()` and then `Poll()` for
local cancellation, or `CancelAsync(joins, cancellationToken)` to also revoke the
service attempt. Terminal client paths release all event subscriptions. Disposing
a coordinator never stops or disposes the caller-owned manager and does not touch
an in-flight peer; call `Cancel()` followed by `Poll()` first when that peer must
also be disconnected.
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,200 @@
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
{
private readonly object _timingGate = new();
private DateTimeOffset _expiresAt;
private int _leaseRenewAfterSeconds;
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
{
lock (_timingGate)
{
return _expiresAt;
}
}
internal set
{
lock (_timingGate)
{
_expiresAt = value;
}
}
}
public int LeaseRenewAfterSeconds
{
get
{
lock (_timingGate)
{
return _leaseRenewAfterSeconds;
}
}
internal set
{
lock (_timingGate)
{
_leaseRenewAfterSeconds = value;
}
}
}
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 IRendezvousJoinClient
{
Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
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;
}
}
}
}
@@ -0,0 +1,83 @@
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class DirectConnectionRequest
{
public JoinAttemptId AttemptId { get; set; }
public string ConnectionTicket { get; set; } = string.Empty;
public override string ToString() =>
$"[DirectConnectionRequest {AttemptId}; ticket redacted]";
}
public static class DirectConnectionRequestCodec
{
public const int EncodedLength = 63;
private const int MagicLength = 4;
private const int AttemptIdLength = 16;
private const int TicketLength = ContractLimits.DerivedCredentialCharacters;
private static readonly byte[] Magic = [(byte)'R', (byte)'V', (byte)'D', (byte)'1'];
public static bool IsRendezvousRequest(ReadOnlySpan<byte> encoded) =>
encoded.Length >= MagicLength && encoded[..MagicLength].SequenceEqual(Magic);
public static byte[] Encode(JoinAttemptId attemptId, string connectionTicket)
{
if (attemptId.Value == Guid.Empty
|| connectionTicket is null
|| connectionTicket.Length != TicketLength
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
{
throw new ArgumentException("The direct connection request fields are invalid.");
}
byte[] encoded = new byte[EncodedLength];
Magic.CopyTo(encoded, 0);
if (!attemptId.Value.TryWriteBytes(encoded.AsSpan(MagicLength, AttemptIdLength)))
{
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
}
Encoding.ASCII.GetBytes(
connectionTicket,
0,
connectionTicket.Length,
encoded,
MagicLength + AttemptIdLength);
return encoded;
}
public static bool TryDecode(
ReadOnlySpan<byte> encoded,
out DirectConnectionRequest? request)
{
request = null;
if (encoded.Length != EncodedLength
|| !encoded[..MagicLength].SequenceEqual(Magic))
{
return false;
}
Guid attemptId = new(encoded.Slice(MagicLength, AttemptIdLength));
if (attemptId == Guid.Empty)
{
return false;
}
string ticket = Encoding.ASCII.GetString(encoded[(MagicLength + AttemptIdLength)..]);
if (!ContractValidation.IsConnectionTicketValid(ticket))
{
return false;
}
request = new DirectConnectionRequest
{
AttemptId = new JoinAttemptId(attemptId),
ConnectionTicket = ticket,
};
return true;
}
}
@@ -0,0 +1,290 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousClientCoordinator : IDisposable
{
private readonly NetManager _manager;
private readonly RendezvousNetListener _networkEvents;
private readonly EventBasedNatPunchListener _punchEvents;
private readonly IPEndPoint _mediator;
private readonly CreateJoinAttemptResponse _attempt;
private readonly IRendezvousCoordinatorClock _clock;
private readonly RendezvousPunchRetrySchedule _retry;
private NetPeer? _connectingPeer;
private bool _cancelRequested;
private int _polling;
private bool _subscriptionsReleased;
private bool _disposed;
public RendezvousClientCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
CreateJoinAttemptResponse attempt,
RendezvousCoordinatorOptions? options = null)
: this(
manager,
networkEvents,
mediator,
attempt,
options,
new SystemRendezvousCoordinatorClock())
{
}
internal RendezvousClientCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
CreateJoinAttemptResponse attempt,
RendezvousCoordinatorOptions? options,
IRendezvousCoordinatorClock clock)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
_punchEvents = _networkEvents.PunchEvents;
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_attempt = attempt ?? throw new ArgumentNullException(nameof(attempt));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
RendezvousCoordinatorOptions validated = (options ?? new RendezvousCoordinatorOptions())
.CopyAndValidate();
_retry = new(validated, _clock);
RendezvousManagerGuard.Validate(_manager, _networkEvents);
if (_mediator.Port is < 1 or > 65_535
|| _attempt.AttemptId.Value == Guid.Empty
|| _attempt.MediationHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(_attempt.ClientPunchCapability)
|| !ContractValidation.IsConnectionTicketValid(_attempt.ConnectionTicketDigest)
|| _attempt.ExpiresAt <= _clock.UtcNow)
{
throw new ArgumentException("The client traversal inputs are invalid.");
}
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
}
public event EventHandler<RendezvousConnectionCompletedEventArgs>? Completed;
public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching;
public NetPeer? ConnectedPeer { get; private set; }
public bool IsCompleted => IsTerminal(State);
public void Cancel() => Volatile.Write(ref _cancelRequested, true);
public async Task<RendezvousClientResult<bool>> CancelAsync(
IRendezvousJoinClient joinClient,
CancellationToken cancellationToken = default)
{
if (joinClient is null)
{
throw new ArgumentNullException(nameof(joinClient));
}
ThrowIfDisposed();
Cancel();
return await joinClient.CancelAsync(_attempt, cancellationToken).ConfigureAwait(false);
}
public void Poll()
{
ThrowIfDisposed();
if (IsCompleted)
{
return;
}
if (Interlocked.Exchange(ref _polling, 1) != 0)
{
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
}
try
{
if (Volatile.Read(ref _cancelRequested))
{
DisconnectPendingPeer();
Complete(RendezvousConnectionState.Cancelled);
return;
}
if (!_manager.IsRunning)
{
Complete(RendezvousConnectionState.ManagerStopped);
return;
}
_manager.PollEvents();
_manager.NatPunchModule.PollEvents();
if (IsCompleted)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
if (Volatile.Read(ref _cancelRequested))
{
DisconnectPendingPeer();
Complete(RendezvousConnectionState.Cancelled);
}
else if (!_manager.IsRunning)
{
Complete(RendezvousConnectionState.ManagerStopped);
}
else if (now >= _attempt.ExpiresAt)
{
DisconnectPendingPeer();
Complete(RendezvousConnectionState.TimedOut);
}
else if (State == RendezvousConnectionState.Punching && _retry.IsDue(now))
{
if (_retry.IsExhausted)
{
Complete(RendezvousConnectionState.TimedOut);
return;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Client,
_attempt.MediationHandle,
_attempt.ClientPunchCapability));
_retry.RecordRequest();
}
}
finally
{
Volatile.Write(ref _polling, 0);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
if (!IsCompleted)
{
Complete(RendezvousConnectionState.Disposed);
}
ReleaseSubscriptions();
_disposed = true;
}
public override string ToString() =>
$"[RendezvousClientCoordinator {_attempt.AttemptId}; credentials redacted]";
private void OnNatIntroductionSuccess(
IPEndPoint target,
NatAddressType addressType,
string encodedIntroduction)
{
_ = addressType;
if (State != RendezvousConnectionState.Punching
|| !NatIntroductionTokenCodec.TryDecode(
encodedIntroduction,
out NatIntroductionToken? introduction)
|| introduction is null
|| introduction.AttemptId != _attempt.AttemptId
|| !NatIntroductionTokenCodec.MatchesDigest(
introduction.ConnectionTicket,
_attempt.ConnectionTicketDigest))
{
return;
}
byte[] connectionData = DirectConnectionRequestCodec.Encode(
introduction.AttemptId,
introduction.ConnectionTicket);
_connectingPeer = _manager.Connect(target, connectionData);
if (_connectingPeer is null
|| _connectingPeer.ConnectionState != ConnectionState.Outgoing)
{
_connectingPeer = null;
Complete(RendezvousConnectionState.Rejected);
return;
}
State = RendezvousConnectionState.Connecting;
}
private void OnPeerConnected(NetPeer peer)
{
if (State != RendezvousConnectionState.Connecting
|| !ReferenceEquals(peer, _connectingPeer))
{
return;
}
ConnectedPeer = peer;
Complete(RendezvousConnectionState.Connected, peer);
}
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
_ = disconnectInfo;
if (State == RendezvousConnectionState.Connecting
&& ReferenceEquals(peer, _connectingPeer))
{
Complete(RendezvousConnectionState.Rejected);
}
}
private void DisconnectPendingPeer()
{
if (_connectingPeer is not null && State == RendezvousConnectionState.Connecting)
{
_connectingPeer.Disconnect();
}
}
private void Complete(RendezvousConnectionState terminalState, NetPeer? peer = null)
{
if (IsCompleted)
{
return;
}
State = terminalState;
ReleaseSubscriptions();
Completed?.Invoke(this, new(terminalState, peer));
}
private void ReleaseSubscriptions()
{
if (_subscriptionsReleased)
{
return;
}
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true;
}
private static bool IsTerminal(RendezvousConnectionState state) => state is
RendezvousConnectionState.Connected
or RendezvousConnectionState.Cancelled
or RendezvousConnectionState.TimedOut
or RendezvousConnectionState.Rejected
or RendezvousConnectionState.ManagerStopped
or RendezvousConnectionState.Disposed;
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(RendezvousClientCoordinator));
}
}
}
@@ -0,0 +1,124 @@
using System.Security.Cryptography;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public enum RendezvousConnectionState
{
Punching = 1,
Connecting = 2,
Connected = 3,
Cancelled = 4,
TimedOut = 5,
Rejected = 6,
ManagerStopped = 7,
Disposed = 8,
}
public sealed class RendezvousConnectionCompletedEventArgs(
RendezvousConnectionState state,
NetPeer? peer = null) : EventArgs
{
public RendezvousConnectionState State { get; } = state;
public NetPeer? Peer { get; } = peer;
}
public sealed class RendezvousCoordinatorOptions
{
public int MaximumPunchRequests { get; set; } = 5;
public int MaximumAttemptChecksPerPoll { get; set; } = 128;
public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
public TimeSpan MaximumPunchRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20);
public double JitterRatio { get; set; } = 0.2;
internal RendezvousCoordinatorOptions CopyAndValidate()
{
if (MaximumPunchRequests is < 1 or > 20
|| MaximumAttemptChecksPerPoll is < 1 or > 1_024
|| InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10)
|| MaximumPunchRetryDelay < InitialPunchRetryDelay
|| MaximumPunchRetryDelay > TimeSpan.FromSeconds(10)
|| ConnectionTicketLifetime <= TimeSpan.Zero
|| ConnectionTicketLifetime > TimeSpan.FromSeconds(20)
|| JitterRatio is < 0 or > 1)
{
throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions));
}
return new RendezvousCoordinatorOptions
{
MaximumPunchRequests = MaximumPunchRequests,
MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll,
InitialPunchRetryDelay = InitialPunchRetryDelay,
MaximumPunchRetryDelay = MaximumPunchRetryDelay,
ConnectionTicketLifetime = ConnectionTicketLifetime,
JitterRatio = JitterRatio,
};
}
}
internal interface IRendezvousCoordinatorClock
{
DateTimeOffset UtcNow { get; }
}
internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
internal static class RendezvousManagerGuard
{
internal static void Validate(
NetManager manager,
RendezvousNetListener networkEvents)
{
networkEvents.ValidateManager(manager);
if (!manager.IsRunning)
{
throw new InvalidOperationException("The caller-owned LiteNetLib manager must be running.");
}
if (!manager.NatPunchEnabled
|| manager.UnsyncedEvents
|| manager.NatPunchModule.UnsyncedEvents)
{
throw new InvalidOperationException(
"The caller-owned manager must enable NAT punching and synchronized event dispatch.");
}
}
}
internal sealed class RendezvousPunchRetrySchedule(
RendezvousCoordinatorOptions options,
IRendezvousCoordinatorClock clock)
{
public int RequestsSent { get; private set; }
public DateTimeOffset NextRequestAt { get; private set; } = DateTimeOffset.MinValue;
public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests;
public bool IsDue(DateTimeOffset now) => now >= NextRequestAt;
public void RecordRequest()
{
int exponent = Math.Min(RequestsSent, 30);
RequestsSent++;
double milliseconds = Math.Min(
options.InitialPunchRetryDelay.TotalMilliseconds * Math.Pow(2, exponent),
options.MaximumPunchRetryDelay.TotalMilliseconds);
if (options.JitterRatio > 0)
{
Span<byte> random = stackalloc byte[1];
RandomNumberGenerator.Fill(random);
double unit = random[0] / 255d;
double multiplier = 1 - options.JitterRatio + (2 * options.JitterRatio * unit);
milliseconds = Math.Min(
milliseconds * multiplier,
options.MaximumPunchRetryDelay.TotalMilliseconds);
}
NextRequestAt = clock.UtcNow + TimeSpan.FromMilliseconds(milliseconds);
}
}
@@ -0,0 +1,547 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public enum RendezvousHostState
{
Active = 1,
ManagerStopped = 2,
Disposed = 3,
}
public sealed class RendezvousHostAttemptCompletedEventArgs(
JoinAttemptId attemptId,
RendezvousConnectionState state,
NetPeer? peer = null) : EventArgs
{
public JoinAttemptId AttemptId { get; } = attemptId;
public RendezvousConnectionState State { get; } = state;
public NetPeer? Peer { get; } = peer;
}
public sealed class RendezvousHostCoordinator : IDisposable
{
private readonly NetManager _manager;
private readonly RendezvousNetListener _networkEvents;
private readonly EventBasedNatPunchListener _punchEvents;
private readonly IPEndPoint _mediator;
private readonly PublishedSession _session;
private readonly IRendezvousJoinClient _joinClient;
private readonly RendezvousCoordinatorOptions _options;
private readonly IRendezvousCoordinatorClock _clock;
private readonly ConnectionTicketValidator _tickets;
private readonly Dictionary<JoinAttemptId, PendingHostAttempt> _attempts = [];
private readonly Dictionary<NetPeer, JoinAttemptId> _acceptedPeers = [];
private readonly Dictionary<JoinAttemptId, DeferredConnectionRequest> _deferredRequests = [];
private readonly Dictionary<JoinAttemptId, DateTimeOffset> _terminalAttempts = [];
private readonly Queue<JoinAttemptId> _attemptSchedule = [];
private readonly List<JoinAttemptId> _cleanupScratch = [];
private HostJoinAttempt[]? _latestSnapshot;
private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue;
private DateTimeOffset _nextTerminalCleanupAt = DateTimeOffset.MinValue;
private int _refreshing;
private int _polling;
private bool _subscriptionsReleased;
private int _disposed;
public RendezvousHostCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
PublishedSession session,
IRendezvousJoinClient joinClient,
RendezvousCoordinatorOptions? options = null)
: this(
manager,
networkEvents,
mediator,
session,
joinClient,
options,
new SystemRendezvousCoordinatorClock(),
null)
{
}
internal RendezvousHostCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
PublishedSession session,
IRendezvousJoinClient joinClient,
RendezvousCoordinatorOptions? options,
IRendezvousCoordinatorClock clock,
ConnectionTicketValidator? tickets)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
_punchEvents = _networkEvents.PunchEvents;
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_session = session ?? throw new ArgumentNullException(nameof(session));
_joinClient = joinClient ?? throw new ArgumentNullException(nameof(joinClient));
_options = (options ?? new RendezvousCoordinatorOptions()).CopyAndValidate();
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_tickets = tickets ?? new ConnectionTicketValidator();
RendezvousManagerGuard.Validate(_manager, _networkEvents);
ValidateInputs();
_networkEvents.RendezvousConnectionRequest += OnConnectionRequest;
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
}
public event EventHandler<RendezvousHostAttemptCompletedEventArgs>? AttemptCompleted;
public RendezvousHostState State { get; private set; } = RendezvousHostState.Active;
public int PendingAttemptCount => _attempts.Count;
internal int DeferredRequestCount => _deferredRequests.Count;
public async Task<RendezvousClientResult<int>> RefreshJoinAttemptsAsync(
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (Interlocked.Exchange(ref _refreshing, 1) != 0)
{
throw new InvalidOperationException("A host invitation refresh is already running.");
}
try
{
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
await _joinClient.BrowseAllForHostAsync(
_session,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
return RendezvousClientResult.Failure<int>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
HostJoinAttempt[] snapshot = result.Value.Select(CopyAttempt).ToArray();
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
Interlocked.Exchange(ref _latestSnapshot, snapshot);
if (Volatile.Read(ref _disposed) != 0)
{
Interlocked.Exchange(ref _latestSnapshot, null);
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
return RendezvousClientResult.Success(snapshot.Length);
}
finally
{
Volatile.Write(ref _refreshing, 0);
}
}
public void Poll()
{
ThrowIfDisposed();
if (State != RendezvousHostState.Active)
{
return;
}
if (Interlocked.Exchange(ref _polling, 1) != 0)
{
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
}
try
{
ApplySnapshots();
if (!_manager.IsRunning)
{
Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped);
return;
}
_manager.NatPunchModule.PollEvents();
_manager.PollEvents();
_manager.NatPunchModule.PollEvents();
if (State != RendezvousHostState.Active)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
if (!_manager.IsRunning)
{
Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped);
return;
}
RefreshPresence(now);
int checks = Math.Min(
_attemptSchedule.Count,
_options.MaximumAttemptChecksPerPoll);
for (int index = 0; index < checks; index++)
{
JoinAttemptId attemptId = _attemptSchedule.Dequeue();
if (!_attempts.TryGetValue(attemptId, out PendingHostAttempt? attempt))
{
continue;
}
if (now >= attempt.Invitation.ExpiresAt)
{
CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut);
continue;
}
if (attempt.State == RendezvousConnectionState.Punching
&& attempt.Retry.IsDue(now))
{
if (attempt.Retry.IsExhausted)
{
CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut);
continue;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Host,
attempt.Invitation.MediationHandle,
attempt.Invitation.HostPunchCapability));
attempt.Retry.RecordRequest();
}
_attemptSchedule.Enqueue(attemptId);
}
if (now >= _nextTerminalCleanupAt)
{
_cleanupScratch.Clear();
foreach (KeyValuePair<JoinAttemptId, DateTimeOffset> terminal in _terminalAttempts)
{
if (terminal.Value <= now)
{
_cleanupScratch.Add(terminal.Key);
}
}
foreach (JoinAttemptId attemptId in _cleanupScratch)
{
_terminalAttempts.Remove(attemptId);
}
_nextTerminalCleanupAt = now + TimeSpan.FromSeconds(1);
}
}
finally
{
Volatile.Write(ref _polling, 0);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
Stop(RendezvousHostState.Disposed, RendezvousConnectionState.Disposed);
Interlocked.Exchange(ref _latestSnapshot, null);
_attemptSchedule.Clear();
_terminalAttempts.Clear();
_cleanupScratch.Clear();
_tickets.Dispose();
}
public override string ToString() =>
$"[RendezvousHostCoordinator {_session.ListingId}; credentials redacted]";
private void ApplySnapshots()
{
HostJoinAttempt[]? latest = Interlocked.Exchange(ref _latestSnapshot, null);
if (latest is null)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
foreach (HostJoinAttempt invitation in latest)
{
if (invitation.AttemptId.Value == Guid.Empty
|| invitation.MediationHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(invitation.HostPunchCapability)
|| !ContractValidation.IsConnectionTicketValid(
invitation.ConnectionTicketDigest))
{
continue;
}
if (invitation.IsCancelled)
{
if (_attempts.ContainsKey(invitation.AttemptId))
{
CompleteAttempt(
invitation.AttemptId,
RendezvousConnectionState.Cancelled);
}
_terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt;
continue;
}
if (invitation.ExpiresAt <= now
|| _attempts.ContainsKey(invitation.AttemptId)
|| _terminalAttempts.ContainsKey(invitation.AttemptId))
{
continue;
}
_attempts.Add(
invitation.AttemptId,
new PendingHostAttempt(
CopyAttempt(invitation),
new RendezvousPunchRetrySchedule(_options, _clock)));
_attemptSchedule.Enqueue(invitation.AttemptId);
}
}
private void RefreshPresence(DateTimeOffset now)
{
if (now < _nextPresenceAt || now >= _session.ExpiresAt)
{
return;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
_session.HostPresenceHandle,
_session.HostPresenceCapability));
_nextPresenceAt = now + TimeSpan.FromSeconds(_session.HostPresenceRefreshAfterSeconds);
}
private void OnNatIntroductionSuccess(
IPEndPoint target,
NatAddressType addressType,
string encodedIntroduction)
{
_ = target;
_ = addressType;
if (!NatIntroductionTokenCodec.TryDecode(
encodedIntroduction,
out NatIntroductionToken? introduction)
|| introduction is null
|| !_attempts.TryGetValue(introduction.AttemptId, out PendingHostAttempt? attempt)
|| !NatIntroductionTokenCodec.MatchesDigest(
introduction.ConnectionTicket,
attempt.Invitation.ConnectionTicketDigest)
|| !_tickets.TryAuthorize(
introduction.AttemptId,
introduction.ConnectionTicket,
Min(
attempt.Invitation.ExpiresAt,
_clock.UtcNow + _options.ConnectionTicketLifetime)))
{
return;
}
attempt.State = RendezvousConnectionState.Connecting;
if (_deferredRequests.Remove(
introduction.AttemptId,
out DeferredConnectionRequest? deferred))
{
AcceptAuthorizedRequest(
introduction.AttemptId,
attempt,
deferred.Request,
deferred.ConnectionTicket);
}
}
private void OnConnectionRequest(ConnectionRequest request)
{
ReadOnlySpan<byte> data = request.Data.GetRemainingBytesSpan();
if (!DirectConnectionRequestCodec.IsRendezvousRequest(data))
{
return;
}
if (!DirectConnectionRequestCodec.TryDecode(data, out DirectConnectionRequest? connection)
|| connection is null
|| !_attempts.TryGetValue(connection.AttemptId, out PendingHostAttempt? attempt)
|| !NatIntroductionTokenCodec.MatchesDigest(
connection.ConnectionTicket,
attempt.Invitation.ConnectionTicketDigest))
{
request.RejectForce([]);
return;
}
if (attempt.State == RendezvousConnectionState.Punching)
{
_deferredRequests[connection.AttemptId] = new(
request,
connection.ConnectionTicket);
return;
}
if (attempt.State != RendezvousConnectionState.Connecting)
{
request.RejectForce([]);
return;
}
AcceptAuthorizedRequest(
connection.AttemptId,
attempt,
request,
connection.ConnectionTicket);
}
private void OnPeerConnected(NetPeer peer)
{
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
{
CompleteAttempt(attemptId, RendezvousConnectionState.Connected, peer);
}
}
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
_ = disconnectInfo;
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
{
CompleteAttempt(attemptId, RendezvousConnectionState.Rejected);
}
}
private void CompleteAttempt(
JoinAttemptId attemptId,
RendezvousConnectionState state,
NetPeer? peer = null)
{
if (!_attempts.Remove(attemptId, out PendingHostAttempt? attempt))
{
return;
}
if (attempt.AcceptedPeer is not null)
{
_acceptedPeers.Remove(attempt.AcceptedPeer);
}
_deferredRequests.Remove(attemptId);
_tickets.Revoke(attemptId);
_terminalAttempts[attemptId] = attempt.Invitation.ExpiresAt;
AttemptCompleted?.Invoke(this, new(attemptId, state, peer));
}
private void Stop(RendezvousHostState hostState, RendezvousConnectionState attemptState)
{
if (State != RendezvousHostState.Active)
{
return;
}
State = hostState;
foreach (JoinAttemptId attemptId in _attempts.Keys.ToArray())
{
CompleteAttempt(attemptId, attemptState);
}
ReleaseSubscriptions();
}
private void ReleaseSubscriptions()
{
if (_subscriptionsReleased)
{
return;
}
_networkEvents.RendezvousConnectionRequest -= OnConnectionRequest;
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true;
}
private void ValidateInputs()
{
if (_mediator.Port is < 1 or > 65_535
|| _session.HostPresenceHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(_session.HostPresenceCapability)
|| _session.HostPresenceRefreshAfterSeconds < 1
|| _session.ExpiresAt <= _clock.UtcNow)
{
throw new ArgumentException("The host traversal inputs are invalid.");
}
}
private static HostJoinAttempt CopyAttempt(HostJoinAttempt attempt) => new()
{
AttemptId = attempt.AttemptId,
MediationHandle = attempt.MediationHandle,
HostPunchCapability = attempt.HostPunchCapability,
ConnectionTicketDigest = attempt.ConnectionTicketDigest,
IsCancelled = attempt.IsCancelled,
ExpiresAt = attempt.ExpiresAt,
};
private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) =>
left <= right ? left : right;
private void AcceptAuthorizedRequest(
JoinAttemptId attemptId,
PendingHostAttempt attempt,
ConnectionRequest request,
string connectionTicket)
{
ConnectionTicketConsumptionResult consumption = _tickets.Consume(
attemptId,
connectionTicket);
if (consumption != ConnectionTicketConsumptionResult.Accepted)
{
request.RejectForce([]);
return;
}
NetPeer peer = request.Accept();
attempt.AcceptedPeer = peer;
_acceptedPeers[peer] = attemptId;
}
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
}
private sealed class PendingHostAttempt(
HostJoinAttempt invitation,
RendezvousPunchRetrySchedule retry)
{
internal HostJoinAttempt Invitation { get; } = invitation;
internal RendezvousPunchRetrySchedule Retry { get; } = retry;
internal RendezvousConnectionState State { get; set; } = RendezvousConnectionState.Punching;
internal NetPeer? AcceptedPeer { get; set; }
}
private sealed class DeferredConnectionRequest(
ConnectionRequest request,
string connectionTicket)
{
internal ConnectionRequest Request { get; } = request;
internal string ConnectionTicket { get; } = connectionTicket;
}
}
@@ -0,0 +1,110 @@
using System.Net;
using System.Net.Sockets;
using LiteNetLib;
using LiteNetLib.Utils;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousNetListener : INetEventListener
{
private NetManager? _manager;
public EventBasedNetListener GameplayEvents { get; } = new();
public EventBasedNatPunchListener PunchEvents { get; } = new();
public NetManager CreateManager()
{
if (_manager is not null)
{
throw new InvalidOperationException(
"This Rendezvous listener is already bound to a LiteNetLib manager.");
}
NetManager manager = new(this) { NatPunchEnabled = true };
manager.NatPunchModule.Init(PunchEvents);
_manager = manager;
return manager;
}
internal event Action<NetPeer>? RendezvousPeerConnected;
internal event Action<NetPeer, DisconnectInfo>? RendezvousPeerDisconnected;
internal event Action<ConnectionRequest>? RendezvousConnectionRequest;
internal void ValidateManager(NetManager manager)
{
if (!ReferenceEquals(_manager, manager))
{
throw new InvalidOperationException(
"The LiteNetLib manager must be created by this Rendezvous listener.");
}
}
public void OnPeerConnected(NetPeer peer)
{
RendezvousPeerConnected?.Invoke(peer);
((INetEventListener)GameplayEvents).OnPeerConnected(peer);
}
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
RendezvousPeerDisconnected?.Invoke(peer, disconnectInfo);
((INetEventListener)GameplayEvents).OnPeerDisconnected(peer, disconnectInfo);
}
public void OnNetworkError(IPEndPoint endPoint, SocketError socketError) =>
((INetEventListener)GameplayEvents).OnNetworkError(endPoint, socketError);
public void OnNetworkReceive(
NetPeer peer,
NetPacketReader reader,
byte channelNumber,
DeliveryMethod deliveryMethod) =>
((INetEventListener)GameplayEvents).OnNetworkReceive(
peer,
reader,
channelNumber,
deliveryMethod);
public void OnNetworkReceiveUnconnected(
IPEndPoint remoteEndPoint,
NetPacketReader reader,
UnconnectedMessageType messageType) =>
((INetEventListener)GameplayEvents).OnNetworkReceiveUnconnected(
remoteEndPoint,
reader,
messageType);
public void OnNetworkLatencyUpdate(NetPeer peer, int latency) =>
((INetEventListener)GameplayEvents).OnNetworkLatencyUpdate(peer, latency);
public void OnConnectionRequest(ConnectionRequest request)
{
int position = request.Data.Position;
bool isRendezvous = DirectConnectionRequestCodec.IsRendezvousRequest(
request.Data.GetRemainingBytesSpan());
request.Data.SetPosition(position);
if (!isRendezvous)
{
((INetEventListener)GameplayEvents).OnConnectionRequest(request);
return;
}
Action<ConnectionRequest>? handler = RendezvousConnectionRequest;
if (handler is null)
{
request.RejectForce([]);
return;
}
handler(request);
}
public void OnMessageDelivered(NetPeer peer, object userData) =>
((INetEventListener)GameplayEvents).OnMessageDelivered(peer, userData);
public void OnNtpResponse(NtpPacket packet) =>
((INetEventListener)GameplayEvents).OnNtpResponse(packet);
public void OnPeerAddressChanged(NetPeer peer, IPEndPoint previousAddress) =>
((INetEventListener)GameplayEvents).OnPeerAddressChanged(peer, previousAddress);
}
@@ -23,6 +23,8 @@ public static class ContractLimits
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
public const int UdpCapabilityMaxCharacters = 192;
public const int ConnectionTicketMaxCharacters = 192;
public const int DerivedCredentialCharacters = 43;
public const int NatPunchRequestTokenCharacters = 192;
public const int LiteNetLibNatTokenMaxCharacters = 256;
public const int SessionCapacityMaxPlayers = 10_000;
}
@@ -37,6 +37,9 @@ public sealed class CreateJoinAttemptResponse
[JsonRequired]
public string ClientPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public string ConnectionTicketDigest { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
public NetworkEndpoint? DedicatedFallback { get; set; }
@@ -53,6 +56,12 @@ public sealed class HostJoinAttempt
[JsonRequired]
public string HostPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public string ConnectionTicketDigest { get; set; } = string.Empty;
[JsonRequired]
public bool IsCancelled { get; set; }
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
@@ -0,0 +1,149 @@
using System.Security.Cryptography;
using System.Text;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class NatIntroductionToken
{
public JoinAttemptId AttemptId { get; set; }
public string ConnectionTicket { get; set; } = string.Empty;
public override string ToString() =>
$"[NatIntroductionToken {AttemptId}; ticket redacted]";
}
public static class NatIntroductionTokenCodec
{
public const int EncodedLength = ContractLimits.DerivedCredentialCharacters;
private const int DecodedLength = 32;
private const int AttemptIdLength = 16;
private const int AuthenticatorLength = DecodedLength - AttemptIdLength;
public static string Encode(JoinAttemptId attemptId, string derivedAuthenticator)
{
if (attemptId.Value == Guid.Empty
|| !ContractValidation.IsConnectionTicketValid(derivedAuthenticator)
|| !TryDecodeBase64Url(derivedAuthenticator, out byte[]? authenticator)
|| authenticator.Length != DecodedLength)
{
throw new ArgumentException("The NAT introduction token fields are invalid.");
}
byte[] payload = new byte[DecodedLength];
try
{
if (!attemptId.Value.TryWriteBytes(payload.AsSpan(0, AttemptIdLength)))
{
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
}
authenticator.AsSpan(0, AuthenticatorLength).CopyTo(payload.AsSpan(AttemptIdLength));
return EncodeBase64Url(payload);
}
finally
{
CryptographicOperations.ZeroMemory(authenticator);
CryptographicOperations.ZeroMemory(payload);
}
}
public static bool TryDecode(string? encoded, out NatIntroductionToken? token)
{
token = null;
if (!ContractValidation.IsConnectionTicketValid(encoded)
|| !TryDecodeBase64Url(encoded!, out byte[]? payload)
|| payload.Length != DecodedLength)
{
return false;
}
try
{
Guid attemptId = new(payload.AsSpan(0, AttemptIdLength));
if (attemptId == Guid.Empty)
{
return false;
}
token = new NatIntroductionToken
{
AttemptId = new JoinAttemptId(attemptId),
ConnectionTicket = encoded!,
};
return true;
}
finally
{
CryptographicOperations.ZeroMemory(payload);
}
}
public static string ComputeDigest(string connectionTicket)
{
if (!ContractValidation.IsConnectionTicketValid(connectionTicket))
{
throw new ArgumentException("The connection ticket is invalid.", nameof(connectionTicket));
}
byte[] encoded = Encoding.ASCII.GetBytes(connectionTicket);
byte[] digest;
using (SHA256 sha256 = SHA256.Create())
{
digest = sha256.ComputeHash(encoded);
}
CryptographicOperations.ZeroMemory(encoded);
try
{
return EncodeBase64Url(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public static bool MatchesDigest(string? connectionTicket, string? expectedDigest)
{
if (!ContractValidation.IsConnectionTicketValid(connectionTicket)
|| !ContractValidation.IsConnectionTicketValid(expectedDigest))
{
return false;
}
byte[] actual = Encoding.ASCII.GetBytes(ComputeDigest(connectionTicket!));
byte[] expected = Encoding.ASCII.GetBytes(expectedDigest!);
try
{
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
finally
{
CryptographicOperations.ZeroMemory(actual);
CryptographicOperations.ZeroMemory(expected);
}
}
private static bool TryDecodeBase64Url(string? encoded, out byte[] bytes)
{
bytes = [];
if (encoded is null || encoded.Length != EncodedLength)
{
return false;
}
try
{
bytes = Convert.FromBase64String(
encoded.Replace('-', '+').Replace('_', '/') + "=");
return true;
}
catch (FormatException)
{
return false;
}
}
private static string EncodeBase64Url(byte[] value) =>
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -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;
}
}
}
@@ -1,5 +1,4 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts;
@@ -10,12 +9,10 @@ namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed class SessionBrowserCursorCodec : IDisposable
{
private const string Prefix = "rvc1";
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
private bool _disposed;
private readonly EphemeralCursorProtector _protector = new();
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
{
ObjectDisposedException.ThrowIf(_disposed, this);
BrowserCursorPayload payload = new()
{
GameId = query.Scope.GameId.Value,
@@ -26,19 +23,14 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
AfterListingId = after.ToString(),
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
};
string encoded = EncodeBytes(JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string content = $"{Prefix}.{encoded}";
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
try
{
string cursor = $"{content}.{EncodeBytes(signature)}";
return ContractValidation.IsCursorValid(cursor)
? cursor
: throw new InvalidOperationException("The browser cursor exceeds its contract limit.");
return _protector.Protect(Prefix, encoded);
}
finally
{
CryptographicOperations.ZeroMemory(signature);
CryptographicOperations.ZeroMemory(encoded);
}
}
@@ -57,31 +49,7 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
return true;
}
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);
if (!validSignature || !TryDecodeBytes(segments[1], out byte[] encodedPayload))
if (!_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
{
return false;
}
@@ -118,64 +86,30 @@ internal sealed class SessionBrowserCursorCodec : IDisposable
return true;
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
CryptographicOperations.ZeroMemory(_key);
}
}
public void Dispose() => _protector.Dispose();
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors 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;
}
}
}
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; }
}
@@ -1,5 +1,7 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
@@ -67,7 +69,9 @@ internal static class ContractEndpoints
.WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>()
.Produces<ApiError>(NotImplementedStatus)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseHostJoinAttempts");
RouteGroupBuilder attempts = endpoints
@@ -76,12 +80,22 @@ internal static class ContractEndpoints
attempts.MapPost("/", CreateJoinAttempt)
.Accepts<CreateJoinAttemptRequest>("application/json")
.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");
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)
.Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(NotImplementedStatus)
.Produces<ApiError>(StatusCodes.Status501NotImplemented)
.WithName("ReportConnectionOutcome");
return endpoints;
@@ -268,10 +282,55 @@ internal static class ContractEndpoints
[FromQuery] int contractVersion,
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
[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) =>
NotImplemented();
private static IResult CreateJoinAttempt(
[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(
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,342 @@
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);
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
"join-attempt-id",
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
string connectionTicket = NatIntroductionTokenCodec.Encode(
attemptId,
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.");
}
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,
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
CreateConnectionTicket(persisted)),
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 || attempt.IsCancelled)
{
return new(RendezvousErrorCode.Conflict);
}
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
{
return new(RendezvousErrorCode.Expired);
}
string ticket = CreateConnectionTicket(attempt);
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,
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
CreateConnectionTicket(attempt)),
IsCancelled = attempt.IsCancelled,
ExpiresAt = attempt.ExpiresAt,
};
}
private string CreateConnectionTicket(StoredJoinAttempt attempt) =>
NatIntroductionTokenCodec.Encode(
attempt.AttemptId,
Derive(
"connection-ticket",
attempt.ClientSubject,
attempt.IdempotencyKey,
attempt.RequestFingerprint,
attempt.CapabilityDerivationSalt));
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('/', '_');
}
+12 -2
View File
@@ -2,6 +2,7 @@ using System.Net;
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;
@@ -122,6 +123,8 @@ else
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));
}
@@ -130,12 +133,19 @@ builder.Services
.BindConfiguration(UdpMediatorOptions.SectionName)
.ValidateDataAnnotations()
.Validate(
options => IPAddress.TryParse(options.ListenAddress, out _),
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.")
options => IPAddress.TryParse(options.ListenAddress, out IPAddress? 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();
builder.Services.AddSingleton<UdpMediatorService>();
if (!isOpenApiGeneration)
{
builder.Services.AddSingleton<NatMediationProcessor>();
builder.Services.AddHostedService(static services =>
services.GetRequiredService<UdpMediatorService>());
}
@@ -122,7 +122,7 @@ internal sealed class PrincipalCredentialService
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]}";
@@ -441,7 +441,14 @@ internal static class Base64Url
try
{
bytes = Convert.FromBase64String(padded);
return true;
if (string.Equals(Encode(bytes), value, StringComparison.Ordinal))
{
return true;
}
CryptographicOperations.ZeroMemory(bytes);
bytes = [];
return false;
}
catch (FormatException)
{
@@ -20,6 +20,7 @@ internal interface ISessionCapabilityService
string idempotencyKey,
string requestFingerprint,
string derivationSalt);
string DeriveOpaqueIdentifier(string purpose, string value);
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)
{
fingerprint = default;
@@ -127,7 +127,7 @@ internal sealed class SessionLeaseService(
ownerLimit), cancellationToken);
if (!created.Succeeded || created.Value is null)
{
return new(MapStore(created.Code));
return new(created.Code.ToContractError());
}
ListingDefinition persisted = created.Value.Definition;
@@ -205,7 +205,7 @@ internal sealed class SessionLeaseService(
ExpiresAt = renewed.Value.LeaseExpiresAt,
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
})
: new(MapStore(renewed.Code));
: new(renewed.Code.ToContractError());
}
public SessionServiceResult<bool> Update(
@@ -253,7 +253,7 @@ internal sealed class SessionLeaseService(
request.Metadata), cancellationToken);
return updated.Succeeded
? new(RendezvousErrorCode.None, true)
: new(MapStore(updated.Code));
: new(updated.Code.ToContractError());
}
public SessionServiceResult<bool> Delete(
@@ -291,7 +291,7 @@ internal sealed class SessionLeaseService(
publisher.Subject), cancellationToken);
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
? new(RendezvousErrorCode.None, true)
: new(MapStore(deleted.Code));
: new(deleted.Code.ToContractError());
}
private RendezvousErrorCode GetAuthorizedListing(
@@ -315,7 +315,7 @@ internal sealed class SessionLeaseService(
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
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)
@@ -403,18 +403,6 @@ internal sealed class SessionLeaseService(
_ => 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)
{
RegisterSessionRequest canonical = new()
@@ -35,6 +35,7 @@ internal sealed record EphemeralStoreOptions
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
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 IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
@@ -50,9 +51,17 @@ internal sealed record EphemeralStoreOptions
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime));
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
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)
{
throw new ArgumentOutOfRangeException(
@@ -246,7 +255,11 @@ internal sealed record CreateJoinAttemptCommand
public required uint ProtocolVersion { get; init; }
public required SecretFingerprint HostCapabilityFingerprint { 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 override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
}
internal sealed record AttemptEndpointBinding(
@@ -261,12 +274,29 @@ internal sealed record StoredJoinAttempt
public required SessionListingId ListingId { get; init; }
public required string ClientSubject { 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 ConnectionTicketExpiresAt { get; init; }
public AttemptEndpointBinding? HostEndpoint { get; init; }
public AttemptEndpointBinding? ClientEndpoint { get; init; }
public required bool IntroductionConsumed { get; init; }
public required bool ConnectionTicketConsumed { get; init; }
public required bool IsCancelled { 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(
MediationHandle Handle,
AttemptPeerRole Role,
@@ -275,9 +305,20 @@ internal sealed record BindAttemptEndpointCommand(
ObservedEndpoint? LocalEndpoint);
internal sealed record IntroductionEndpoints(
JoinAttemptId AttemptId,
StoredJoinAttempt Attempt,
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(
string Namespace,
@@ -316,8 +357,11 @@ internal interface IEphemeralRendezvousStore
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand 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<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> RevokeListing(SessionListingId listingId, 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
{
private static readonly TimeSpan UdpMaintenanceInterval = TimeSpan.FromSeconds(1);
private readonly object _gate = new();
private readonly EphemeralStoreOptions _options;
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> _revocations = new(StringComparer.Ordinal);
private TimeSpan? _drainDeadline;
private TimeSpan _nextUdpMaintenance;
private long _maintenanceSweepCount;
private bool _available = true;
public InMemoryEphemeralRendezvousStore(
@@ -38,6 +41,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
}
public Guid InstanceId { get; }
internal long MaintenanceSweepCount => Interlocked.Read(ref _maintenanceSweepCount);
public bool IsAvailable
{
@@ -270,6 +274,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
if (!_presenceHandles.TryGetValue(command.Handle, out SessionListingId listingId)
|| !_listings.TryGetValue(listingId, out ListingEntry? entry)
|| entry.LeaseDeadline <= now
|| entry.Definition.HostPresenceFingerprint != command.CapabilityFingerprint)
{
return new(StoreResultCode.NotFound);
@@ -285,7 +290,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
command.LocalEndpoint,
now + _options.PresenceLifetime);
return new(StoreResultCode.Success, Snapshot(entry));
}, cancellationToken);
}, cancellationToken, eagerCleanup: false);
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
VisibleListingQuery query,
@@ -390,9 +395,74 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.Success, Snapshot(attempt));
}, 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 || entry.IsCancelled)
&& (!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.IsCancelled)
{
return new(StoreResultCode.Success, true, true);
}
attempt.IsCancelled = true;
return new(StoreResultCode.Success, true);
}, cancellationToken);
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
BindAttemptEndpointCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(_ =>
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(now =>
{
ArgumentNullException.ThrowIfNull(command);
if (command.Handle.Value == Guid.Empty
@@ -409,7 +479,14 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
}
if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId)
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt))
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|| attempt.Deadline <= now
|| attempt.IsCancelled)
{
return new(StoreResultCode.NotFound);
}
if (!HasFreshHostPresence(attempt, now))
{
return new(StoreResultCode.NotFound);
}
@@ -443,11 +520,11 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
}
return new(StoreResultCode.Success, Snapshot(attempt));
}, cancellationToken);
}, cancellationToken, eagerCleanup: false);
public StoreResult<IntroductionEndpoints> ConsumeIntroduction(
MediationHandle handle,
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(_ =>
CancellationToken cancellationToken = default) => Atomic<IntroductionEndpoints>(now =>
{
if (!_available)
{
@@ -455,7 +532,14 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
}
if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId)
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt))
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|| attempt.Deadline <= now
|| attempt.IsCancelled)
{
return new(StoreResultCode.NotFound);
}
if (!HasFreshHostPresence(attempt, now))
{
return new(StoreResultCode.NotFound);
}
@@ -471,10 +555,60 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
}
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(
attempt.Command.AttemptId,
Snapshot(attempt),
attempt.HostEndpoint,
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.IsCancelled)
{
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);
public StoreResult<bool> ConsumeReplay(
@@ -582,18 +716,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();
lock (_gate)
{
cancellationToken.ThrowIfCancellationRequested();
TimeSpan now = _monotonicClock.Elapsed;
Cleanup(now);
// 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);
_nextUdpMaintenance = now + UdpMaintenanceInterval;
}
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)
{
if (!_available)
@@ -613,6 +767,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private void Cleanup(TimeSpan now)
{
_maintenanceSweepCount++;
if (_drainDeadline is TimeSpan drainDeadline && now >= drainDeadline)
{
ClearActiveState();
@@ -714,10 +869,19 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
ListingId = entry.Command.ListingId,
ClientSubject = entry.Command.ClientSubject,
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,
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
HostEndpoint = entry.HostEndpoint,
ClientEndpoint = entry.ClientEndpoint,
IntroductionConsumed = entry.IntroductionConsumed,
ConnectionTicketConsumed = entry.ConnectionTicketConsumed,
IsCancelled = entry.IsCancelled,
};
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
@@ -789,6 +953,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| command.ProtocolVersion == 0
|| !command.HostCapabilityFingerprint.IsValid
|| !command.ClientCapabilityFingerprint.IsValid
|| !command.ConnectionTicketFingerprint.IsValid
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|| command.ScopeAttemptLimit <= 0)
{
throw new ArgumentException("Join attempt invariants are invalid.", nameof(command));
@@ -853,11 +1019,16 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
public CreateJoinAttemptCommand Command { get; } = command;
public SecretFingerprint HostCapabilityFingerprint { get; } = command.HostCapabilityFingerprint;
public SecretFingerprint ClientCapabilityFingerprint { get; } = command.ClientCapabilityFingerprint;
public SecretFingerprint ConnectionTicketFingerprint { get; } = command.ConnectionTicketFingerprint;
public TimeSpan Deadline { get; } = deadline;
public DateTimeOffset WallExpiresAt { get; } = wallExpiresAt;
public TimeSpan? TicketDeadline { get; set; }
public DateTimeOffset? TicketWallExpiresAt { get; set; }
public AttemptEndpointBinding? HostEndpoint { get; set; }
public AttemptEndpointBinding? ClientEndpoint { get; set; }
public bool IntroductionConsumed { get; set; }
public bool ConnectionTicketConsumed { get; set; }
public bool IsCancelled { get; set; }
}
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,328 @@
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 IntroductionToken)
{
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));
return NatMediationResult.Introduced;
}
catch (Exception exception) when (exception is SocketException
or InvalidOperationException
or ArgumentException)
{
return NatMediationResult.Rejected;
}
}
private static NatIntroductionPlan CreatePlan(
IntroductionEndpoints endpoints,
ConnectionTicketGrant ticket)
{
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,
ticket.Ticket);
}
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]
public string ListenAddress { get; set; } = "0.0.0.0";
public string? Ipv6ListenAddress { get; set; }
/// <summary>
/// Gets or sets the UDP port. Zero requests an ephemeral port for tests.
/// </summary>
[Range(0, 65_535)]
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.Sockets;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using LiteNetLib;
using LiteNetLib.Layers;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Transport;
/// <summary>
/// Owns the cancellable UDP socket used by the future NAT mediator.
/// </summary>
internal sealed partial class UdpMediatorService : BackgroundService
{
private readonly ILogger<UdpMediatorService> _logger;
private readonly UdpMediatorOptions _options;
private readonly IEphemeralRendezvousStore _store;
private readonly ISessionCapabilityService _capabilities;
private UdpClient? _udpClient;
private readonly NatMediationProcessor _processor;
private LiteNetManager? _manager;
private LiteNetIntroductionSink? _introductionSink;
/// <summary>
/// Initializes a new UDP mediator service.
/// </summary>
public UdpMediatorService(
IOptions<UdpMediatorOptions> options,
ILogger<UdpMediatorService> logger,
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities)
NatMediationProcessor processor)
{
_options = options.Value;
_logger = logger;
_store = store;
_capabilities = capabilities;
_processor = processor;
}
/// <summary>
/// Gets the bound endpoint after startup completes.
/// </summary>
public IPEndPoint? LocalEndpoint { get; private set; }
public IPEndPoint? LocalIpv6Endpoint { get; private set; }
/// <inheritdoc />
public override Task StartAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_udpClient is not null)
if (_manager is not null)
{
throw new InvalidOperationException("The UDP mediator is already running.");
}
IPAddress listenAddress = IPAddress.Parse(_options.ListenAddress);
UdpClient udpClient = new(new IPEndPoint(listenAddress, _options.Port));
_udpClient = udpClient;
IPEndPoint localEndpoint =
(IPEndPoint?)udpClient.Client.LocalEndPoint
?? throw new InvalidOperationException("The UDP socket did not expose its bound endpoint.");
LocalEndpoint = localEndpoint;
if (listenAddress.AddressFamily != AddressFamily.InterNetwork)
{
throw new InvalidOperationException("The required UDP listen address must be IPv4.");
}
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);
}
/// <inheritdoc />
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken).ConfigureAwait(false);
_udpClient?.Dispose();
_udpClient = null;
LocalEndpoint = null;
StopManager();
LogMediatorStopped(_logger);
}
/// <inheritdoc />
public override void Dispose()
{
_udpClient?.Dispose();
_udpClient = null;
LocalEndpoint = null;
StopManager();
base.Dispose();
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
UdpClient udpClient = _udpClient
LiteNetManager manager = _manager
?? throw new InvalidOperationException("The UDP mediator socket was not initialized.");
long previous = Stopwatch.GetTimestamp();
try
{
while (!stoppingToken.IsCancellationRequested)
{
UdpReceiveResult received = await udpClient
.ReceiveAsync(stoppingToken)
.ConfigureAwait(false);
ProcessDatagram(received.Buffer, received.RemoteEndPoint, stoppingToken);
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
manager.PollEvents();
manager.NatPunchModule.PollEvents();
long current = Stopwatch.GetTimestamp();
manager.ManualUpdate((float)Stopwatch.GetElapsedTime(previous, current).TotalMilliseconds);
previous = current;
await Task.Delay(_options.PollIntervalMilliseconds, stoppingToken).ConfigureAwait(false);
}
}
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
{
LocalEndpoint = null;
LocalIpv6Endpoint = null;
}
}
internal UdpPresenceProcessingResult ProcessDatagram(
ReadOnlySpan<byte> encoded,
IPEndPoint observedSource,
CancellationToken cancellationToken = default)
private void StopManager()
{
ArgumentNullException.ThrowIfNull(observedSource);
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|| datagram is null
|| !_capabilities.TryFingerprint(datagram.Capability, out SecretFingerprint fingerprint))
{
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;
LiteNetManager? manager = Interlocked.Exchange(ref _manager, null);
_introductionSink = null;
LocalEndpoint = null;
LocalIpv6Endpoint = null;
manager?.Stop();
}
[LoggerMessage(
@@ -172,12 +147,62 @@ internal sealed partial class UdpMediatorService : BackgroundService
Level = LogLevel.Information,
Message = "UDP mediator stopped")]
private static partial void LogMediatorStopped(ILogger logger);
}
internal enum UdpPresenceProcessingResult
{
Dropped = 0,
HostPresenceAccepted = 1,
HostPresenceRejected = 2,
ClientPresenceDeferred = 3,
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
{
public void Introduce(NatIntroductionPlan plan) => module.NatIntroduce(
plan.HostLocal,
plan.HostPublic,
plan.ClientLocal,
plan.ClientPublic,
plan.IntroductionToken);
}
private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
{
private INatIntroductionSink? _sink;
public void Attach(INatIntroductionSink sink) => _sink = sink;
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": {
"Udp": {
"ListenAddress": "0.0.0.0",
"Port": 9050
"Port": 9050,
"MaxDatagramsPerPoll": 256,
"PollIntervalMilliseconds": 2
}
},
"Logging": {
@@ -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,616 @@
using System.Net;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousCoordinatorBehaviorTests
{
[Fact]
public void NatIntroductionAloneDoesNotCompleteTheClientAttempt()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
int completions = 0;
harness.Coordinator.Completed += (_, _) => completions++;
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
harness.IntroductionToken);
Assert.Equal(RendezvousConnectionState.Connecting, harness.Coordinator.State);
Assert.False(harness.Coordinator.IsCompleted);
Assert.Equal(0, completions);
}
[Fact]
public void ClientRejectsASyntacticallyValidIntroductionWithTheWrongTicket()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
string forged = NatIntroductionTokenCodec.Encode(
harness.AttemptId,
Credential('F'));
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
forged);
Assert.Equal(RendezvousConnectionState.Punching, harness.Coordinator.State);
Assert.False(harness.Coordinator.IsCompleted);
}
[Fact]
public void CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
List<RendezvousConnectionState> completions = [];
harness.Coordinator.Completed += (_, completion) => completions.Add(completion.State);
harness.Coordinator.Cancel();
harness.Coordinator.Poll();
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
harness.IntroductionToken);
harness.Coordinator.Poll();
Assert.Equal(RendezvousConnectionState.Cancelled, harness.Coordinator.State);
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
}
[Fact]
public void ExhaustedPunchBudgetTimesOutExactlyOnceUnderAFakeClock()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
{
MaximumPunchRequests = 1,
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(10),
JitterRatio = 0,
});
int completions = 0;
harness.Coordinator.Completed += (_, _) => completions++;
harness.Coordinator.Poll();
clock.Advance(TimeSpan.FromMilliseconds(10));
harness.Coordinator.Poll();
clock.Advance(TimeSpan.FromMinutes(1));
harness.Coordinator.Poll();
Assert.Equal(RendezvousConnectionState.TimedOut, harness.Coordinator.State);
Assert.Equal(1, completions);
}
[Fact]
public void ManagerShutdownAndDisposalEachReleaseTheirTerminalPathOnce()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness stopped = new(clock);
int stoppedCompletions = 0;
stopped.Coordinator.Completed += (_, _) => stoppedCompletions++;
stopped.Manager.Stop();
stopped.Coordinator.Poll();
stopped.Coordinator.Poll();
Assert.Equal(RendezvousConnectionState.ManagerStopped, stopped.Coordinator.State);
Assert.Equal(1, stoppedCompletions);
using ClientHarness disposed = new(clock);
int disposedCompletions = 0;
disposed.Coordinator.Completed += (_, _) => disposedCompletions++;
disposed.Coordinator.Dispose();
disposed.Coordinator.Dispose();
((INatPunchListener)disposed.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
disposed.IntroductionToken);
Assert.Equal(RendezvousConnectionState.Disposed, disposed.Coordinator.State);
Assert.Equal(1, disposedCompletions);
Assert.Throws<ObjectDisposedException>(() => disposed.Coordinator.Poll());
}
[Fact]
public async Task DirectConnectionRejectionProducesOneTerminalTransition()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness client = new(clock);
EventBasedNetListener rejectingEvents = new();
rejectingEvents.ConnectionRequestEvent += request => request.Reject();
NetManager rejectingHost = new(rejectingEvents);
try
{
Assert.True(rejectingHost.Start(0));
int completions = 0;
client.Coordinator.Completed += (_, _) => completions++;
((INatPunchListener)client.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, rejectingHost.LocalPort),
NatAddressType.External,
client.IntroductionToken);
DateTime deadline = DateTime.UtcNow.AddSeconds(2);
while (!client.Coordinator.IsCompleted && DateTime.UtcNow < deadline)
{
rejectingHost.PollEvents();
client.Coordinator.Poll();
await Task.Delay(2);
}
Assert.Equal(RendezvousConnectionState.Rejected, client.Coordinator.State);
Assert.Equal(1, completions);
client.Coordinator.Poll();
Assert.Equal(1, completions);
}
finally
{
rejectingHost.Stop();
}
}
[Fact]
public void UnsynchronizedLiteNetCallbacksAreRejectedAtConstruction()
{
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
manager.UnsyncedEvents = true;
try
{
Assert.True(manager.Start(0));
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 9_050),
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
}
finally
{
manager.Stop();
}
}
[Fact]
public void CoordinatorRejectsAManagerCreatedByAnotherRoutingListener()
{
RendezvousNetListener managerEvents = new();
NetManager manager = managerEvents.CreateManager();
RendezvousNetListener mismatchedEvents = new();
try
{
Assert.True(manager.Start(0));
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
manager,
mismatchedEvents,
new IPEndPoint(IPAddress.Loopback, 9_050),
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task PublishedSessionTimingRemainsValidDuringConcurrentRenewalReads()
{
DateTimeOffset firstExpiry = new(2030, 1, 1, 0, 1, 0, TimeSpan.Zero);
DateTimeOffset secondExpiry = new(2030, 1, 1, 0, 2, 0, TimeSpan.Zero);
PublishedSession session = CreateSession(firstExpiry);
Task writer = Task.Run(() =>
{
for (int index = 0; index < 10_000; index++)
{
session.ExpiresAt = index % 2 == 0 ? firstExpiry : secondExpiry;
session.LeaseRenewAfterSeconds = index % 2 == 0 ? 10 : 20;
}
});
Task reader = Task.Run(() =>
{
for (int index = 0; index < 10_000; index++)
{
DateTimeOffset expiry = session.ExpiresAt;
int renewAfter = session.LeaseRenewAfterSeconds;
Assert.True(expiry == firstExpiry || expiry == secondExpiry);
Assert.True(renewAfter is 10 or 20 or 30);
}
});
await Task.WhenAll(writer, reader);
}
[Fact]
public async Task HostDoesNotMistakeAnIntroducedAttemptForCancellationWhenItLeavesPolling()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
NetManager manager = networkEvents.CreateManager();
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000111"));
HostJoinAttempt invitation = new()
{
AttemptId = attemptId,
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000112")),
HostPunchCapability = Credential('H'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(attemptId, Credential('T'))),
ExpiresAt = clock.UtcNow + TimeSpan.FromSeconds(30),
};
MutableJoinClient joins = new([invitation]);
using ConnectionTicketValidator tickets = new(16, clock);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
tickets);
int completions = 0;
host.AttemptCompleted += (_, _) => completions++;
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
Assert.Equal(1, host.PendingAttemptCount);
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_003),
NatAddressType.External,
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')));
joins.Attempts = [];
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
Assert.Equal(1, host.PendingAttemptCount);
Assert.Equal(0, completions);
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task HostCancellationSnapshotRevokesAnAuthorizedTicketAndCompletesOnce()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
NetManager manager = networkEvents.CreateManager();
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000131"));
string ticket = NatIntroductionTokenCodec.Encode(attemptId, Credential('T'));
HostJoinAttempt invitation = CreateHostAttempt(
attemptId,
new(Guid.Parse("00000000-0000-0000-0000-000000000132")),
ticket,
clock.UtcNow + TimeSpan.FromSeconds(30));
MutableJoinClient joins = new([invitation]);
using ConnectionTicketValidator tickets = new(16, clock);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
tickets);
List<RendezvousConnectionState> completions = [];
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_003),
NatAddressType.External,
ticket);
invitation.IsCancelled = true;
joins.Attempts = [invitation];
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
host.Poll();
Assert.Equal(0, host.PendingAttemptCount);
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
Assert.Equal(
ConnectionTicketConsumptionResult.Revoked,
tickets.Consume(attemptId, ticket));
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task HostAppliesOnlyTheLatestUnpolledSnapshot()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000141"));
HostJoinAttempt invitation = CreateHostAttempt(
attemptId,
new(Guid.Parse("00000000-0000-0000-0000-000000000142")),
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')),
clock.UtcNow + TimeSpan.FromSeconds(30));
MutableJoinClient joins = new([invitation]);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
null);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
joins.Attempts = [];
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
Assert.Equal(0, host.PendingAttemptCount);
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task DisposingHostDuringRefreshDropsTheLateSnapshot()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
BlockingJoinClient joins = new();
try
{
Assert.True(manager.Start(0));
RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
null);
Task<RendezvousClientResult<int>> refresh = host.RefreshJoinAttemptsAsync();
await joins.WaitUntilCalled;
host.Dispose();
joins.Complete([]);
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await refresh);
Assert.Throws<ObjectDisposedException>(() => host.Poll());
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task HostBoundsAttemptExpiryChecksPerPoll()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
JoinAttemptId firstId = new(Guid.Parse("00000000-0000-0000-0000-000000000151"));
JoinAttemptId secondId = new(Guid.Parse("00000000-0000-0000-0000-000000000152"));
DateTimeOffset expiresAt = clock.UtcNow + TimeSpan.FromSeconds(1);
MutableJoinClient joins = new([
CreateHostAttempt(
firstId,
new(Guid.Parse("00000000-0000-0000-0000-000000000153")),
NatIntroductionTokenCodec.Encode(firstId, Credential('T')),
expiresAt),
CreateHostAttempt(
secondId,
new(Guid.Parse("00000000-0000-0000-0000-000000000154")),
NatIntroductionTokenCodec.Encode(secondId, Credential('U')),
expiresAt),
]);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions
{
MaximumAttemptChecksPerPoll = 1,
JitterRatio = 0,
},
clock,
null);
List<RendezvousConnectionState> completions = [];
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
clock.Advance(TimeSpan.FromSeconds(2));
host.Poll();
Assert.Equal(1, host.PendingAttemptCount);
Assert.Equal([RendezvousConnectionState.TimedOut], completions);
host.Poll();
Assert.Equal(0, host.PendingAttemptCount);
Assert.Equal(
[RendezvousConnectionState.TimedOut, RendezvousConnectionState.TimedOut],
completions);
}
finally
{
manager.Stop();
}
}
private static CreateJoinAttemptResponse CreateAttempt(DateTimeOffset expiresAt) => new()
{
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000101")),
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000102")),
ClientPunchCapability = Credential('C'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000101")),
Credential('T'))),
ExpiresAt = expiresAt,
};
private static PublishedSession CreateSession(DateTimeOffset expiresAt) => new(new RegisterSessionResponse
{
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000121")),
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000122")),
LeaseToken = "lease-token",
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000123")),
HostPresenceCapability = Credential('P'),
ExpiresAt = expiresAt,
LeaseRenewAfterSeconds = 30,
HostPresenceRefreshAfterSeconds = 10,
});
private static HostJoinAttempt CreateHostAttempt(
JoinAttemptId attemptId,
MediationHandle mediationHandle,
string connectionTicket,
DateTimeOffset expiresAt) => new()
{
AttemptId = attemptId,
MediationHandle = mediationHandle,
HostPunchCapability = Credential('H'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(connectionTicket),
ExpiresAt = expiresAt,
};
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
private sealed class ClientHarness : IDisposable
{
internal ClientHarness(
ManualCoordinatorClock clock,
RendezvousCoordinatorOptions? options = null)
{
NetworkEvents = new();
PunchEvents = NetworkEvents.PunchEvents;
Manager = NetworkEvents.CreateManager();
Assert.True(Manager.Start(0));
CreateJoinAttemptResponse attempt = CreateAttempt(clock.UtcNow + TimeSpan.FromSeconds(30));
AttemptId = attempt.AttemptId;
IntroductionToken = NatIntroductionTokenCodec.Encode(
attempt.AttemptId,
Credential('T'));
Coordinator = new(
Manager,
NetworkEvents,
new IPEndPoint(IPAddress.Loopback, 65_001),
attempt,
options,
clock);
}
internal RendezvousNetListener NetworkEvents { get; }
internal EventBasedNatPunchListener PunchEvents { get; }
internal NetManager Manager { get; }
internal RendezvousClientCoordinator Coordinator { get; }
internal JoinAttemptId AttemptId { get; }
internal string IntroductionToken { get; }
public void Dispose()
{
Coordinator.Dispose();
Manager.Stop();
}
}
private sealed class MutableJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
{
internal IReadOnlyList<HostJoinAttempt> Attempts { get; set; } = attempts;
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default) => Task.FromResult(
RendezvousClientResult.Success(Attempts));
}
private sealed class BlockingJoinClient : IRendezvousJoinClient
{
private readonly TaskCompletionSource<bool> _called = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<IReadOnlyList<HostJoinAttempt>> _result = new(
TaskCreationOptions.RunContinuationsAsynchronously);
internal Task WaitUntilCalled => _called.Task;
internal void Complete(IReadOnlyList<HostJoinAttempt> attempts) =>
_result.SetResult(attempts);
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default)
{
_called.SetResult(true);
return RendezvousClientResult.Success(await _result.Task.WaitAsync(cancellationToken));
}
}
private sealed class ManualCoordinatorClock(DateTimeOffset now) :
IRendezvousCoordinatorClock,
IConnectionTicketClock
{
public DateTimeOffset UtcNow { get; private set; } = now;
internal void Advance(TimeSpan amount) => UtcNow += amount;
}
}
@@ -0,0 +1,272 @@
using System.Net;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.JoinAttempts;
using LiteNetLib;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousCoordinatorIntegrationTests
{
[Fact]
public async Task CallerOwnedManagersCompleteAuthenticatedDirectConnectionAndRejectTicketReplay()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(8));
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "sdk-direct-connect");
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 mediatorService = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await mediatorService.StartAsync(timeout.Token);
RendezvousNetListener hostEvents = new();
RendezvousNetListener clientEvents = new();
bool gameplayConnectionRequestHandled = false;
hostEvents.GameplayEvents.ConnectionRequestEvent += _ =>
gameplayConnectionRequestHandled = true;
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
EventBasedNatPunchListener clientPunch = clientEvents.PunchEvents;
NetManager hostManager = hostEvents.CreateManager();
NetManager clientManager = clientEvents.CreateManager();
string? introductionToken = null;
string? hostIntroductionToken = null;
clientPunch.NatIntroductionSuccess += (_, _, token) => introductionToken = token;
hostPunch.NatIntroductionSuccess += (_, _, token) => hostIntroductionToken = token;
try
{
Assert.True(hostManager.Start(0));
Assert.True(clientManager.Start(0));
IPEndPoint mediator = Assert.IsType<IPEndPoint>(mediatorService.LocalEndpoint);
FakeJoinClient joinClient = new([hostAttempt]);
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
using ConnectionTicketValidator tickets = new(1_024, clock);
using RendezvousHostCoordinator host = new(
hostManager,
hostEvents,
mediator,
new PublishedSession(registration),
joinClient,
FastOptions(),
clock,
tickets);
using RendezvousClientCoordinator client = new(
clientManager,
clientEvents,
mediator,
created,
FastOptions(),
clock);
List<RendezvousHostAttemptCompletedEventArgs> hostCompletions = [];
List<RendezvousConnectionCompletedEventArgs> clientCompletions = [];
host.AttemptCompleted += (_, completion) => hostCompletions.Add(completion);
client.Completed += (_, completion) => clientCompletions.Add(completion);
Assert.True((await host.RefreshJoinAttemptsAsync(timeout.Token)).IsSuccess);
while ((!client.IsCompleted || hostCompletions.Count == 0)
&& !timeout.IsCancellationRequested)
{
host.Poll();
client.Poll();
await Task.Delay(2);
}
Assert.True(
client.State == RendezvousConnectionState.Connected,
$"Client ended in {client.State}; host pending={host.PendingAttemptCount}; "
+ $"host completions={hostCompletions.Count}; introduction={introductionToken is not null}; "
+ $"host introduction={hostIntroductionToken is not null}; "
+ $"client digest={NatIntroductionTokenCodec.MatchesDigest(introductionToken, created.ConnectionTicketDigest)}; "
+ $"host digest={NatIntroductionTokenCodec.MatchesDigest(hostIntroductionToken, hostAttempt.ConnectionTicketDigest)}.");
Assert.NotNull(client.ConnectedPeer);
Assert.Equal(
RendezvousConnectionState.Connected,
Assert.Single(clientCompletions).State);
RendezvousHostAttemptCompletedEventArgs hostCompletion = Assert.Single(hostCompletions);
Assert.Equal(created.AttemptId, hostCompletion.AttemptId);
Assert.Equal(RendezvousConnectionState.Connected, hostCompletion.State);
Assert.NotNull(hostCompletion.Peer);
Assert.False(gameplayConnectionRequestHandled);
Assert.True(NatIntroductionTokenCodec.TryDecode(
introductionToken,
out NatIntroductionToken? introduction));
Assert.NotNull(introduction);
EventBasedNetListener replayEvents = new();
bool replayConnected = false;
replayEvents.PeerConnectedEvent += _ => replayConnected = true;
NetManager replayManager = new(replayEvents);
try
{
Assert.True(replayManager.Start(0));
replayManager.Connect(
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
DirectConnectionRequestCodec.Encode(
created.AttemptId,
introduction.ConnectionTicket));
DateTime replayDeadline = DateTime.UtcNow.AddSeconds(1);
while (DateTime.UtcNow < replayDeadline && !replayConnected)
{
host.Poll();
replayManager.PollEvents();
await Task.Delay(2, timeout.Token);
}
Assert.False(replayConnected);
Assert.False(gameplayConnectionRequestHandled);
Assert.Single(hostCompletions);
}
finally
{
replayManager.Stop();
}
}
finally
{
hostManager.Stop();
clientManager.Stop();
await mediatorService.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task HostDefersADirectRequestUntilTheMatchingNatIntroductionArrives()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "direct-before-nat");
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
ConnectionTicketGrant grant = Assert.IsType<ConnectionTicketGrant>(
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
RendezvousNetListener hostEvents = new();
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
EventBasedNetListener clientEvents = new();
bool clientConnected = false;
clientEvents.PeerConnectedEvent += _ => clientConnected = true;
NetManager hostManager = hostEvents.CreateManager();
NetManager clientManager = new(clientEvents);
try
{
Assert.True(hostManager.Start(0));
Assert.True(clientManager.Start(0));
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
FakeJoinClient joins = new([hostAttempt]);
using ConnectionTicketValidator tickets = new(16, clock);
using RendezvousHostCoordinator host = new(
hostManager,
hostEvents,
new IPEndPoint(IPAddress.Loopback, 65_000),
new PublishedSession(registration),
joins,
FastOptions(),
clock,
tickets);
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
host.AttemptCompleted += (_, completion) => completions.Add(completion);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
bool observedDeferredRequest = false;
hostEvents.RendezvousConnectionRequest += _ =>
{
observedDeferredRequest = host.DeferredRequestCount == 1;
((INatPunchListener)hostPunch).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, clientManager.LocalPort),
NatAddressType.External,
grant.Ticket);
};
clientManager.Connect(
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
DirectConnectionRequestCodec.Encode(created.AttemptId, grant.Ticket));
DateTime connectedDeadline = DateTime.UtcNow.AddSeconds(1);
while ((!clientConnected || completions.Count == 0)
&& DateTime.UtcNow < connectedDeadline)
{
host.Poll();
clientManager.PollEvents();
await Task.Delay(2);
}
Assert.True(
clientConnected,
$"Deferred observed={observedDeferredRequest}; deferred={host.DeferredRequestCount}; "
+ $"pending={host.PendingAttemptCount}; completions={completions.Count}.");
Assert.True(observedDeferredRequest);
Assert.Equal(0, host.DeferredRequestCount);
Assert.Equal(
RendezvousConnectionState.Connected,
Assert.Single(completions).State);
}
finally
{
hostManager.Stop();
clientManager.Stop();
}
}
private static RendezvousCoordinatorOptions FastOptions() => new()
{
MaximumPunchRequests = 20,
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(100),
JitterRatio = 0,
};
private sealed class FakeJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
{
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default) => Task.FromResult(
RendezvousClientResult.Success(attempts));
}
private sealed class FixedCoordinatorClock(DateTimeOffset now) :
IRendezvousCoordinatorClock,
IConnectionTicketClock
{
public DateTimeOffset UtcNow { get; } = now;
}
}
@@ -0,0 +1,154 @@
using System.Net;
using System.Text.Json;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousJoinClientTests
{
[Fact]
public async Task JoinIssuanceRetriesTheSameIdempotentPayloadAndCancellationUsesCapability()
{
CreateJoinAttemptResponse created = CreateAttempt();
RecordingHandler handler = new(
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
JsonResponse(HttpStatusCode.Created, created),
new HttpResponseMessage(HttpStatusCode.NoContent));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(
http,
new RendezvousClientOptions { JitterRatio = 0 },
new ImmediateDelay());
CreateJoinAttemptRequest request = new()
{
IdempotencyKey = "stable-join-key",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000201")),
ProtocolVersion = 7,
};
RendezvousClientResult<CreateJoinAttemptResponse> result = await client.CreateAsync(request);
RendezvousClientResult<bool> cancelled = await client.CancelAsync(created);
Assert.True(result.IsSuccess, result.Message);
Assert.True(cancelled.IsSuccess, cancelled.Message);
Assert.Equal(handler.Requests[0].Body, handler.Requests[1].Body);
Assert.Contains("stable-join-key", handler.Requests[0].Body, StringComparison.Ordinal);
RecordedRequest cancellation = handler.Requests[2];
Assert.Equal(HttpMethod.Delete, cancellation.Method);
Assert.Equal(
created.ClientPunchCapability,
cancellation.Headers["X-Rendezvous-Client-Punch-Capability"]);
}
[Fact]
public async Task HostInvitationPollingFollowsCursorsWithTheLeaseToken()
{
HostJoinAttempt first = CreateHostAttempt("00000000-0000-0000-0000-000000000211");
HostJoinAttempt second = CreateHostAttempt("00000000-0000-0000-0000-000000000212");
RecordingHandler handler = new(
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
{
Items = [first],
NextCursor = "next page+cursor",
}),
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
{
Items = [second],
}));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
PublishedSession session = new(new RegisterSessionResponse
{
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000220")),
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000221")),
LeaseToken = "lease-secret",
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000222")),
HostPresenceCapability = Credential('P'),
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
LeaseRenewAfterSeconds = 15,
HostPresenceRefreshAfterSeconds = 10,
});
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
await client.BrowseAllForHostAsync(session);
Assert.True(result.IsSuccess, result.Message);
Assert.Equal([first.AttemptId, second.AttemptId], result.Value!.Select(item => item.AttemptId));
Assert.Equal(2, handler.Requests.Count);
Assert.All(handler.Requests, request =>
Assert.Equal("lease-secret", request.Headers["X-Rendezvous-Lease-Token"]));
Assert.Contains("cursor=next%20page%2Bcursor", handler.Requests[1].Uri.Query, StringComparison.Ordinal);
}
private static CreateJoinAttemptResponse CreateAttempt() => new()
{
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")),
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000203")),
ClientPunchCapability = Credential('C'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000202")),
Credential('T'))),
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
};
private static HostJoinAttempt CreateHostAttempt(string id) => new()
{
AttemptId = new(Guid.Parse(id)),
MediationHandle = new(Guid.NewGuid()),
HostPunchCapability = Credential('H'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(new JoinAttemptId(Guid.Parse(id)), Credential('T'))),
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
};
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
private static HttpResponseMessage JsonResponse<T>(HttpStatusCode status, T value) => new(status)
{
Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options)),
};
private sealed class RecordingHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
internal List<RecordedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Dictionary<string, string> headers = request.Headers.ToDictionary(
static item => item.Key,
static item => string.Join(",", item.Value),
StringComparer.OrdinalIgnoreCase);
Requests.Add(new(
request.Method,
request.RequestUri!,
headers,
request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken)));
return _responses.Dequeue();
}
}
private sealed record RecordedRequest(
HttpMethod Method,
Uri Uri,
IReadOnlyDictionary<string, string> Headers,
string Body);
private sealed class ImmediateDelay : IRendezvousDelay
{
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
}
}
@@ -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/ready",
"/v1/join-attempts",
"/v1/join-attempts/{attemptId}",
"/v1/join-attempts/{attemptId}/outcome",
"/v1/sessions",
"/v1/sessions/{listingId}",
@@ -85,5 +86,23 @@ public sealed class OpenApiCompatibilityTests
.GetProperty("security");
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 FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
@@ -23,6 +24,24 @@ public sealed class PublicApiCompatibilityTests
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)
{
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)
.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}}}");
}
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)
.Where(static method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal))
.OrderBy(static method => method.Name, StringComparer.Ordinal)
@@ -0,0 +1,59 @@
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class TraversalTokenCodecTests
{
[Fact]
public void IntroductionTokenBindsAttemptAndRedactsTheFixedTicket()
{
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000301"));
string authenticator = Credential('T');
string encoded = NatIntroductionTokenCodec.Encode(attemptId, authenticator);
Assert.Equal(NatIntroductionTokenCodec.EncodedLength, encoded.Length);
Assert.True(encoded.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters);
Assert.True(NatIntroductionTokenCodec.TryDecode(encoded, out NatIntroductionToken? decoded));
Assert.NotNull(decoded);
Assert.Equal(attemptId, decoded.AttemptId);
Assert.Equal(encoded, decoded.ConnectionTicket);
Assert.DoesNotContain(encoded, decoded.ToString(), StringComparison.Ordinal);
}
[Fact]
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
{
string valid = NatIntroductionTokenCodec.Encode(
new JoinAttemptId(Guid.Parse("abcdef00-0000-0000-0000-000000000302")),
Credential('T'));
Assert.False(NatIntroductionTokenCodec.TryDecode(null, out _));
Assert.False(NatIntroductionTokenCodec.TryDecode(valid[..^1], out _));
Assert.False(NatIntroductionTokenCodec.TryDecode(valid[..^1] + "!", out _));
Assert.False(NatIntroductionTokenCodec.TryDecode(new string('A', 43), out _));
}
[Fact]
public void DirectConnectionRequestRoundTripsFixedBoundedPayloadAndRedactsTicket()
{
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000303"));
string ticket = Credential('D');
byte[] encoded = DirectConnectionRequestCodec.Encode(attemptId, ticket);
Assert.Equal(DirectConnectionRequestCodec.EncodedLength, encoded.Length);
Assert.True(DirectConnectionRequestCodec.TryDecode(encoded, out DirectConnectionRequest? decoded));
Assert.NotNull(decoded);
Assert.Equal(attemptId, decoded.AttemptId);
Assert.Equal(ticket, decoded.ConnectionTicket);
Assert.DoesNotContain(ticket, decoded.ToString(), StringComparison.Ordinal);
encoded[0] ^= 0xff;
Assert.False(DirectConnectionRequestCodec.TryDecode(encoded, out _));
Assert.False(DirectConnectionRequestCodec.TryDecode(encoded.AsSpan(1), out _));
}
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
}
@@ -0,0 +1,206 @@
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 cancelledPollRequest = new(
HttpMethod.Get,
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
cancelledPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage cancelledPollResponse = await host.HttpClient.SendAsync(cancelledPollRequest);
BrowseHostJoinAttemptsResponse cancelledPoll = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await cancelledPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
HostJoinAttempt cancelledAttempt = Assert.Single(cancelledPoll.Items);
Assert.Equal(created.AttemptId, cancelledAttempt.AttemptId);
Assert.True(cancelledAttempt.IsCancelled);
}
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,321 @@
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.True(fixture.Service.Cancel(
created.AttemptId,
created.ClientPunchCapability).Succeeded);
HostJoinAttempt cancelled = Assert.Single(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
10,
null).Value!.Items);
Assert.Equal(created.AttemptId, cancelled.AttemptId);
Assert.True(cancelled.IsCancelled);
}
[Fact]
public void CancellationAfterIntroductionRevokesTicketIssuanceAndConsumption()
{
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(fixture.Sessions.Capabilities.TryFingerprint(
issued.Value!.Ticket,
out SecretFingerprint ticketFingerprint));
Assert.True(fixture.Service.Cancel(
created.AttemptId,
created.ClientPunchCapability).Succeeded);
StoredJoinAttempt cancelled = fixture.GetAttempt(registration, created.AttemptId);
Assert.True(cancelled.IsCancelled);
Assert.Equal(
RendezvousErrorCode.Conflict,
fixture.Service.IssueConnectionTicket(cancelled).Error);
Assert.Equal(
StoreResultCode.Conflict,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
created.AttemptId,
ticketFingerprint)).Code);
}
[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
{
[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]
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
{
@@ -0,0 +1,416 @@
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.True(NatIntroductionTokenCodec.TryDecode(
plan.IntroductionToken,
out NatIntroductionToken? introduction));
Assert.NotNull(introduction);
Assert.Equal(attempt.AttemptId, introduction.AttemptId);
Assert.Equal(43, introduction.ConnectionTicket.Length);
Assert.DoesNotContain(introduction.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
introduction.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 CancellationAfterIntroductionCreatesAHostRevocationTombstone()
{
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.True(cancelled.Succeeded);
sink.Release();
Assert.Equal(NatMediationResult.Introduced, await completion);
HostJoinAttempt cancelledAttempt = Assert.Single(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items);
Assert.True(cancelledAttempt.IsCancelled);
}
[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.Sockets;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.Sessions;
using FinalFactory.Rendezvous.Tests.State;
using FinalFactory.Rendezvous.Tests.JoinAttempts;
using LiteNetLib;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -13,55 +12,24 @@ namespace FinalFactory.Rendezvous.Tests.Server;
public sealed class UdpMediatorServiceTests
{
[Fact]
public void AuthenticatedHostDatagramGatesVisibilityUsingObservedGameplaySocket()
{
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()
public async Task ServiceBindsAnEphemeralLiteNetLibPortAndStopsCleanly()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
UdpMediatorOptions options = new()
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
};
ManualRendezvousClock clock = new();
InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock);
using EphemeralCapabilityIssuer capabilities = new();
using JoinAttemptFixture fixture = new();
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(options),
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
store,
capabilities);
processor);
await service.StartAsync(timeout.Token);
@@ -69,9 +37,307 @@ public sealed class UdpMediatorServiceTests
Assert.NotNull(boundEndpoint);
Assert.Equal(IPAddress.Loopback, boundEndpoint.Address);
Assert.InRange(boundEndpoint.Port, 1, 65_535);
Assert.Null(service.LocalIpv6Endpoint);
await service.StopAsync(timeout.Token);
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.True(NatIntroductionTokenCodec.TryDecode(
hostTicket,
out NatIntroductionToken? introduction));
Assert.NotNull(introduction);
Assert.Equal(created.AttemptId, introduction.AttemptId);
Assert.Equal(43, introduction.ConnectionTicket.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: "
+ $"responses={hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length}, "
+ $"request={clientDatagram.Length}.");
}
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);
}
}
}
@@ -61,6 +61,7 @@ public sealed class SessionLeaseServiceTests
{
LeaseLifetime = TimeSpan.FromSeconds(5),
JoinAttemptLifetime = TimeSpan.FromSeconds(5),
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
IdempotencyLifetime = TimeSpan.FromSeconds(6),
};
using SessionLeaseFixture fixture = new(options);
@@ -95,6 +95,8 @@ internal sealed class EphemeralStateFixture
ProtocolVersion = listing.Definition.ProtocolVersion,
HostCapabilityFingerprint = Fingerprint($"host-{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,160 @@
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.DirectConnectionRequest
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.String ConnectionTicket {get;set;}
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequestCodec
FIELD System.Int32 EncodedLength=63
METHOD System.Byte[] Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
METHOD System.Boolean IsRendezvousRequest(System.ReadOnlySpan<System.Byte> encoded)
METHOD System.Boolean TryDecode(System.ReadOnlySpan<System.Byte> encoded, FinalFactory.Rendezvous.Client.DirectConnectionRequest& request)
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.IRendezvousJoinClient
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.HostJoinAttempt>>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse>> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, 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.RendezvousClientCoordinator
CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options)
PROP LiteNetLib.NetPeer ConnectedPeer {get;}
PROP System.Boolean IsCompleted {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs> Completed
METHOD System.Void Cancel()
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken)
METHOD System.Void Dispose()
METHOD System.Void Poll()
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.RendezvousConnectionCompletedEventArgs
CTOR (FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
PROP LiteNetLib.NetPeer Peer {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState
ENUM Punching=1
ENUM Connecting=2
ENUM Connected=3
ENUM Cancelled=4
ENUM TimedOut=5
ENUM Rejected=6
ENUM ManagerStopped=7
ENUM Disposed=8
TYPE FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions
CTOR ()
PROP System.TimeSpan ConnectionTicketLifetime {get;set;}
PROP System.TimeSpan InitialPunchRetryDelay {get;set;}
PROP System.Double JitterRatio {get;set;}
PROP System.Int32 MaximumAttemptChecksPerPoll {get;set;}
PROP System.Int32 MaximumPunchRequests {get;set;}
PROP System.TimeSpan MaximumPunchRetryDelay {get;set;}
TYPE FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs
CTOR (FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;}
PROP LiteNetLib.NetPeer Peer {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousHostCoordinator
CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options)
PROP System.Int32 PendingAttemptCount {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousHostState State {get;}
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs> AttemptCompleted
METHOD System.Void Dispose()
METHOD System.Void Poll()
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Int32>> RefreshJoinAttemptsAsync(System.Threading.CancellationToken cancellationToken)
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Client.RendezvousHostState
ENUM Active=1
ENUM ManagerStopped=2
ENUM Disposed=3
TYPE FinalFactory.Rendezvous.Client.RendezvousJoinClient
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.HostJoinAttempt>>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse>> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.RendezvousNetListener
CTOR ()
PROP LiteNetLib.EventBasedNetListener GameplayEvents {get;}
PROP LiteNetLib.EventBasedNatPunchListener PunchEvents {get;}
METHOD LiteNetLib.NetManager CreateManager()
METHOD System.Void OnConnectionRequest(LiteNetLib.ConnectionRequest request)
METHOD System.Void OnMessageDelivered(LiteNetLib.NetPeer peer, System.Object userData)
METHOD System.Void OnNetworkError(System.Net.IPEndPoint endPoint, System.Net.Sockets.SocketError socketError)
METHOD System.Void OnNetworkLatencyUpdate(LiteNetLib.NetPeer peer, System.Int32 latency)
METHOD System.Void OnNetworkReceive(LiteNetLib.NetPeer peer, LiteNetLib.NetPacketReader reader, System.Byte channelNumber, LiteNetLib.DeliveryMethod deliveryMethod)
METHOD System.Void OnNetworkReceiveUnconnected(System.Net.IPEndPoint remoteEndPoint, LiteNetLib.NetPacketReader reader, LiteNetLib.UnconnectedMessageType messageType)
METHOD System.Void OnNtpResponse(LiteNetLib.Utils.NtpPacket packet)
METHOD System.Void OnPeerAddressChanged(LiteNetLib.NetPeer peer, System.Net.IPEndPoint previousAddress)
METHOD System.Void OnPeerConnected(LiteNetLib.NetPeer peer)
METHOD System.Void OnPeerDisconnected(LiteNetLib.NetPeer peer, LiteNetLib.DisconnectInfo disconnectInfo)
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)
@@ -49,6 +49,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
FIELD System.Int32 ConnectionTicketMaxCharacters=192
FIELD System.Int32 ContractVersion=1
FIELD System.Int32 CursorMaxCharacters=512
FIELD System.Int32 DerivedCredentialCharacters=43
FIELD System.Int32 DiagnosticCodeMaxCharacters=64
FIELD System.Int32 DisplayNameMaxBytes=128
FIELD System.Int32 EnvironmentIdMaxCharacters=32
@@ -61,6 +62,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
FIELD System.Int32 MetadataMaxBytes=4096
FIELD System.Int32 MetadataMaxKeys=32
FIELD System.Int32 MetadataValueMaxBytes=256
FIELD System.Int32 NatPunchRequestTokenCharacters=192
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
FIELD System.Int32 RegionIdMaxCharacters=32
FIELD System.Int32 SessionCapacityMaxPlayers=10000
@@ -96,6 +98,7 @@ TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.String ClientPunchCapability {get;set;}
PROP System.String ConnectionTicketDigest {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
@@ -135,8 +138,10 @@ TYPE FinalFactory.Rendezvous.Contracts.HealthResponse
TYPE FinalFactory.Rendezvous.Contracts.HostJoinAttempt
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.String ConnectionTicketDigest {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
PROP System.String HostPunchCapability {get;set;}
PROP System.Boolean IsCancelled {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.JoinAttemptId
CTOR (System.Guid value)
@@ -171,6 +176,31 @@ TYPE FinalFactory.Rendezvous.Contracts.MediationHandle
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_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
TYPE FinalFactory.Rendezvous.Contracts.NatIntroductionToken
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.String ConnectionTicket {get;set;}
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Contracts.NatIntroductionTokenCodec
FIELD System.Int32 EncodedLength=43
METHOD System.String ComputeDigest(System.String connectionTicket)
METHOD System.String Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String derivedAuthenticator)
METHOD System.Boolean MatchesDigest(System.String connectionTicket, System.String expectedDigest)
METHOD System.Boolean TryDecode(System.String encoded, FinalFactory.Rendezvous.Contracts.NatIntroductionToken& token)
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
CTOR ()
PROP System.String Address {get;set;}
@@ -1 +1 @@
{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}
{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","connectionTicketDigest":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}