Compare commits

..

2 Commits

Author SHA1 Message Date
KyuubiYoru 6d076c281a feat: implement authenticated NAT mediator (#11)
quality-gate / quality (push) Successful in 59s
Closes #11
2026-07-16 07:37:02 +02:00
KyuubiYoru 1baa1055dc feat: implement scoped join attempts and tickets (#10)
quality-gate / quality (push) Successful in 1m1s
Closes #10
2026-07-16 06:56:30 +02:00
44 changed files with 3716 additions and 286 deletions
+125 -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": {
@@ -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,66 @@
# ADR 0008: scoped join attempts and one-time connection tickets
- Status: Accepted
- Date: 2026-07-16
- Tracking: #10
## Decision
Join creation is an unauthenticated public operation because v1 does not treat a
Rendezvous caller as game identity. The HTTP source address is normalized and
converted to a process-keyed opaque subject for idempotency and bounded policy
accounting; raw addresses and the derived subject are never returned or logged.
A successful request means only that this network client may try to connect to
this active session. It does not reserve capacity or grant gameplay admission.
Creation validates the v1 contract, caller idempotency key, enabled tenant policy,
exact gameplay protocol, listing scope, live lease, and fresh authenticated host
presence in one atomic store operation. A listing advertised as full remains
joinable because its player count is advisory and the game host owns the final
capacity, identity, ban, and admission decision.
Each attempt derives independent host-punch, client-punch, and connection-ticket
credentials plus opaque attempt and mediation IDs from a process-ephemeral HMAC
key, the client subject, the complete canonical request fingerprint, a fresh salt,
and a purpose/role label. Credentials are 32-byte base64url values (43 characters),
below both the 192-character Rendezvous capability ceiling and LiteNetLib's
256-character NAT token ceiling. State retains keyed credential fingerprints,
derivation inputs, and salt—not issued plaintext. All diagnostic string
representations redact credentials and derivation material.
The client receives only its punch capability. A host polls its own listing with
the lease token in `X-Rendezvous-Lease-Token` and receives only host-role
capabilities through a signed, listing-bound, five-minute cursor. Replaying an
identical join request returns the same live attempt; changing the request under
the same owner/key conflicts. A client may cancel with its punch capability in
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically removes the
attempt. Listing deletion, expiry, revocation, or process restart removes every
associated attempt and credential fingerprint.
Endpoint binding remains role- and capability-specific. The first endpoint
observed for a role wins atomically; an exact UDP duplicate is idempotent, while
endpoint or role substitution is rejected. An introduction is consumable once
only after both roles bind, so concurrent attempts for the same listing cannot
cross-wire.
The connection ticket is distinct from both punch capabilities and is reproduced
only after introduction succeeds. Its window begins at that moment and lasts at
most 20 seconds without outliving the 30-second attempt. The server has an atomic
fingerprint-consumption seam for mediator tests and revocation. On the game host,
the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest,
accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/
revoked/replayed tickets, and zeroes retained digests and key material on disposal.
Issue #11 carries the ticket in the authenticated introduction; issue #12 wires
authorization and consumption into the caller-owned LiteNetLib coordinator.
## Consequences
- A join attempt is transport authorization, never proof of player identity or a
game slot.
- Network-address-derived subjects are process-local abuse/idempotency scopes,
not stable user identifiers; stronger authenticated player scopes require a
future game-owned identity contract.
- Cancellation after a ticket has reached a host must also revoke that host's
local validator entry; coordinator wiring owns that race in issue #12.
- Capability and ticket plaintext never enter browser results, state snapshots,
logs, metrics, or generated string representations.
@@ -0,0 +1,68 @@
# ADR 0009: authenticated bounded LiteNetLib NAT mediator
- Status: Accepted
- Date: 2026-07-16
- Tracking: #11
## Decision
The server owns one LiteNetLib `NetManager` and its `NatPunchModule` on the
configured UDP endpoint. It runs in manual mode with a configured maximum number
of datagrams per poll and a short caller-owned poll interval. LiteNetLib events
are unsynchronized so authenticated requests are processed immediately on that
single polling path rather than accumulated in an unbounded event queue. The
mediator never accepts a LiteNetLib gameplay connection or handles application
payloads.
The packet layer also consumes the frozen v1 presence envelope on the same
socket. Native NAT requests use a canonical fixed-size 192-character token that
binds a role (`HostPresence`, attempt `Host`, or attempt `Client`), mediation
handle, and the already-issued capability. Both transports enter one processor
and the same atomic store operations. No transport-supplied public address is
trusted; the socket source is authoritative.
LiteNetLib's native NAT packet family also contains introduction-response and
punch frames that are appropriate for peers but unsafe on a public mediator: a
forged response can name arbitrary destinations. The packet layer therefore
decodes only the pinned `NatIntroduceRequest` wire shape and consumes every
inbound packet before `NatPunchModule` sees it. The module is outbound-only and
may send introductions solely from a completed authorized plan.
Listing presence refreshes authorize no response. Attempt contributions bind the
first observed endpoint for exactly one capability role. Exact duplicates are
idempotent; a different endpoint, the opposite role, an expired/cancelled
attempt, or a stale listing presence cannot replace it. The introduction is
consumed atomically only after both roles bind and their observed address
families match, preventing concurrent attempts for one listing from cross-wiring.
A reported local candidate is eligible only when it is RFC 1918 IPv4 or IPv6
unique-local unicast, matches the observed family, and both peers have the same
observed public address. Otherwise `NatIntroduce` receives the observed public
endpoint in the local slot. Loopback, link-local, multicast, unspecified,
documentation IPv6, global-address claims, and cross-family claims are never
disclosed as local targets. IPv4 is required; observed global IPv6 can be used
when both peers contribute IPv6, without claiming guaranteed IPv6 NAT traversal.
The introduction carries only the distinct connection ticket and is emitted at
most once to each verified observed endpoint. The fixed authenticated native
request and bounded frozen envelope keep the combined response bytes within the
2.0 verified amplification budget; unauthenticated inputs receive zero bytes.
Malformed, truncated, oversized, spoofed, or unrelated LiteNetLib packets do not
grow Rendezvous state. Raw endpoints and credentials are never logged or exposed
through diagnostic string representations.
Frozen IPv6 listing-presence refresh remains valid because it emits no response.
IPv6 attempt roles require the fixed-size native LiteNetLib request; accepting the
short frozen envelope would exceed the 2.0 byte budget for two IPv6 introduction
frames. The required IPv4 listen address and optional IPv6 listen address are
configured separately so enabling one family never widens the other family to a
wildcard bind.
## Consequences
- Hosts refresh listing presence and answer invitations from their actual
gameplay socket; a separate mediator socket would observe the wrong mapping.
- Caller-owned SDK coordination in #12 must poll the host invitation endpoint,
send the corresponding native role token, and consume the returned ticket.
- UDP loss can prevent traversal, but it cannot cause an arbitrary destination,
replay, role substitution, or cross-attempt introduction.
+2
View File
@@ -10,6 +10,8 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [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;
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
+34 -1
View File
@@ -42,6 +42,21 @@ if (!registered.IsSuccess || registered.Value is null)
Load `publisherCredential` from the game's deployment secret boundary; never
embed it in a client build or source control. A successful registration returns a
`PublishedSession` containing the lease and host-presence capabilities.
Send a periodic presence request from the host's gameplay `NetManager` using the
server-controlled refresh interval and the fixed-size native token:
```csharp
string presenceToken = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
session.HostPresenceHandle,
session.HostPresenceCapability);
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
```
The same codec creates `Host` tokens for host-polled invitations and `Client`
tokens for a created join attempt. Always send them from the same LiteNetLib
socket that will carry the direct game connection; the mediator ignores any
caller-supplied public endpoint.
Lease renewal is explicit and caller-controlled:
@@ -58,4 +73,22 @@ 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`.
See the repository's ADR 0007 for retry, paging, ownership, and failure semantics.
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.
@@ -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;
}
@@ -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,330 @@
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.JoinAttempts;
internal sealed record JoinAttemptServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
{
public bool Succeeded => Error == RendezvousErrorCode.None;
}
internal sealed record ConnectionTicketGrant(string Ticket, DateTimeOffset ExpiresAt)
{
public override string ToString() => "[ConnectionTicketGrant: ticket redacted]";
}
internal sealed class JoinAttemptService(
GamePolicyRegistry policies,
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities,
JoinAttemptCursorCodec cursors,
IWallClock clock)
{
public string CreateAnonymousClientSubject(IPAddress remoteAddress)
{
ArgumentNullException.ThrowIfNull(remoteAddress);
IPAddress normalized = remoteAddress.IsIPv4MappedToIPv6
? remoteAddress.MapToIPv4()
: remoteAddress;
return capabilities.DeriveOpaqueIdentifier("join-http-client", normalized.ToString());
}
public JoinAttemptServiceResult<CreateJoinAttemptResponse> Create(
string clientSubject,
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrWhiteSpace(clientSubject))
{
throw new ArgumentException("A bounded client subject is required.", nameof(clientSubject));
}
RendezvousErrorCode validation = ValidateCreate(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
if (!policies.TryGet(request.GameId, request.EnvironmentId, out GamePolicy? policy)
|| policy is null)
{
return new(RendezvousErrorCode.NotFound);
}
if (!policy.AllowsProtocol(request.ProtocolVersion))
{
return new(RendezvousErrorCode.IncompatibleProtocol);
}
string requestFingerprint = ComputeRequestFingerprint(request);
string derivationSalt = capabilities.CreateDerivationSalt();
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt);
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|| !capabilities.TryFingerprint(connectionTicket, out SecretFingerprint ticketFingerprint))
{
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
}
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
"join-attempt-id",
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
"join-mediation-handle",
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt));
StoreResult<StoredJoinAttempt> created = store.CreateJoinAttempt(new()
{
IdempotencyOwner = clientSubject,
IdempotencyKey = request.IdempotencyKey,
RequestFingerprint = requestFingerprint,
ClientSubject = clientSubject,
AttemptId = attemptId,
MediationHandle = mediationHandle,
Scope = new(request.GameId, request.EnvironmentId),
ListingId = request.ListingId,
ProtocolVersion = request.ProtocolVersion,
HostCapabilityFingerprint = hostFingerprint,
ClientCapabilityFingerprint = clientFingerprint,
ConnectionTicketFingerprint = ticketFingerprint,
CapabilityDerivationSalt = derivationSalt,
ScopeAttemptLimit = policy.MaxActiveJoinAttempts,
}, cancellationToken);
if (!created.Succeeded || created.Value is null)
{
return new(created.Code.ToContractError());
}
StoredJoinAttempt persisted = created.Value;
clientCapability = Derive(
"join-client-punch",
persisted.ClientSubject,
persisted.IdempotencyKey,
persisted.RequestFingerprint,
persisted.CapabilityDerivationSalt);
if (!capabilities.TryFingerprint(clientCapability, out SecretFingerprint persistedFingerprint)
|| persistedFingerprint != persisted.ClientCapabilityFingerprint)
{
throw new InvalidOperationException("Stored join state could not reproduce its client capability.");
}
return new(RendezvousErrorCode.None, new CreateJoinAttemptResponse
{
AttemptId = persisted.AttemptId,
MediationHandle = persisted.MediationHandle,
ClientPunchCapability = clientCapability,
ExpiresAt = persisted.ExpiresAt,
});
}
public JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> BrowseForHost(
SessionListingId listingId,
int contractVersion,
string? leaseToken,
int pageSize,
string? cursor,
CancellationToken cancellationToken = default)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
if (version != RendezvousErrorCode.None)
{
return new(version);
}
if (!ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|| !ContractValidation.IsPageSizeValid(pageSize)
|| !ContractValidation.IsCursorValid(cursor)
|| !capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint))
{
return new(RendezvousErrorCode.InvalidRequest);
}
if (!cursors.TryDecode(cursor, listingId, clock.UtcNow, out JoinAttemptId? after))
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<IReadOnlyList<StoredJoinAttempt>> found = store.BrowseHostJoinAttempts(new(
listingId,
leaseFingerprint,
pageSize + 1,
after), cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return new(found.Code.ToContractError());
}
bool hasMore = found.Value.Count > pageSize;
StoredJoinAttempt[] page = found.Value.Take(pageSize).ToArray();
BrowseHostJoinAttemptsResponse response = new()
{
Items = page.Select(CreateHostAttempt).ToList(),
NextCursor = hasMore && page.Length > 0
? cursors.Encode(listingId, page[^1].AttemptId, clock.UtcNow)
: null,
};
int encodedBytes = JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length;
return ContractValidation.IsBrowserResponseSizeValid(encodedBytes)
? new(RendezvousErrorCode.None, response)
: new(RendezvousErrorCode.CapacityExceeded);
}
public JoinAttemptServiceResult<bool> Cancel(
JoinAttemptId attemptId,
string? clientPunchCapability,
CancellationToken cancellationToken = default)
{
if (!ContractValidation.IsCapabilityValid(clientPunchCapability)
|| !capabilities.TryFingerprint(clientPunchCapability, out SecretFingerprint fingerprint))
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<bool> cancelled = store.CancelJoinAttempt(new(attemptId, fingerprint), cancellationToken);
return cancelled.Succeeded
? new(RendezvousErrorCode.None, true)
: new(cancelled.Code.ToContractError());
}
public JoinAttemptServiceResult<ConnectionTicketGrant> IssueConnectionTicket(
StoredJoinAttempt attempt)
{
ArgumentNullException.ThrowIfNull(attempt);
if (!attempt.IntroductionConsumed)
{
return new(RendezvousErrorCode.Conflict);
}
if (attempt.ConnectionTicketExpiresAt <= clock.UtcNow)
{
return new(RendezvousErrorCode.Expired);
}
string ticket = Derive(
"connection-ticket",
attempt.ClientSubject,
attempt.IdempotencyKey,
attempt.RequestFingerprint,
attempt.CapabilityDerivationSalt);
if (!ContractValidation.IsConnectionTicketValid(ticket)
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|| fingerprint != attempt.ConnectionTicketFingerprint)
{
throw new InvalidOperationException("Stored join state could not reproduce its connection ticket.");
}
return new(RendezvousErrorCode.None, new(ticket, attempt.ConnectionTicketExpiresAt));
}
private HostJoinAttempt CreateHostAttempt(StoredJoinAttempt attempt)
{
string capability = Derive(
"join-host-punch",
attempt.ClientSubject,
attempt.IdempotencyKey,
attempt.RequestFingerprint,
attempt.CapabilityDerivationSalt);
if (!ContractValidation.IsCapabilityValid(capability)
|| !capabilities.TryFingerprint(capability, out SecretFingerprint fingerprint)
|| fingerprint != attempt.HostCapabilityFingerprint)
{
throw new InvalidOperationException("Stored join state could not reproduce its host capability.");
}
return new()
{
AttemptId = attempt.AttemptId,
MediationHandle = attempt.MediationHandle,
HostPunchCapability = capability,
ExpiresAt = attempt.ExpiresAt,
};
}
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|| string.IsNullOrEmpty(request.GameId.Value)
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|| request.ListingId.Value == Guid.Empty
|| request.ProtocolVersion == 0
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private static string ComputeRequestFingerprint(CreateJoinAttemptRequest request)
{
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(request, ContractJson.Options);
byte[] digest = SHA256.HashData(encoded);
CryptographicOperations.ZeroMemory(encoded);
try
{
return Encode(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
private string Derive(
string purpose,
string clientSubject,
CreateJoinAttemptRequest request,
string requestFingerprint,
string derivationSalt) => Derive(
purpose,
clientSubject,
request.IdempotencyKey,
requestFingerprint,
derivationSalt);
private string Derive(
string purpose,
string clientSubject,
string idempotencyKey,
string requestFingerprint,
string derivationSalt) => capabilities.DeriveCapability(
purpose,
clientSubject,
idempotencyKey,
requestFingerprint,
derivationSalt);
private static bool CredentialLengthsAreValid(
string hostCapability,
string clientCapability,
string ticket) =>
ContractValidation.IsCapabilityValid(hostCapability)
&& ContractValidation.IsCapabilityValid(clientCapability)
&& ContractValidation.IsConnectionTicketValid(ticket)
&& hostCapability.Length == ContractLimits.DerivedCredentialCharacters
&& clientCapability.Length == ContractLimits.DerivedCredentialCharacters
&& ticket.Length == ContractLimits.DerivedCredentialCharacters
&& hostCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters
&& clientCapability.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters;
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
+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,8 +441,15 @@ internal static class Base64Url
try
{
bytes = Convert.FromBase64String(padded);
if (string.Equals(Encode(bytes), value, StringComparison.Ordinal))
{
return true;
}
CryptographicOperations.ZeroMemory(bytes);
bytes = [];
return false;
}
catch (FormatException)
{
return false;
@@ -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,28 @@ 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 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 +304,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 +356,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
&& (!query.AfterAttemptId.HasValue
|| entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0))
.OrderBy(static entry => entry.Command.AttemptId.Value)
.Take(query.MaximumResults)
.Select(Snapshot)
.ToArray();
return new(StoreResultCode.Success, attempts);
}, cancellationToken);
public StoreResult<bool> CancelJoinAttempt(
CancelJoinAttemptCommand command,
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
{
ArgumentNullException.ThrowIfNull(command);
if (command.AttemptId.Value == Guid.Empty || !command.ClientCapabilityFingerprint.IsValid)
{
throw new ArgumentException("Join cancellation invariants are invalid.", nameof(command));
}
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!_attempts.TryGetValue(command.AttemptId, out AttemptEntry? attempt)
|| attempt.ClientCapabilityFingerprint != command.ClientCapabilityFingerprint)
{
return new(StoreResultCode.NotFound);
}
if (attempt.IntroductionConsumed)
{
return new(StoreResultCode.Conflict);
}
RemoveAttempt(command.AttemptId);
return new(StoreResultCode.Success, true);
}, cancellationToken);
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
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,13 @@ 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)
{
return new(StoreResultCode.NotFound);
}
if (!HasFreshHostPresence(attempt, now))
{
return new(StoreResultCode.NotFound);
}
@@ -443,11 +519,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 +531,13 @@ 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)
{
return new(StoreResultCode.NotFound);
}
if (!HasFreshHostPresence(attempt, now))
{
return new(StoreResultCode.NotFound);
}
@@ -471,10 +553,55 @@ 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.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 +709,38 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
}
}
private StoreResult<T> Atomic<T>(Func<TimeSpan, StoreResult<T>> operation, CancellationToken cancellationToken)
private StoreResult<T> Atomic<T>(
Func<TimeSpan, StoreResult<T>> operation,
CancellationToken cancellationToken,
bool eagerCleanup = true)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
cancellationToken.ThrowIfCancellationRequested();
TimeSpan now = _monotonicClock.Elapsed;
// Authenticated UDP duplicates need O(1) store work. Their operations
// check exact resource deadlines and amortize physical expiry removal.
bool drainExpired = _drainDeadline is TimeSpan drainDeadline
&& now >= drainDeadline;
if (drainExpired || eagerCleanup || now >= _nextUdpMaintenance)
{
Cleanup(now);
_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 +760,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private void Cleanup(TimeSpan now)
{
_maintenanceSweepCount++;
if (_drainDeadline is TimeSpan drainDeadline && now >= drainDeadline)
{
ClearActiveState();
@@ -714,10 +862,18 @@ 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,
};
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
@@ -789,6 +945,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 +1011,15 @@ 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; }
}
private sealed record IdempotencyEntry(
@@ -0,0 +1,20 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.State;
internal static class StoreResultMapping
{
public static RendezvousErrorCode ToContractError(this StoreResultCode code) => code switch
{
StoreResultCode.Success => RendezvousErrorCode.None,
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
StoreResultCode.Expired => RendezvousErrorCode.Expired,
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
RendezvousErrorCode.ServiceUnavailable,
_ => RendezvousErrorCode.InternalError,
};
}
@@ -0,0 +1,73 @@
using System.Buffers.Binary;
using System.Net;
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Transport;
internal static class LiteNetNatRequestCodec
{
private const byte NatMessageProperty = 17;
private const int TypeIdentifierLength = 8;
private const int TokenLengthPrefix = NatPunchRequestTokenCodec.EncodedLength + 1;
// LiteNetLib 2.1.4's private NatIntroduceRequest type ID. The native socket
// integration test deliberately fails if a package upgrade changes this wire value.
private static ReadOnlySpan<byte> RequestTypeIdentifier =>
[0x88, 0xbe, 0x10, 0x26, 0xbf, 0xb1, 0x66, 0x9c];
public static bool TryDecode(
ReadOnlySpan<byte> datagram,
out IPEndPoint? claimedLocalEndpoint,
out string? token)
{
claimedLocalEndpoint = null;
token = null;
if (datagram.Length < 1 + TypeIdentifierLength + 1 + 4 + 2 + 2
+ NatPunchRequestTokenCodec.EncodedLength
|| datagram[0] != NatMessageProperty
|| !datagram.Slice(1, TypeIdentifierLength).SequenceEqual(RequestTypeIdentifier))
{
return false;
}
int offset = 1 + TypeIdentifierLength;
int addressLength = datagram[offset++] switch
{
0 => 4,
1 => 16,
_ => 0,
};
int expectedLength = offset + addressLength + 2 + 2
+ NatPunchRequestTokenCodec.EncodedLength;
if (addressLength == 0 || datagram.Length != expectedLength)
{
return false;
}
IPAddress localAddress = new(datagram.Slice(offset, addressLength));
offset += addressLength;
int localPort = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
offset += 2;
int encodedTokenLength = BinaryPrimitives.ReadUInt16LittleEndian(datagram.Slice(offset, 2));
offset += 2;
if (localPort == 0 || encodedTokenLength != TokenLengthPrefix)
{
return false;
}
ReadOnlySpan<byte> tokenBytes = datagram.Slice(
offset,
NatPunchRequestTokenCodec.EncodedLength);
for (int index = 0; index < tokenBytes.Length; index++)
{
if (tokenBytes[index] > 0x7f)
{
return false;
}
}
claimedLocalEndpoint = new(localAddress, localPort);
token = Encoding.ASCII.GetString(tokenBytes);
return true;
}
}
@@ -0,0 +1,323 @@
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Transport;
internal interface INatIntroductionSink
{
void Introduce(NatIntroductionPlan plan);
}
internal sealed record NatIntroductionPlan(
IPEndPoint HostLocal,
IPEndPoint HostPublic,
IPEndPoint ClientLocal,
IPEndPoint ClientPublic,
string ConnectionTicket)
{
public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]";
}
internal enum NatMediationResult
{
Dropped = 0,
HostPresenceAccepted = 1,
HostPresenceRejected = 2,
WaitingForPeer = 3,
Introduced = 4,
Duplicate = 5,
Rejected = 6,
}
internal sealed class NatMediationProcessor(
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities,
JoinAttemptService joinAttempts)
{
public NatMediationResult ProcessDatagram(
ReadOnlySpan<byte> encoded,
IPEndPoint observedPublicEndpoint,
INatIntroductionSink introductionSink,
CancellationToken cancellationToken = default)
{
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|| datagram is null
|| datagram.Capability.Length != ContractLimits.DerivedCredentialCharacters
|| !IPAddress.TryParse(datagram.LocalAddress, out IPAddress? localAddress))
{
return NatMediationResult.Dropped;
}
IPEndPoint claimedLocalEndpoint = new(localAddress, datagram.LocalPort);
NatPunchPeerRole role = datagram.MessageType == UdpPresenceMessageType.ClientPresence
? NatPunchPeerRole.Client
: NatPunchPeerRole.HostPresence;
bool observedIpv6 = observedPublicEndpoint.AddressFamily == AddressFamily.InterNetworkV6
&& !observedPublicEndpoint.Address.IsIPv4MappedToIPv6;
if (role == NatPunchPeerRole.Client && observedIpv6)
{
return NatMediationResult.Dropped;
}
NatMediationResult result = ProcessRequest(
claimedLocalEndpoint,
observedPublicEndpoint,
NatPunchRequestTokenCodec.Encode(role, datagram.MediationHandle, datagram.Capability),
introductionSink,
cancellationToken);
if (role != NatPunchPeerRole.HostPresence
|| result != NatMediationResult.HostPresenceRejected
|| observedIpv6)
{
return result;
}
return ProcessRequest(
claimedLocalEndpoint,
observedPublicEndpoint,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Host,
datagram.MediationHandle,
datagram.Capability),
introductionSink,
cancellationToken);
}
public NatMediationResult ProcessRequest(
IPEndPoint claimedLocalEndpoint,
IPEndPoint observedPublicEndpoint,
string token,
INatIntroductionSink introductionSink,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(claimedLocalEndpoint);
ArgumentNullException.ThrowIfNull(observedPublicEndpoint);
ArgumentNullException.ThrowIfNull(introductionSink);
if (!NatPunchRequestTokenCodec.TryDecode(token, out NatPunchRequestToken? request)
|| request is null
|| !TryCreateObservedEndpoint(observedPublicEndpoint, out ObservedEndpoint publicEndpoint)
|| !capabilities.TryFingerprint(request.Capability, out SecretFingerprint fingerprint))
{
return NatMediationResult.Dropped;
}
ObservedEndpoint? localEndpoint = TryCreatePrivateCandidate(
claimedLocalEndpoint,
publicEndpoint.AddressFamily,
out ObservedEndpoint candidate)
? candidate
: null;
if (request.Role == NatPunchPeerRole.HostPresence)
{
StoreResult<StoredListing> presence = store.BindHostPresence(new(
request.MediationHandle,
fingerprint,
publicEndpoint,
localEndpoint), cancellationToken);
return presence.Succeeded
? NatMediationResult.HostPresenceAccepted
: NatMediationResult.HostPresenceRejected;
}
AttemptPeerRole role = request.Role switch
{
NatPunchPeerRole.Host => AttemptPeerRole.Host,
NatPunchPeerRole.Client => AttemptPeerRole.Client,
_ => default,
};
if (role == default)
{
return NatMediationResult.Dropped;
}
StoreResult<StoredJoinAttempt> bound = store.BindAttemptEndpoint(new(
request.MediationHandle,
role,
fingerprint,
publicEndpoint,
localEndpoint), cancellationToken);
if (!bound.Succeeded || bound.Value is null)
{
return bound.Code == StoreResultCode.ReplayRejected
? NatMediationResult.Rejected
: NatMediationResult.Dropped;
}
StoredJoinAttempt attempt = bound.Value;
if (attempt.IntroductionConsumed)
{
return NatMediationResult.Duplicate;
}
if (attempt.HostEndpoint is null || attempt.ClientEndpoint is null)
{
return NatMediationResult.WaitingForPeer;
}
if (attempt.HostEndpoint.PublicEndpoint.AddressFamily
!= attempt.ClientEndpoint.PublicEndpoint.AddressFamily)
{
return NatMediationResult.Rejected;
}
StoreResult<IntroductionEndpoints> consumed = store.ConsumeIntroduction(
request.MediationHandle,
cancellationToken);
if (!consumed.Succeeded || consumed.Value is null)
{
return consumed.Code == StoreResultCode.ReplayRejected
? NatMediationResult.Duplicate
: NatMediationResult.Rejected;
}
JoinAttemptServiceResult<ConnectionTicketGrant> ticket = joinAttempts.IssueConnectionTicket(
consumed.Value.Attempt);
if (!ticket.Succeeded || ticket.Value is null)
{
return NatMediationResult.Rejected;
}
try
{
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value.Ticket));
return NatMediationResult.Introduced;
}
catch (Exception exception) when (exception is SocketException
or InvalidOperationException
or ArgumentException)
{
return NatMediationResult.Rejected;
}
}
private static NatIntroductionPlan CreatePlan(
IntroductionEndpoints endpoints,
string connectionTicket)
{
IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint);
IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint);
bool sameNat = hostPublic.Address.Equals(clientPublic.Address);
IPEndPoint hostLocal = sameNat && endpoints.Host.LocalEndpoint is { } hostCandidate
? ToIpEndpoint(hostCandidate)
: hostPublic;
IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate
? ToIpEndpoint(clientCandidate)
: clientPublic;
return new(hostLocal, hostPublic, clientLocal, clientPublic, connectionTicket);
}
private static bool TryCreateObservedEndpoint(
IPEndPoint source,
out ObservedEndpoint endpoint)
{
endpoint = default;
if (source.Port is < 1 or > 65_535)
{
return false;
}
IPAddress address = source.Address.IsIPv4MappedToIPv6
? source.Address.MapToIPv4()
: source.Address;
if (address.Equals(IPAddress.Any)
|| address.Equals(IPAddress.IPv6Any)
|| address.IsIPv6Multicast
|| IsIpv4MulticastOrBroadcast(address)
|| (address.AddressFamily == AddressFamily.InterNetworkV6
&& !IsGlobalIpv6(address)))
{
return false;
}
AddressFamilyKind family = address.AddressFamily switch
{
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
_ => default,
};
if (family == default)
{
return false;
}
endpoint = new(family, address.ToString(), source.Port);
return true;
}
private static bool TryCreatePrivateCandidate(
IPEndPoint source,
AddressFamilyKind publicFamily,
out ObservedEndpoint endpoint)
{
endpoint = default;
if (source.Port is < 1 or > 65_535)
{
return false;
}
IPAddress address = source.Address.IsIPv4MappedToIPv6
? source.Address.MapToIPv4()
: source.Address;
AddressFamilyKind family = address.AddressFamily switch
{
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
_ => default,
};
if (family != publicFamily || !IsPrivateUnicast(address))
{
return false;
}
endpoint = new(family, address.ToString(), source.Port);
return true;
}
private static bool IsPrivateUnicast(IPAddress address)
{
byte[] bytes = address.GetAddressBytes();
return address.AddressFamily switch
{
AddressFamily.InterNetwork => bytes[0] == 10
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 192 && bytes[1] == 168),
AddressFamily.InterNetworkV6 => (bytes[0] & 0xfe) == 0xfc,
_ => false,
};
}
private static bool IsGlobalIpv6(IPAddress address) =>
!address.Equals(IPAddress.IPv6Loopback)
&& !address.Equals(IPAddress.IPv6Any)
&& !address.IsIPv6LinkLocal
&& !address.IsIPv6Multicast
&& !address.IsIPv6SiteLocal
&& !IsPrivateUnicast(address)
&& !IsDocumentationIpv6(address);
private static bool IsIpv4MulticastOrBroadcast(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetwork)
{
return false;
}
byte[] bytes = address.GetAddressBytes();
return bytes[0] >= 224 || bytes.All(static value => value == byte.MaxValue);
}
private static bool IsDocumentationIpv6(IPAddress address)
{
byte[] bytes = address.GetAddressBytes();
return bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8;
}
private static IPEndPoint ToIpEndpoint(ObservedEndpoint endpoint) =>
new(IPAddress.Parse(endpoint.Address), endpoint.Port);
}
@@ -18,9 +18,17 @@ public sealed class UdpMediatorOptions
[Required]
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);
private sealed class LiteNetIntroductionSink(NatPunchModule module) : INatIntroductionSink
{
public void Introduce(NatIntroductionPlan plan) => module.NatIntroduce(
plan.HostLocal,
plan.HostPublic,
plan.ClientLocal,
plan.ClientPublic,
plan.ConnectionTicket);
}
internal enum UdpPresenceProcessingResult
private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
{
Dropped = 0,
HostPresenceAccepted = 1,
HostPresenceRejected = 2,
ClientPresenceDeferred = 3,
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,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());
}
}
@@ -0,0 +1,204 @@
using System.Net;
using System.Net.Http.Json;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
using FinalFactory.Rendezvous.Tests.State;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
public sealed class JoinAttemptHttpEndpointTests
{
[Fact]
public async Task ClientCreatesHostPollsAndCapabilityCancelsAnAttemptOverHttp()
{
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistration(),
host.PublisherCredential));
Assert.True(host.Capabilities.TryFingerprint(
session.HostPresenceCapability,
out SecretFingerprint presenceFingerprint));
Assert.True(host.Store.BindHostPresence(new(
session.HostPresenceHandle,
presenceFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
null)).Succeeded);
CreateJoinAttemptRequest request = new()
{
IdempotencyKey = "http-join-1",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = session.ListingId,
ProtocolVersion = 7,
};
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
"v1/join-attempts",
request,
ContractJson.Options);
Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode);
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
using HttpRequestMessage pollRequest = new(
HttpMethod.Get,
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
pollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage pollResponse = await host.HttpClient.SendAsync(pollRequest);
Assert.Equal(HttpStatusCode.OK, pollResponse.StatusCode);
BrowseHostJoinAttemptsResponse polled = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await pollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
HostJoinAttempt hostAttempt = Assert.Single(polled.Items);
Assert.Equal(created.AttemptId, hostAttempt.AttemptId);
Assert.NotEqual(created.ClientPunchCapability, hostAttempt.HostPunchCapability);
using HttpResponseMessage missingCapability = await host.HttpClient.DeleteAsync(
$"v1/join-attempts/{created.AttemptId}");
Assert.Equal(HttpStatusCode.BadRequest, missingCapability.StatusCode);
ApiError missingCapabilityError = Assert.IsType<ApiError>(
await missingCapability.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
Assert.Equal(RendezvousErrorCode.InvalidRequest, missingCapabilityError.Code);
using HttpRequestMessage unauthorizedCancel = new(
HttpMethod.Delete,
$"v1/join-attempts/{created.AttemptId}");
unauthorizedCancel.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedCancel);
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
using HttpRequestMessage cancelRequest = new(
HttpMethod.Delete,
$"v1/join-attempts/{created.AttemptId}");
cancelRequest.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
created.ClientPunchCapability);
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
using HttpRequestMessage emptyPollRequest = new(
HttpMethod.Get,
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest);
BrowseHostJoinAttemptsResponse empty = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
Assert.Empty(empty.Items);
}
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
{
Assert.True(result.IsSuccess, result.Message);
return Assert.IsAssignableFrom<T>(result.Value);
}
private static RegisterSessionRequest CreateRegistration() => new()
{
IdempotencyKey = "join-http-host",
GameId = new("space-game"),
EnvironmentId = new("production"),
RegionId = new("eu-central"),
ProtocolVersion = 7,
BuildVersion = "1.0.0",
DisplayName = "Join HTTP host",
Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 8, MaximumPlayers = 8 },
Metadata = new() { ["mode"] = "online-coop" },
};
private sealed class JoinHttpTestHost : IAsyncDisposable
{
private readonly WebApplication _application;
private JoinHttpTestHost(
WebApplication application,
HttpClient httpClient,
InMemoryEphemeralRendezvousStore store,
EphemeralCapabilityIssuer capabilities,
string publisherCredential)
{
_application = application;
HttpClient = httpClient;
Store = store;
Capabilities = capabilities;
PublisherCredential = publisherCredential;
}
internal HttpClient HttpClient { get; }
internal InMemoryEphemeralRendezvousStore Store { get; }
internal EphemeralCapabilityIssuer Capabilities { get; }
internal string PublisherCredential { get; }
internal static async Task<JoinHttpTestHost> StartAsync()
{
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(),
ProvisioningTestData.CreateSecrets("secret-1"),
clock.UtcNow);
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
string credential = provisioning.Credentials.Issue(principal, clock.UtcNow);
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions));
builder.Services.Configure<RouteHandlerOptions>(static options =>
options.ThrowOnBadRequest = true);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Policies);
builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(store);
builder.Services.AddSingleton<IWallClock>(clock);
builder.Services.AddSingleton(capabilities);
builder.Services.AddSingleton<ISessionCapabilityService>(capabilities);
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
WebApplication app = builder.Build();
app.UseExceptionHandler();
app.MapRendezvousContractEndpoints();
await app.StartAsync();
IServer server = app.Services.GetRequiredService<IServer>();
string address = Assert.Single(server.Features.Get<IServerAddressesFeature>()!.Addresses);
return new(
app,
new HttpClient { BaseAddress = new Uri(address) },
store,
capabilities,
credential);
}
public async ValueTask DisposeAsync()
{
HttpClient.Dispose();
await _application.StopAsync();
await _application.DisposeAsync();
}
}
}
@@ -0,0 +1,286 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
public sealed class JoinAttemptServiceTests
{
[Fact]
public void CreateIsIdempotentAndScopesDistinctRoleCredentials()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "stable-join-key");
JoinAttemptServiceResult<CreateJoinAttemptResponse> first = fixture.Service.Create(
fixture.ClientSubject,
request);
JoinAttemptServiceResult<CreateJoinAttemptResponse> replay = fixture.Service.Create(
fixture.ClientSubject,
request);
Assert.True(first.Succeeded);
Assert.True(replay.Succeeded);
Assert.Equal(first.Value!.AttemptId, replay.Value!.AttemptId);
Assert.Equal(first.Value.MediationHandle, replay.Value.MediationHandle);
Assert.Equal(first.Value.ClientPunchCapability, replay.Value.ClientPunchCapability);
Assert.True(ContractValidation.IsCapabilityValid(first.Value.ClientPunchCapability));
Assert.InRange(
first.Value.ClientPunchCapability.Length,
1,
ContractLimits.LiteNetLibNatTokenMaxCharacters);
HostJoinAttempt host = Assert.Single(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
10,
null).Value!.Items);
Assert.NotEqual(host.HostPunchCapability, first.Value.ClientPunchCapability);
Assert.DoesNotContain(first.Value.ClientPunchCapability, fixture.Sessions.Store.ToString(), StringComparison.Ordinal);
}
[Fact]
public void SameIdempotencyKeyWithDifferentRequestConflicts()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
(RegisterSessionResponse other, _) = fixture.CreateHost();
CreateJoinAttemptRequest request = fixture.Request(registration.ListingId, "reused-key");
Assert.True(fixture.Service.Create(fixture.ClientSubject, request).Succeeded);
request.ListingId = other.ListingId;
JoinAttemptServiceResult<CreateJoinAttemptResponse> conflict = fixture.Service.Create(
fixture.ClientSubject,
request);
Assert.Equal(RendezvousErrorCode.Conflict, conflict.Error);
}
[Fact]
public void CreationRejectsStaleIncompatibleAndCrossTenantListings()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
(RegisterSessionResponse active, _) = fixture.CreateHost();
CreateJoinAttemptRequest incompatible = fixture.Request(active.ListingId);
incompatible.ProtocolVersion = 8;
Assert.Equal(
RendezvousErrorCode.IncompatibleProtocol,
fixture.Service.Create(fixture.ClientSubject, incompatible).Error);
CreateJoinAttemptRequest otherTenant = fixture.Request(active.ListingId);
otherTenant.GameId = new("other-game");
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
}
[Fact]
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
fixture.Create(registration.ListingId);
fixture.Create(registration.ListingId);
fixture.Create(registration.ListingId);
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> first = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
1,
null);
Assert.True(first.Succeeded);
Assert.Single(first.Value!.Items);
Assert.NotNull(first.Value.NextCursor);
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> second = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
1,
first.Value.NextCursor);
Assert.True(second.Succeeded);
Assert.NotEqual(first.Value.Items[0].AttemptId, second.Value!.Items[0].AttemptId);
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1,
null).Error);
Assert.Equal(
RendezvousErrorCode.InvalidRequest,
fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
1,
first.Value.NextCursor + "x").Error);
(RegisterSessionResponse other, _) = fixture.CreateHost();
Assert.Equal(
RendezvousErrorCode.InvalidRequest,
fixture.Service.BrowseForHost(
other.ListingId,
ContractLimits.ContractVersion,
other.LeaseToken,
1,
first.Value.NextCursor).Error);
}
[Fact]
public void CancellationRequiresTheAttemptsClientCapabilityAndRevokesState()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
Assert.Equal(
RendezvousErrorCode.NotFound,
fixture.Service.Cancel(
created.AttemptId,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").Error);
Assert.True(fixture.Service.Cancel(
created.AttemptId,
created.ClientPunchCapability).Succeeded);
Assert.Empty(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
10,
null).Value!.Items);
}
[Fact]
public void RoleAndAttemptCapabilitiesCannotCrossWireConcurrentAttempts()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
second.ClientPunchCapability,
out SecretFingerprint secondClientFingerprint));
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
first.ClientPunchCapability,
out SecretFingerprint firstClientFingerprint));
Assert.Equal(
StoreResultCode.NotFound,
fixture.Sessions.Store.BindAttemptEndpoint(new(
firstStored.MediationHandle,
AttemptPeerRole.Client,
secondClientFingerprint,
new(AddressFamilyKind.Ipv4, "198.51.100.20", 42_000),
null)).Code);
Assert.Equal(
StoreResultCode.NotFound,
fixture.Sessions.Store.BindAttemptEndpoint(new(
firstStored.MediationHandle,
AttemptPeerRole.Host,
firstClientFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
null)).Code);
}
[Fact]
public async Task ConnectionTicketIsDistinctExpiringAndAtomicallySingleUse()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
JoinAttemptServiceResult<ConnectionTicketGrant> issued = fixture.Service.IssueConnectionTicket(
introduction.Attempt);
Assert.True(issued.Succeeded);
Assert.True(ContractValidation.IsConnectionTicketValid(issued.Value!.Ticket));
Assert.NotEqual(created.ClientPunchCapability, issued.Value.Ticket);
Assert.DoesNotContain(issued.Value.Ticket, issued.Value.ToString(), StringComparison.Ordinal);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
issued.Value.Ticket,
out SecretFingerprint ticketFingerprint));
ConsumeConnectionTicketCommand command = new(created.AttemptId, ticketFingerprint);
using ManualResetEventSlim start = new(false);
Task<StoreResult<bool>> left = Task.Run(() =>
{
start.Wait();
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
});
Task<StoreResult<bool>> right = Task.Run(() =>
{
start.Wait();
return fixture.Sessions.Store.ConsumeConnectionTicket(command);
});
start.Set();
StoreResult<bool>[] results = await Task.WhenAll(left, right);
Assert.Single(results, static result => result.Succeeded);
Assert.Single(results, static result => result.Code == StoreResultCode.ReplayRejected);
}
[Fact]
public void TicketRejectsAlteredCrossAttemptPreIntroductionAndExpiry()
{
EphemeralStoreOptions options = new()
{
ConnectionTicketLifetime = TimeSpan.FromSeconds(5),
};
using JoinAttemptFixture fixture = new(options);
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse first = fixture.Create(registration.ListingId);
CreateJoinAttemptResponse second = fixture.Create(registration.ListingId);
StoredJoinAttempt firstStored = fixture.GetAttempt(registration, first.AttemptId);
StoredJoinAttempt secondStored = fixture.GetAttempt(registration, second.AttemptId);
Assert.Equal(
StoreResultCode.Conflict,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
first.AttemptId,
firstStored.ConnectionTicketFingerprint)).Code);
Assert.Equal(
StoreResultCode.NotFound,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
second.AttemptId,
firstStored.ConnectionTicketFingerprint)).Code);
fixture.Introduce(registration, first);
fixture.Sessions.Clock.Advance(options.ConnectionTicketLifetime);
Assert.Equal(
StoreResultCode.Expired,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
first.AttemptId,
firstStored.ConnectionTicketFingerprint)).Code);
Assert.False(secondStored.ConnectionTicketConsumed);
}
[Fact]
public void TicketWindowBeginsAtIntroductionAndNeverOutlivesTheAttempt()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(15));
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
ConnectionTicketGrant ticket = Assert.IsType<ConnectionTicketGrant>(
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
Assert.Equal(created.ExpiresAt, ticket.ExpiresAt);
Assert.Equal(TimeSpan.FromSeconds(15), ticket.ExpiresAt - fixture.Sessions.Clock.UtcNow);
Assert.DoesNotContain(
introduction.Attempt.CapabilityDerivationSalt,
introduction.Attempt.ToString(),
StringComparison.Ordinal);
}
}
@@ -0,0 +1,117 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
using FinalFactory.Rendezvous.Tests.Sessions;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
internal sealed class JoinAttemptFixture : IDisposable
{
private int _sequence;
public JoinAttemptFixture(EphemeralStoreOptions? options = null)
{
Sessions = new(options);
Cursors = new();
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40"));
}
public SessionLeaseFixture Sessions { get; }
public JoinAttemptCursorCodec Cursors { get; }
public JoinAttemptService Service { get; }
public string ClientSubject { get; }
public (RegisterSessionResponse Registration, StoredListing Listing) CreateHost(bool bindPresence = true)
{
RegisterSessionResponse registration = Sessions.Register();
if (bindPresence)
{
Assert.True(Sessions.BindPresence(registration).Succeeded);
}
StoredListing listing = Sessions.Store.GetListing(registration.ListingId, false).Value!;
return (registration, listing);
}
public CreateJoinAttemptRequest Request(
SessionListingId listingId,
string? idempotencyKey = null) => new()
{
IdempotencyKey = idempotencyKey ?? $"join-{Interlocked.Increment(ref _sequence)}",
GameId = Sessions.Scope.GameId,
EnvironmentId = Sessions.Scope.EnvironmentId,
ListingId = listingId,
ProtocolVersion = 7,
};
public CreateJoinAttemptResponse Create(
SessionListingId listingId,
string? idempotencyKey = null)
{
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = Service.Create(
ClientSubject,
Request(listingId, idempotencyKey));
Assert.True(result.Succeeded);
return Assert.IsType<CreateJoinAttemptResponse>(result.Value);
}
public StoredJoinAttempt GetAttempt(
RegisterSessionResponse registration,
JoinAttemptId attemptId)
{
Assert.True(Sessions.Capabilities.TryFingerprint(
registration.LeaseToken,
out SecretFingerprint leaseFingerprint));
IReadOnlyList<StoredJoinAttempt> attempts = Sessions.Store.BrowseHostJoinAttempts(new(
registration.ListingId,
leaseFingerprint,
ContractLimits.BrowserPageMaxItems)).Value!;
return attempts.Single(attempt => attempt.AttemptId == attemptId);
}
public IntroductionEndpoints Introduce(
RegisterSessionResponse registration,
CreateJoinAttemptResponse created)
{
StoredJoinAttempt attempt = GetAttempt(registration, created.AttemptId);
HostJoinAttempt host = Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
Assert.True(Sessions.Capabilities.TryFingerprint(
host.HostPunchCapability,
out SecretFingerprint hostFingerprint));
Assert.True(Sessions.Capabilities.TryFingerprint(
created.ClientPunchCapability,
out SecretFingerprint clientFingerprint));
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
attempt.MediationHandle,
AttemptPeerRole.Host,
hostFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.20", 41_000),
null)).Succeeded);
Assert.True(Sessions.Store.BindAttemptEndpoint(new(
attempt.MediationHandle,
AttemptPeerRole.Client,
clientFingerprint,
new(AddressFamilyKind.Ipv4, "198.51.100.40", 42_000),
null)).Succeeded);
StoreResult<IntroductionEndpoints> introduced = Sessions.Store.ConsumeIntroduction(
attempt.MediationHandle);
Assert.True(introduced.Succeeded);
return introduced.Value!;
}
public void Dispose()
{
Cursors.Dispose();
Sessions.Dispose();
}
}
@@ -5,6 +5,15 @@ namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PrincipalCredentialTests
{
[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,404 @@
using System.Collections.Concurrent;
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Server.Transport;
using FinalFactory.Rendezvous.Tests.JoinAttempts;
namespace FinalFactory.Rendezvous.Tests.Server;
public sealed class NatMediationProcessorTests
{
[Fact]
public void AuthenticatedHostPresenceUsesTheObservedGameplaySocket()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost(bindPresence: false);
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
string token = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
registration.HostPresenceHandle,
registration.HostPresenceCapability);
Assert.Equal(
NatMediationResult.HostPresenceAccepted,
processor.ProcessRequest(
Endpoint("192.168.1.50", 40_000),
Endpoint("203.0.113.77", 51_234),
token,
sink));
Assert.Equal(registration.ListingId, Assert.Single(fixture.Sessions.Browse()).Definition.ListingId);
Assert.Empty(sink.Plans);
}
[Fact]
public void MatchedPeersReceiveOneIntroductionAndSameNatPrivateCandidates()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "same-nat");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
NatIntroductionPlan plan = Assert.Single(sink.Plans);
Assert.Equal(Endpoint("192.168.1.10", 41_000), plan.HostLocal);
Assert.Equal(Endpoint("192.168.1.11", 42_000), plan.ClientLocal);
Assert.Equal(Endpoint("203.0.113.20", 51_000), plan.HostPublic);
Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic);
Assert.Equal(43, plan.ConnectionTicket.Length);
Assert.DoesNotContain(plan.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
plan.ConnectionTicket,
out SecretFingerprint ticketFingerprint));
Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new(
attempt.AttemptId,
ticketFingerprint)).Succeeded);
Assert.Equal(
NatMediationResult.Duplicate,
Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("203.0.113.20", 52_000)));
Assert.Single(sink.Plans);
}
[Fact]
public void DifferentNatsAndInvalidLocalClaimsExposeOnlyObservedPublicEndpoints()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "different-nats");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
_ = Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("8.8.8.8", 42_000), Endpoint("198.51.100.40", 52_000));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
NatIntroductionPlan plan = Assert.Single(sink.Plans);
Assert.Equal(plan.HostPublic, plan.HostLocal);
Assert.Equal(plan.ClientPublic, plan.ClientLocal);
Assert.NotEqual(IPAddress.Parse("8.8.8.8"), plan.ClientLocal.Address);
}
[Fact]
public void RoleAndEndpointSubstitutionAreRejectedWithoutChangingTheFirstBinding()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "substitution");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
string crossRole = NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Host,
attempt.Handle,
attempt.ClientCapability);
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessRequest(
Endpoint("192.168.1.10", 41_000),
Endpoint("203.0.113.20", 51_000),
crossRole,
sink));
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
Assert.Equal(
NatMediationResult.Rejected,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.99", 41_999), Endpoint("203.0.113.99", 51_999)));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.2.10", 42_000), Endpoint("198.51.100.40", 52_000)));
Assert.Equal(Endpoint("203.0.113.20", 51_000), Assert.Single(sink.Plans).HostPublic);
}
[Fact]
public void ConcurrentAttemptsForOneSessionNeverCrossWire()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials first = CreateAttempt(fixture, registration, "parallel-1");
AttemptCredentials second = CreateAttempt(fixture, registration, "parallel-2");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
_ = Process(processor, sink, first, NatPunchPeerRole.Host,
Endpoint("10.0.0.10", 41_001), Endpoint("203.0.113.10", 51_001));
_ = Process(processor, sink, second, NatPunchPeerRole.Host,
Endpoint("10.0.0.20", 41_002), Endpoint("203.0.113.20", 51_002));
_ = Process(processor, sink, second, NatPunchPeerRole.Client,
Endpoint("10.0.0.21", 42_002), Endpoint("198.51.100.20", 52_002));
_ = Process(processor, sink, first, NatPunchPeerRole.Client,
Endpoint("10.0.0.11", 42_001), Endpoint("198.51.100.10", 52_001));
Assert.Equal(2, sink.Plans.Count);
Assert.Contains(sink.Plans, plan =>
plan.HostPublic.Equals(Endpoint("203.0.113.10", 51_001))
&& plan.ClientPublic.Equals(Endpoint("198.51.100.10", 52_001)));
Assert.Contains(sink.Plans, plan =>
plan.HostPublic.Equals(Endpoint("203.0.113.20", 51_002))
&& plan.ClientPublic.Equals(Endpoint("198.51.100.20", 52_002)));
}
[Fact]
public async Task ConcurrentDuplicateCompletionEmitsExactlyOneIntroduction()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "completion-race");
NatMediationProcessor processor = CreateProcessor(fixture);
ConcurrentIntroductionSink sink = new();
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
using Barrier barrier = new(2);
Task<NatMediationResult>[] completions = Enumerable.Range(0, 2)
.Select(_ => Task.Run(() =>
{
barrier.SignalAndWait();
return Process(processor, sink, attempt, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000));
}))
.ToArray();
NatMediationResult[] results = await Task.WhenAll(completions);
Assert.Single(results, result => result == NatMediationResult.Introduced);
Assert.Single(sink.Plans);
}
[Fact]
public async Task CancellationCannotReportSuccessAfterIntroductionIsConsumed()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "cancel-race");
NatMediationProcessor processor = CreateProcessor(fixture);
using BlockingIntroductionSink sink = new();
_ = Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
Task<NatMediationResult> completion = Task.Run(() => Process(
processor,
sink,
attempt,
NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000),
Endpoint("198.51.100.40", 52_000)));
Assert.True(sink.WaitUntilEntered(TimeSpan.FromSeconds(2)));
JoinAttemptServiceResult<bool> cancelled = fixture.Service.Cancel(
attempt.AttemptId,
attempt.ClientCapability);
Assert.Equal(RendezvousErrorCode.Conflict, cancelled.Error);
sink.Release();
Assert.Equal(NatMediationResult.Introduced, await completion);
}
[Fact]
public void DuplicateFloodAmortizesGlobalExpiryMaintenance()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials attempt = CreateAttempt(fixture, registration, "maintenance-budget");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
long before = fixture.Sessions.Store.MaintenanceSweepCount;
for (int index = 0; index < 256; index++)
{
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, attempt, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
}
Assert.InRange(fixture.Sessions.Store.MaintenanceSweepCount - before, 0, 1);
Assert.Empty(sink.Plans);
}
[Fact]
public void MissingStaleCancelledAndMalformedRequestsNeverIntroduce()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials stale = CreateAttempt(fixture, registration, "stale");
AttemptCredentials cancelled = CreateAttempt(fixture, registration, "cancelled");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
Assert.Equal(
NatMediationResult.WaitingForPeer,
Process(processor, sink, stale, NatPunchPeerRole.Client,
Endpoint("192.168.1.11", 42_000), Endpoint("198.51.100.40", 52_000)));
Assert.True(fixture.Service.Cancel(cancelled.AttemptId, cancelled.ClientCapability).Succeeded);
Assert.Equal(
NatMediationResult.Dropped,
Process(processor, sink, cancelled, NatPunchPeerRole.Client,
Endpoint("192.168.1.12", 42_001), Endpoint("198.51.100.41", 52_001)));
fixture.Sessions.Clock.Advance(TimeSpan.FromSeconds(21));
Assert.Equal(
NatMediationResult.Dropped,
Process(processor, sink, stale, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000)));
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessRequest(
Endpoint("192.168.1.10", 41_000),
Endpoint("203.0.113.20", 51_000),
"malformed",
sink));
Assert.Empty(sink.Plans);
}
[Fact]
public void AddressFamiliesMustMatchAndOnlyGlobalIpv6SourcesAreAccepted()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
AttemptCredentials mismatch = CreateAttempt(fixture, registration, "family-mismatch");
AttemptCredentials ipv6 = CreateAttempt(fixture, registration, "ipv6");
NatMediationProcessor processor = CreateProcessor(fixture);
CaptureIntroductionSink sink = new();
byte[] shortFrozenIpv6 = RendezvousUdpCodec.Encode(new PresenceDatagram
{
MessageType = UdpPresenceMessageType.ClientPresence,
MediationHandle = ipv6.Handle,
AddressFamily = AddressFamilyKind.Ipv6,
LocalAddress = "fd00::11",
LocalPort = 42_000,
Capability = ipv6.ClientCapability,
});
Assert.Equal(
NatMediationResult.Dropped,
processor.ProcessDatagram(
shortFrozenIpv6,
Endpoint("2606:4700:4700::1001", 52_000),
sink));
_ = Process(processor, sink, mismatch, NatPunchPeerRole.Host,
Endpoint("192.168.1.10", 41_000), Endpoint("203.0.113.20", 51_000));
Assert.Equal(
NatMediationResult.Rejected,
Process(processor, sink, mismatch, NatPunchPeerRole.Client,
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1111", 52_000)));
Assert.Equal(
NatMediationResult.Dropped,
Process(processor, sink, ipv6, NatPunchPeerRole.Host,
Endpoint("fd00::10", 41_000), Endpoint("2001:db8::10", 51_000)));
_ = Process(processor, sink, ipv6, NatPunchPeerRole.Host,
Endpoint("fd00::10", 41_000), Endpoint("2606:4700:4700::1000", 51_000));
Assert.Equal(
NatMediationResult.Introduced,
Process(processor, sink, ipv6, NatPunchPeerRole.Client,
Endpoint("fd00::11", 42_000), Endpoint("2606:4700:4700::1001", 52_000)));
Assert.Single(sink.Plans);
}
private static NatMediationProcessor CreateProcessor(JoinAttemptFixture fixture) => new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
private static AttemptCredentials CreateAttempt(
JoinAttemptFixture fixture,
RegisterSessionResponse registration,
string idempotencyKey)
{
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, idempotencyKey);
HostJoinAttempt host = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
return new(
created.AttemptId,
created.MediationHandle,
host.HostPunchCapability,
created.ClientPunchCapability);
}
private static NatMediationResult Process(
NatMediationProcessor processor,
INatIntroductionSink sink,
AttemptCredentials attempt,
NatPunchPeerRole role,
IPEndPoint local,
IPEndPoint observed) => processor.ProcessRequest(
local,
observed,
NatPunchRequestTokenCodec.Encode(
role,
attempt.Handle,
role == NatPunchPeerRole.Client
? attempt.ClientCapability
: attempt.HostCapability),
sink);
private static IPEndPoint Endpoint(string address, int port) =>
new(IPAddress.Parse(address), port);
private sealed record AttemptCredentials(
JoinAttemptId AttemptId,
MediationHandle Handle,
string HostCapability,
string ClientCapability);
private sealed class CaptureIntroductionSink : INatIntroductionSink
{
public List<NatIntroductionPlan> Plans { get; } = [];
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
}
private sealed class ConcurrentIntroductionSink : INatIntroductionSink
{
public ConcurrentBag<NatIntroductionPlan> Plans { get; } = [];
public void Introduce(NatIntroductionPlan plan) => Plans.Add(plan);
}
private sealed class BlockingIntroductionSink : INatIntroductionSink, IDisposable
{
private readonly ManualResetEventSlim _entered = new();
private readonly ManualResetEventSlim _release = new();
public void Introduce(NatIntroductionPlan plan)
{
_entered.Set();
_release.Wait(TimeSpan.FromSeconds(2));
}
public bool WaitUntilEntered(TimeSpan timeout) => _entered.Wait(timeout);
public void Release() => _release.Set();
public void Dispose()
{
_entered.Dispose();
_release.Dispose();
}
}
}
@@ -1,10 +1,9 @@
using System.Net;
using System.Net.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()
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,
};
ManualRendezvousClock clock = new();
InMemoryEphemeralRendezvousStore store = new(new EphemeralStoreOptions(), clock, clock);
using EphemeralCapabilityIssuer capabilities = new();
using UdpMediatorService service = new(
Options.Create(options),
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
store,
capabilities);
processor);
await service.StartAsync(timeout.Token);
@@ -69,9 +37,300 @@ 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.Equal(43, hostTicket.Length);
}
finally
{
host.Stop();
client.Stop();
await service.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task FrozenV1EnvelopeIsConsumedOnTheLiteNetSocketWithinAmplificationBudget()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "v1-envelope");
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await service.StartAsync(timeout.Token);
using UdpClient host = new(new IPEndPoint(IPAddress.Loopback, 0));
using UdpClient client = new(new IPEndPoint(IPAddress.Loopback, 0));
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
byte[] hostDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
{
MessageType = UdpPresenceMessageType.HostPresence,
MediationHandle = created.MediationHandle,
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.168.1.10",
LocalPort = 41_000,
Capability = hostAttempt.HostPunchCapability,
});
byte[] clientDatagram = RendezvousUdpCodec.Encode(new PresenceDatagram
{
MessageType = UdpPresenceMessageType.ClientPresence,
MediationHandle = created.MediationHandle,
AddressFamily = AddressFamilyKind.Ipv4,
LocalAddress = "192.168.1.11",
LocalPort = 42_000,
Capability = created.ClientPunchCapability,
});
try
{
await host.SendAsync(hostDatagram, mediator, timeout.Token);
await client.SendAsync(clientDatagram, mediator, timeout.Token);
UdpReceiveResult hostIntroduction = await host.ReceiveAsync(timeout.Token);
UdpReceiveResult clientIntroduction = await client.ReceiveAsync(timeout.Token);
Assert.True(
hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length
<= clientDatagram.Length * 2,
"The completing authenticated contribution exceeded the 2.0 response-byte budget.");
}
finally
{
await service.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task OversizedMalformedAndGameplayDatagramsReceiveNoResponse()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new();
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await service.StartAsync(timeout.Token);
using UdpClient sender = new(new IPEndPoint(IPAddress.Loopback, 0));
IPEndPoint mediator = Assert.IsType<IPEndPoint>(service.LocalEndpoint);
byte[] oversized = new byte[ContractLimits.UdpDatagramMaxBytes + 1];
oversized[0] = RendezvousUdpCodec.MagicFirst;
oversized[1] = RendezvousUdpCodec.MagicSecond;
byte[] gameplayPayload = [0x01, 0x02, 0x03, 0x04];
byte[] malformedNative = [17, 0];
try
{
await sender.SendAsync(oversized, mediator, timeout.Token);
await sender.SendAsync(gameplayPayload, mediator, timeout.Token);
await sender.SendAsync(malformedNative, mediator, timeout.Token);
using CancellationTokenSource noResponse = new(TimeSpan.FromMilliseconds(150));
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
await sender.ReceiveAsync(noResponse.Token));
}
finally
{
await service.StopAsync(CancellationToken.None);
}
}
[Fact]
public async Task ForgedNativeIntroductionResponseCannotReflectToPayloadEndpoint()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
using JoinAttemptFixture fixture = new();
NatMediationProcessor processor = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
fixture.Service);
using UdpMediatorService service = new(
Options.Create(new UdpMediatorOptions
{
ListenAddress = IPAddress.Loopback.ToString(),
Port = 0,
MaxDatagramsPerPoll = 8,
PollIntervalMilliseconds = 1,
}),
NullLogger<UdpMediatorService>.Instance,
processor);
await service.StartAsync(timeout.Token);
using UdpClient reflectedTarget = new(new IPEndPoint(IPAddress.Loopback, 0));
using UdpClient responseCapture = new(new IPEndPoint(IPAddress.Loopback, 0));
using UdpClient attacker = new(new IPEndPoint(IPAddress.Loopback, 0));
LiteNetManager generator = new(new EventBasedLiteNetListener()) { NatPunchEnabled = true };
try
{
Assert.True(generator.Start(0));
IPEndPoint target = (IPEndPoint)reflectedTarget.Client.LocalEndPoint!;
IPEndPoint capture = (IPEndPoint)responseCapture.Client.LocalEndPoint!;
generator.NatPunchModule.NatIntroduce(
target,
new IPEndPoint(IPAddress.Loopback, 9),
capture,
capture,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
byte[] forgedResponse = (await responseCapture.ReceiveAsync(timeout.Token)).Buffer;
await attacker.SendAsync(
forgedResponse,
Assert.IsType<IPEndPoint>(service.LocalEndpoint),
timeout.Token);
using CancellationTokenSource noReflection = new(TimeSpan.FromMilliseconds(150));
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
await reflectedTarget.ReceiveAsync(noReflection.Token));
}
finally
{
generator.Stop();
await service.StopAsync(CancellationToken.None);
}
}
}
@@ -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());
}
}
}
@@ -1,3 +1,17 @@
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult
ENUM Accepted=1
ENUM NotFound=2
ENUM Expired=3
ENUM Rejected=4
ENUM AlreadyConsumed=5
ENUM Revoked=6
TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator
CTOR (System.Int32 maximumAuthorizedTickets)
METHOD FinalFactory.Rendezvous.Client.ConnectionTicketConsumptionResult Consume(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
METHOD System.Void Dispose()
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
METHOD System.String ToString()
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
@@ -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
@@ -171,6 +173,20 @@ 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.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;}