feat(client): add rendezvous traversal coordinators (#12)
quality-gate / quality (push) Successful in 56s
quality-gate / quality (push) Successful in 56s
This commit is contained in:
@@ -1054,6 +1054,7 @@
|
|||||||
"attemptId",
|
"attemptId",
|
||||||
"mediationHandle",
|
"mediationHandle",
|
||||||
"clientPunchCapability",
|
"clientPunchCapability",
|
||||||
|
"connectionTicketDigest",
|
||||||
"expiresAt"
|
"expiresAt"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1071,6 +1072,9 @@
|
|||||||
"clientPunchCapability": {
|
"clientPunchCapability": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"connectionTicketDigest": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
@@ -1146,6 +1150,8 @@
|
|||||||
"attemptId",
|
"attemptId",
|
||||||
"mediationHandle",
|
"mediationHandle",
|
||||||
"hostPunchCapability",
|
"hostPunchCapability",
|
||||||
|
"connectionTicketDigest",
|
||||||
|
"isCancelled",
|
||||||
"expiresAt"
|
"expiresAt"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1159,6 +1165,12 @@
|
|||||||
"hostPunchCapability": {
|
"hostPunchCapability": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"connectionTicketDigest": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"isCancelled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"expiresAt": {
|
"expiresAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
|
|||||||
@@ -24,18 +24,24 @@ credentials plus opaque attempt and mediation IDs from a process-ephemeral HMAC
|
|||||||
key, the client subject, the complete canonical request fingerprint, a fresh salt,
|
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),
|
and a purpose/role label. Credentials are 32-byte base64url values (43 characters),
|
||||||
below both the 192-character Rendezvous capability ceiling and LiteNetLib's
|
below both the 192-character Rendezvous capability ceiling and LiteNetLib's
|
||||||
256-character NAT token ceiling. State retains keyed credential fingerprints,
|
256-character NAT token ceiling. The connection ticket uses half of that payload
|
||||||
derivation inputs, and salt—not issued plaintext. All diagnostic string
|
for its attempt ID and half for an independently derived 128-bit authenticator, so
|
||||||
representations redact credentials and derivation material.
|
the SDK can correlate concurrent introductions without increasing UDP response
|
||||||
|
size. State retains keyed credential fingerprints, derivation inputs, and salt—not
|
||||||
|
issued plaintext. All diagnostic string representations redact credentials and
|
||||||
|
derivation material.
|
||||||
|
|
||||||
The client receives only its punch capability. A host polls its own listing with
|
The 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
|
the lease token in `X-Rendezvous-Lease-Token` and receives only host-role
|
||||||
capabilities through a signed, listing-bound, five-minute cursor. Replaying an
|
capabilities through a signed, listing-bound, five-minute cursor. Replaying an
|
||||||
identical join request returns the same live attempt; changing the request under
|
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
|
the same owner/key conflicts. A client may cancel with its punch capability in
|
||||||
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically removes the
|
`X-Rendezvous-Client-Punch-Capability`; cancellation atomically marks the attempt
|
||||||
attempt. Listing deletion, expiry, revocation, or process restart removes every
|
and retains a bounded tombstone until its original expiry. Host polling returns
|
||||||
associated attempt and credential fingerprint.
|
that tombstone so a coordinator can revoke any local ticket authorization, while
|
||||||
|
endpoint binding, introduction, ticket issuance, and ticket consumption all
|
||||||
|
reject the cancelled attempt. Listing deletion, expiry, revocation, or process
|
||||||
|
restart removes every associated attempt and credential fingerprint.
|
||||||
|
|
||||||
Endpoint binding remains role- and capability-specific. The first endpoint
|
Endpoint binding remains role- and capability-specific. The first endpoint
|
||||||
observed for a role wins atomically; an exact UDP duplicate is idempotent, while
|
observed for a role wins atomically; an exact UDP duplicate is idempotent, while
|
||||||
@@ -50,8 +56,14 @@ fingerprint-consumption seam for mediator tests and revocation. On the game host
|
|||||||
the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest,
|
the SDK's bounded `ConnectionTicketValidator` stores a process-keyed digest,
|
||||||
accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/
|
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.
|
revoked/replayed tickets, and zeroes retained digests and key material on disposal.
|
||||||
Issue #11 carries the ticket in the authenticated introduction; issue #12 wires
|
Issue #11 carries the fixed-size ticket in the authenticated introduction. Issue
|
||||||
authorization and consumption into the caller-owned LiteNetLib coordinator.
|
#12 extracts its embedded attempt ID, bounds the host's local authorization window
|
||||||
|
by both the host-polled attempt expiry and the configured ticket lifetime, then
|
||||||
|
wires one-time consumption into the caller-owned coordinator. Both peers receive
|
||||||
|
a digest of the exact expected ticket over HTTP and reject any syntactically valid
|
||||||
|
but unauthenticated introduction token. Embedding the ID prevents concurrent or
|
||||||
|
late introductions from cross-binding a valid ticket while preserving the
|
||||||
|
mediator's 2.0 response-byte amplification ceiling.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousJoinClient : IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
private const string LeaseTokenHeader = "X-Rendezvous-Lease-Token";
|
||||||
|
private const string ClientPunchCapabilityHeader = "X-Rendezvous-Client-Punch-Capability";
|
||||||
|
|
||||||
|
private readonly RendezvousHttpTransport _transport;
|
||||||
|
|
||||||
|
public RendezvousJoinClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
RendezvousClientOptions? options = null,
|
||||||
|
IRendezvousDelay? delay = null)
|
||||||
|
{
|
||||||
|
_transport = new(httpClient, options, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(request));
|
||||||
|
}
|
||||||
|
CreateJoinAttemptRequest body = new()
|
||||||
|
{
|
||||||
|
ContractVersion = request.ContractVersion,
|
||||||
|
IdempotencyKey = request.IdempotencyKey,
|
||||||
|
GameId = request.GameId,
|
||||||
|
EnvironmentId = request.EnvironmentId,
|
||||||
|
ListingId = request.ListingId,
|
||||||
|
ProtocolVersion = request.ProtocolVersion,
|
||||||
|
};
|
||||||
|
return _transport.SendSafeAsync<CreateJoinAttemptResponse>(
|
||||||
|
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/join-attempts", body),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (attempt is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(attempt));
|
||||||
|
}
|
||||||
|
return _transport.SendSafeAsync<bool>(
|
||||||
|
() => HeaderRequest(
|
||||||
|
HttpMethod.Delete,
|
||||||
|
$"v1/join-attempts/{attempt.AttemptId}",
|
||||||
|
ClientPunchCapabilityHeader,
|
||||||
|
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt))),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
if (pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
string query = $"v1/sessions/{session.ListingId}/join-attempts"
|
||||||
|
+ $"?contractVersion={ContractLimits.ContractVersion}"
|
||||||
|
+ $"&pageSize={pageSize}"
|
||||||
|
+ (cursor is null ? string.Empty : $"&cursor={Uri.EscapeDataString(cursor)}");
|
||||||
|
return _transport.SendSafeAsync<BrowseHostJoinAttemptsResponse>(
|
||||||
|
() => HeaderRequest(
|
||||||
|
HttpMethod.Get,
|
||||||
|
query,
|
||||||
|
LeaseTokenHeader,
|
||||||
|
RequireHeaderValue(session.LeaseToken, nameof(session))),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(session));
|
||||||
|
}
|
||||||
|
if (maximumPages is < 1 or > 1_000)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maximumPages));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<HostJoinAttempt> attempts = [];
|
||||||
|
string? cursor = null;
|
||||||
|
for (int page = 0; page < maximumPages; page++)
|
||||||
|
{
|
||||||
|
RendezvousClientResult<BrowseHostJoinAttemptsResponse> result =
|
||||||
|
await BrowseForHostAsync(
|
||||||
|
session,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
cursor,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.AddRange(result.Value.Items);
|
||||||
|
cursor = result.Value.NextCursor;
|
||||||
|
if (string.IsNullOrEmpty(cursor))
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Success<IReadOnlyList<HostJoinAttempt>>(
|
||||||
|
attempts.AsReadOnly());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
|
||||||
|
RendezvousErrorCode.CapacityExceeded,
|
||||||
|
$"Host invitation polling exceeded the configured {maximumPages}-page limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage HeaderRequest(
|
||||||
|
HttpMethod method,
|
||||||
|
string uri,
|
||||||
|
string header,
|
||||||
|
string value)
|
||||||
|
{
|
||||||
|
HttpRequestMessage request = new(method, uri);
|
||||||
|
request.Headers.TryAddWithoutValidation(header, value);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RequireHeaderValue(string value, string parameterName) =>
|
||||||
|
!string.IsNullOrWhiteSpace(value)
|
||||||
|
? value
|
||||||
|
: throw new ArgumentException("The required capability is missing.", parameterName);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# FinalFactory.Rendezvous.Client
|
# FinalFactory.Rendezvous.Client
|
||||||
|
|
||||||
Godot-independent .NET publisher and session-browser SDK for Rendezvous v1.
|
Godot-independent .NET publisher, browser, join, and LiteNetLib traversal SDK for Rendezvous v1.
|
||||||
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
|
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
@@ -53,10 +53,75 @@ string presenceToken = NatPunchRequestTokenCodec.Encode(
|
|||||||
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
|
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
|
||||||
```
|
```
|
||||||
|
|
||||||
The same codec creates `Host` tokens for host-polled invitations and `Client`
|
For direct connections, let the SDK drive those tokens from the same caller-owned
|
||||||
tokens for a created join attempt. Always send them from the same LiteNetLib
|
LiteNetLib socket that carries gameplay. Ask the routing listener to create the
|
||||||
socket that will carry the direct game connection; the mediator ignores any
|
bound manager, then configure and start that caller-owned manager yourself. The
|
||||||
caller-supplied public endpoint.
|
factory does not open a socket, and synchronized events must remain enabled:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager gameplayNetManager = networkEvents.CreateManager();
|
||||||
|
if (!gameplayNetManager.Start(0))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The gameplay UDP socket could not start.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The host polls join invitations asynchronously; that method only queues a
|
||||||
|
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
|
||||||
|
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
|
||||||
|
frame on the thread that owns the manager:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
RendezvousJoinClient joins = new(http);
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
gameplayNetManager,
|
||||||
|
networkEvents,
|
||||||
|
mediatorEndPoint,
|
||||||
|
session,
|
||||||
|
joins);
|
||||||
|
|
||||||
|
// Run periodically from the game's normal async scheduling path.
|
||||||
|
await host.RefreshJoinAttemptsAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Godot _Process, Update, or the equivalent main-thread frame callback.
|
||||||
|
host.Poll();
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not also call `gameplayNetManager.PollEvents()` or
|
||||||
|
`gameplayNetManager.NatPunchModule.PollEvents()` when a coordinator owns polling.
|
||||||
|
The host coordinator refreshes host presence, punches for queued invitations,
|
||||||
|
validates the introduction ticket, and accepts the direct request. Subscribe to
|
||||||
|
`AttemptCompleted`; a `Connected` result is raised only after LiteNetLib reports
|
||||||
|
the accepted peer as connected. Register ordinary gameplay callbacks on
|
||||||
|
`networkEvents.GameplayEvents`; the routing listener reserves Rendezvous direct
|
||||||
|
requests for ticket validation and forwards every other callback normally.
|
||||||
|
|
||||||
|
The joining game first creates the HTTP attempt, then uses its own already-started
|
||||||
|
gameplay manager in the same frame loop:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
CreateJoinAttemptResponse attempt = (await joins.CreateAsync(
|
||||||
|
createJoinRequest,
|
||||||
|
cancellationToken)).Value
|
||||||
|
?? throw new InvalidOperationException("Join issuance failed.");
|
||||||
|
using RendezvousClientCoordinator client = new(
|
||||||
|
gameplayNetManager,
|
||||||
|
networkEvents,
|
||||||
|
mediatorEndPoint,
|
||||||
|
attempt);
|
||||||
|
|
||||||
|
// Godot _Process, Update, or the equivalent main-thread frame callback.
|
||||||
|
client.Poll();
|
||||||
|
```
|
||||||
|
|
||||||
|
NAT introduction changes the client state to `Connecting`; it is not success.
|
||||||
|
Only `Connected` supplies `ConnectedPeer`. Call `Cancel()` and then `Poll()` for
|
||||||
|
local cancellation, or `CancelAsync(joins, cancellationToken)` to also revoke the
|
||||||
|
service attempt. Terminal client paths release all event subscriptions. Disposing
|
||||||
|
a coordinator never stops or disposes the caller-owned manager and does not touch
|
||||||
|
an in-flight peer; call `Cancel()` followed by `Poll()` first when that peer must
|
||||||
|
also be disconnected.
|
||||||
|
|
||||||
Lease renewal is explicit and caller-controlled:
|
Lease renewal is explicit and caller-controlled:
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ public static class RendezvousClientResult
|
|||||||
|
|
||||||
public sealed class PublishedSession
|
public sealed class PublishedSession
|
||||||
{
|
{
|
||||||
|
private readonly object _timingGate = new();
|
||||||
|
private DateTimeOffset _expiresAt;
|
||||||
|
private int _leaseRenewAfterSeconds;
|
||||||
|
|
||||||
internal PublishedSession(RegisterSessionResponse response)
|
internal PublishedSession(RegisterSessionResponse response)
|
||||||
{
|
{
|
||||||
ListingId = response.ListingId;
|
ListingId = response.ListingId;
|
||||||
@@ -49,8 +53,8 @@ public sealed class PublishedSession
|
|||||||
LeaseToken = response.LeaseToken;
|
LeaseToken = response.LeaseToken;
|
||||||
HostPresenceHandle = response.HostPresenceHandle;
|
HostPresenceHandle = response.HostPresenceHandle;
|
||||||
HostPresenceCapability = response.HostPresenceCapability;
|
HostPresenceCapability = response.HostPresenceCapability;
|
||||||
ExpiresAt = response.ExpiresAt;
|
_expiresAt = response.ExpiresAt;
|
||||||
LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
_leaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
||||||
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
|
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +63,41 @@ public sealed class PublishedSession
|
|||||||
public string LeaseToken { get; }
|
public string LeaseToken { get; }
|
||||||
public MediationHandle HostPresenceHandle { get; }
|
public MediationHandle HostPresenceHandle { get; }
|
||||||
public string HostPresenceCapability { get; }
|
public string HostPresenceCapability { get; }
|
||||||
public DateTimeOffset ExpiresAt { get; internal set; }
|
public DateTimeOffset ExpiresAt
|
||||||
public int LeaseRenewAfterSeconds { get; internal set; }
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
return _expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internal set
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
_expiresAt = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int LeaseRenewAfterSeconds
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
return _leaseRenewAfterSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
internal set
|
||||||
|
{
|
||||||
|
lock (_timingGate)
|
||||||
|
{
|
||||||
|
_leaseRenewAfterSeconds = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
public int HostPresenceRefreshAfterSeconds { get; }
|
public int HostPresenceRefreshAfterSeconds { get; }
|
||||||
|
|
||||||
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
|
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
|
||||||
@@ -109,6 +146,28 @@ public interface IRendezvousSessionBrowserClient
|
|||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
public interface IRendezvousDelay
|
public interface IRendezvousDelay
|
||||||
{
|
{
|
||||||
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Text;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class DirectConnectionRequest
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId { get; set; }
|
||||||
|
public string ConnectionTicket { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[DirectConnectionRequest {AttemptId}; ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DirectConnectionRequestCodec
|
||||||
|
{
|
||||||
|
public const int EncodedLength = 63;
|
||||||
|
|
||||||
|
private const int MagicLength = 4;
|
||||||
|
private const int AttemptIdLength = 16;
|
||||||
|
private const int TicketLength = ContractLimits.DerivedCredentialCharacters;
|
||||||
|
private static readonly byte[] Magic = [(byte)'R', (byte)'V', (byte)'D', (byte)'1'];
|
||||||
|
|
||||||
|
public static bool IsRendezvousRequest(ReadOnlySpan<byte> encoded) =>
|
||||||
|
encoded.Length >= MagicLength && encoded[..MagicLength].SequenceEqual(Magic);
|
||||||
|
|
||||||
|
public static byte[] Encode(JoinAttemptId attemptId, string connectionTicket)
|
||||||
|
{
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| connectionTicket is null
|
||||||
|
|| connectionTicket.Length != TicketLength
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The direct connection request fields are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] encoded = new byte[EncodedLength];
|
||||||
|
Magic.CopyTo(encoded, 0);
|
||||||
|
if (!attemptId.Value.TryWriteBytes(encoded.AsSpan(MagicLength, AttemptIdLength)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Encoding.ASCII.GetBytes(
|
||||||
|
connectionTicket,
|
||||||
|
0,
|
||||||
|
connectionTicket.Length,
|
||||||
|
encoded,
|
||||||
|
MagicLength + AttemptIdLength);
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(
|
||||||
|
ReadOnlySpan<byte> encoded,
|
||||||
|
out DirectConnectionRequest? request)
|
||||||
|
{
|
||||||
|
request = null;
|
||||||
|
if (encoded.Length != EncodedLength
|
||||||
|
|| !encoded[..MagicLength].SequenceEqual(Magic))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid attemptId = new(encoded.Slice(MagicLength, AttemptIdLength));
|
||||||
|
if (attemptId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string ticket = Encoding.ASCII.GetString(encoded[(MagicLength + AttemptIdLength)..]);
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(ticket))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
request = new DirectConnectionRequest
|
||||||
|
{
|
||||||
|
AttemptId = new JoinAttemptId(attemptId),
|
||||||
|
ConnectionTicket = ticket,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousClientCoordinator : IDisposable
|
||||||
|
{
|
||||||
|
private readonly NetManager _manager;
|
||||||
|
private readonly RendezvousNetListener _networkEvents;
|
||||||
|
private readonly EventBasedNatPunchListener _punchEvents;
|
||||||
|
private readonly IPEndPoint _mediator;
|
||||||
|
private readonly CreateJoinAttemptResponse _attempt;
|
||||||
|
private readonly IRendezvousCoordinatorClock _clock;
|
||||||
|
private readonly RendezvousPunchRetrySchedule _retry;
|
||||||
|
private NetPeer? _connectingPeer;
|
||||||
|
private bool _cancelRequested;
|
||||||
|
private int _polling;
|
||||||
|
private bool _subscriptionsReleased;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public RendezvousClientCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousCoordinatorOptions? options = null)
|
||||||
|
: this(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
mediator,
|
||||||
|
attempt,
|
||||||
|
options,
|
||||||
|
new SystemRendezvousCoordinatorClock())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousClientCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
RendezvousCoordinatorOptions? options,
|
||||||
|
IRendezvousCoordinatorClock clock)
|
||||||
|
{
|
||||||
|
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||||
|
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
|
||||||
|
_punchEvents = _networkEvents.PunchEvents;
|
||||||
|
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
|
_attempt = attempt ?? throw new ArgumentNullException(nameof(attempt));
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
RendezvousCoordinatorOptions validated = (options ?? new RendezvousCoordinatorOptions())
|
||||||
|
.CopyAndValidate();
|
||||||
|
_retry = new(validated, _clock);
|
||||||
|
|
||||||
|
RendezvousManagerGuard.Validate(_manager, _networkEvents);
|
||||||
|
if (_mediator.Port is < 1 or > 65_535
|
||||||
|
|| _attempt.AttemptId.Value == Guid.Empty
|
||||||
|
|| _attempt.MediationHandle.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(_attempt.ClientPunchCapability)
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(_attempt.ConnectionTicketDigest)
|
||||||
|
|| _attempt.ExpiresAt <= _clock.UtcNow)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The client traversal inputs are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
|
||||||
|
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<RendezvousConnectionCompletedEventArgs>? Completed;
|
||||||
|
|
||||||
|
public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching;
|
||||||
|
public NetPeer? ConnectedPeer { get; private set; }
|
||||||
|
public bool IsCompleted => IsTerminal(State);
|
||||||
|
|
||||||
|
public void Cancel() => Volatile.Write(ref _cancelRequested, true);
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (joinClient is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(joinClient));
|
||||||
|
}
|
||||||
|
|
||||||
|
ThrowIfDisposed();
|
||||||
|
Cancel();
|
||||||
|
return await joinClient.CancelAsync(_attempt, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Poll()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (IsCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Interlocked.Exchange(ref _polling, 1) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _cancelRequested))
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(RendezvousConnectionState.Cancelled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
Complete(RendezvousConnectionState.ManagerStopped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.PollEvents();
|
||||||
|
_manager.NatPunchModule.PollEvents();
|
||||||
|
if (IsCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
if (Volatile.Read(ref _cancelRequested))
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(RendezvousConnectionState.Cancelled);
|
||||||
|
}
|
||||||
|
else if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
Complete(RendezvousConnectionState.ManagerStopped);
|
||||||
|
}
|
||||||
|
else if (now >= _attempt.ExpiresAt)
|
||||||
|
{
|
||||||
|
DisconnectPendingPeer();
|
||||||
|
Complete(RendezvousConnectionState.TimedOut);
|
||||||
|
}
|
||||||
|
else if (State == RendezvousConnectionState.Punching && _retry.IsDue(now))
|
||||||
|
{
|
||||||
|
if (_retry.IsExhausted)
|
||||||
|
{
|
||||||
|
Complete(RendezvousConnectionState.TimedOut);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
_mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Client,
|
||||||
|
_attempt.MediationHandle,
|
||||||
|
_attempt.ClientPunchCapability));
|
||||||
|
_retry.RecordRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _polling, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsCompleted)
|
||||||
|
{
|
||||||
|
Complete(RendezvousConnectionState.Disposed);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseSubscriptions();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[RendezvousClientCoordinator {_attempt.AttemptId}; credentials redacted]";
|
||||||
|
|
||||||
|
private void OnNatIntroductionSuccess(
|
||||||
|
IPEndPoint target,
|
||||||
|
NatAddressType addressType,
|
||||||
|
string encodedIntroduction)
|
||||||
|
{
|
||||||
|
_ = addressType;
|
||||||
|
if (State != RendezvousConnectionState.Punching
|
||||||
|
|| !NatIntroductionTokenCodec.TryDecode(
|
||||||
|
encodedIntroduction,
|
||||||
|
out NatIntroductionToken? introduction)
|
||||||
|
|| introduction is null
|
||||||
|
|| introduction.AttemptId != _attempt.AttemptId
|
||||||
|
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||||
|
introduction.ConnectionTicket,
|
||||||
|
_attempt.ConnectionTicketDigest))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] connectionData = DirectConnectionRequestCodec.Encode(
|
||||||
|
introduction.AttemptId,
|
||||||
|
introduction.ConnectionTicket);
|
||||||
|
_connectingPeer = _manager.Connect(target, connectionData);
|
||||||
|
if (_connectingPeer is null
|
||||||
|
|| _connectingPeer.ConnectionState != ConnectionState.Outgoing)
|
||||||
|
{
|
||||||
|
_connectingPeer = null;
|
||||||
|
Complete(RendezvousConnectionState.Rejected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
State = RendezvousConnectionState.Connecting;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerConnected(NetPeer peer)
|
||||||
|
{
|
||||||
|
if (State != RendezvousConnectionState.Connecting
|
||||||
|
|| !ReferenceEquals(peer, _connectingPeer))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectedPeer = peer;
|
||||||
|
Complete(RendezvousConnectionState.Connected, peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||||
|
{
|
||||||
|
_ = disconnectInfo;
|
||||||
|
if (State == RendezvousConnectionState.Connecting
|
||||||
|
&& ReferenceEquals(peer, _connectingPeer))
|
||||||
|
{
|
||||||
|
Complete(RendezvousConnectionState.Rejected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisconnectPendingPeer()
|
||||||
|
{
|
||||||
|
if (_connectingPeer is not null && State == RendezvousConnectionState.Connecting)
|
||||||
|
{
|
||||||
|
_connectingPeer.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Complete(RendezvousConnectionState terminalState, NetPeer? peer = null)
|
||||||
|
{
|
||||||
|
if (IsCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
State = terminalState;
|
||||||
|
ReleaseSubscriptions();
|
||||||
|
Completed?.Invoke(this, new(terminalState, peer));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseSubscriptions()
|
||||||
|
{
|
||||||
|
if (_subscriptionsReleased)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
|
||||||
|
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
|
||||||
|
_subscriptionsReleased = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTerminal(RendezvousConnectionState state) => state is
|
||||||
|
RendezvousConnectionState.Connected
|
||||||
|
or RendezvousConnectionState.Cancelled
|
||||||
|
or RendezvousConnectionState.TimedOut
|
||||||
|
or RendezvousConnectionState.Rejected
|
||||||
|
or RendezvousConnectionState.ManagerStopped
|
||||||
|
or RendezvousConnectionState.Disposed;
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousClientCoordinator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum RendezvousConnectionState
|
||||||
|
{
|
||||||
|
Punching = 1,
|
||||||
|
Connecting = 2,
|
||||||
|
Connected = 3,
|
||||||
|
Cancelled = 4,
|
||||||
|
TimedOut = 5,
|
||||||
|
Rejected = 6,
|
||||||
|
ManagerStopped = 7,
|
||||||
|
Disposed = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousConnectionCompletedEventArgs(
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
NetPeer? peer = null) : EventArgs
|
||||||
|
{
|
||||||
|
public RendezvousConnectionState State { get; } = state;
|
||||||
|
public NetPeer? Peer { get; } = peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
public int MaximumPunchRequests { get; set; } = 5;
|
||||||
|
public int MaximumAttemptChecksPerPoll { get; set; } = 128;
|
||||||
|
public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||||
|
public TimeSpan MaximumPunchRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||||
|
public TimeSpan ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20);
|
||||||
|
public double JitterRatio { get; set; } = 0.2;
|
||||||
|
|
||||||
|
internal RendezvousCoordinatorOptions CopyAndValidate()
|
||||||
|
{
|
||||||
|
if (MaximumPunchRequests is < 1 or > 20
|
||||||
|
|| MaximumAttemptChecksPerPoll is < 1 or > 1_024
|
||||||
|
|| InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10)
|
||||||
|
|| MaximumPunchRetryDelay < InitialPunchRetryDelay
|
||||||
|
|| MaximumPunchRetryDelay > TimeSpan.FromSeconds(10)
|
||||||
|
|| ConnectionTicketLifetime <= TimeSpan.Zero
|
||||||
|
|| ConnectionTicketLifetime > TimeSpan.FromSeconds(20)
|
||||||
|
|| JitterRatio is < 0 or > 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
MaximumPunchRequests = MaximumPunchRequests,
|
||||||
|
MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll,
|
||||||
|
InitialPunchRetryDelay = InitialPunchRetryDelay,
|
||||||
|
MaximumPunchRetryDelay = MaximumPunchRetryDelay,
|
||||||
|
ConnectionTicketLifetime = ConnectionTicketLifetime,
|
||||||
|
JitterRatio = JitterRatio,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface IRendezvousCoordinatorClock
|
||||||
|
{
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class RendezvousManagerGuard
|
||||||
|
{
|
||||||
|
internal static void Validate(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents)
|
||||||
|
{
|
||||||
|
networkEvents.ValidateManager(manager);
|
||||||
|
if (!manager.IsRunning)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The caller-owned LiteNetLib manager must be running.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manager.NatPunchEnabled
|
||||||
|
|| manager.UnsyncedEvents
|
||||||
|
|| manager.NatPunchModule.UnsyncedEvents)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The caller-owned manager must enable NAT punching and synchronized event dispatch.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RendezvousPunchRetrySchedule(
|
||||||
|
RendezvousCoordinatorOptions options,
|
||||||
|
IRendezvousCoordinatorClock clock)
|
||||||
|
{
|
||||||
|
public int RequestsSent { get; private set; }
|
||||||
|
public DateTimeOffset NextRequestAt { get; private set; } = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests;
|
||||||
|
|
||||||
|
public bool IsDue(DateTimeOffset now) => now >= NextRequestAt;
|
||||||
|
|
||||||
|
public void RecordRequest()
|
||||||
|
{
|
||||||
|
int exponent = Math.Min(RequestsSent, 30);
|
||||||
|
RequestsSent++;
|
||||||
|
double milliseconds = Math.Min(
|
||||||
|
options.InitialPunchRetryDelay.TotalMilliseconds * Math.Pow(2, exponent),
|
||||||
|
options.MaximumPunchRetryDelay.TotalMilliseconds);
|
||||||
|
if (options.JitterRatio > 0)
|
||||||
|
{
|
||||||
|
Span<byte> random = stackalloc byte[1];
|
||||||
|
RandomNumberGenerator.Fill(random);
|
||||||
|
double unit = random[0] / 255d;
|
||||||
|
double multiplier = 1 - options.JitterRatio + (2 * options.JitterRatio * unit);
|
||||||
|
milliseconds = Math.Min(
|
||||||
|
milliseconds * multiplier,
|
||||||
|
options.MaximumPunchRetryDelay.TotalMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
NextRequestAt = clock.UtcNow + TimeSpan.FromMilliseconds(milliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public enum RendezvousHostState
|
||||||
|
{
|
||||||
|
Active = 1,
|
||||||
|
ManagerStopped = 2,
|
||||||
|
Disposed = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousHostAttemptCompletedEventArgs(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
NetPeer? peer = null) : EventArgs
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId { get; } = attemptId;
|
||||||
|
public RendezvousConnectionState State { get; } = state;
|
||||||
|
public NetPeer? Peer { get; } = peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RendezvousHostCoordinator : IDisposable
|
||||||
|
{
|
||||||
|
private readonly NetManager _manager;
|
||||||
|
private readonly RendezvousNetListener _networkEvents;
|
||||||
|
private readonly EventBasedNatPunchListener _punchEvents;
|
||||||
|
private readonly IPEndPoint _mediator;
|
||||||
|
private readonly PublishedSession _session;
|
||||||
|
private readonly IRendezvousJoinClient _joinClient;
|
||||||
|
private readonly RendezvousCoordinatorOptions _options;
|
||||||
|
private readonly IRendezvousCoordinatorClock _clock;
|
||||||
|
private readonly ConnectionTicketValidator _tickets;
|
||||||
|
private readonly Dictionary<JoinAttemptId, PendingHostAttempt> _attempts = [];
|
||||||
|
private readonly Dictionary<NetPeer, JoinAttemptId> _acceptedPeers = [];
|
||||||
|
private readonly Dictionary<JoinAttemptId, DeferredConnectionRequest> _deferredRequests = [];
|
||||||
|
private readonly Dictionary<JoinAttemptId, DateTimeOffset> _terminalAttempts = [];
|
||||||
|
private readonly Queue<JoinAttemptId> _attemptSchedule = [];
|
||||||
|
private readonly List<JoinAttemptId> _cleanupScratch = [];
|
||||||
|
private HostJoinAttempt[]? _latestSnapshot;
|
||||||
|
private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue;
|
||||||
|
private DateTimeOffset _nextTerminalCleanupAt = DateTimeOffset.MinValue;
|
||||||
|
private int _refreshing;
|
||||||
|
private int _polling;
|
||||||
|
private bool _subscriptionsReleased;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
public RendezvousHostCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
PublishedSession session,
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
RendezvousCoordinatorOptions? options = null)
|
||||||
|
: this(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
mediator,
|
||||||
|
session,
|
||||||
|
joinClient,
|
||||||
|
options,
|
||||||
|
new SystemRendezvousCoordinatorClock(),
|
||||||
|
null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousHostCoordinator(
|
||||||
|
NetManager manager,
|
||||||
|
RendezvousNetListener networkEvents,
|
||||||
|
IPEndPoint mediator,
|
||||||
|
PublishedSession session,
|
||||||
|
IRendezvousJoinClient joinClient,
|
||||||
|
RendezvousCoordinatorOptions? options,
|
||||||
|
IRendezvousCoordinatorClock clock,
|
||||||
|
ConnectionTicketValidator? tickets)
|
||||||
|
{
|
||||||
|
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||||
|
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
|
||||||
|
_punchEvents = _networkEvents.PunchEvents;
|
||||||
|
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||||
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||||
|
_joinClient = joinClient ?? throw new ArgumentNullException(nameof(joinClient));
|
||||||
|
_options = (options ?? new RendezvousCoordinatorOptions()).CopyAndValidate();
|
||||||
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||||
|
_tickets = tickets ?? new ConnectionTicketValidator();
|
||||||
|
|
||||||
|
RendezvousManagerGuard.Validate(_manager, _networkEvents);
|
||||||
|
ValidateInputs();
|
||||||
|
_networkEvents.RendezvousConnectionRequest += OnConnectionRequest;
|
||||||
|
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
|
||||||
|
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<RendezvousHostAttemptCompletedEventArgs>? AttemptCompleted;
|
||||||
|
|
||||||
|
public RendezvousHostState State { get; private set; } = RendezvousHostState.Active;
|
||||||
|
public int PendingAttemptCount => _attempts.Count;
|
||||||
|
internal int DeferredRequestCount => _deferredRequests.Count;
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<int>> RefreshJoinAttemptsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (Interlocked.Exchange(ref _refreshing, 1) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("A host invitation refresh is already running.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
|
||||||
|
await _joinClient.BrowseAllForHostAsync(
|
||||||
|
_session,
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!result.IsSuccess || result.Value is null)
|
||||||
|
{
|
||||||
|
return RendezvousClientResult.Failure<int>(
|
||||||
|
result.Error,
|
||||||
|
result.Message,
|
||||||
|
result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
HostJoinAttempt[] snapshot = result.Value.Select(CopyAttempt).ToArray();
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Exchange(ref _latestSnapshot, snapshot);
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _latestSnapshot, null);
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||||
|
}
|
||||||
|
|
||||||
|
return RendezvousClientResult.Success(snapshot.Length);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _refreshing, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Poll()
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Interlocked.Exchange(ref _polling, 1) != 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ApplySnapshots();
|
||||||
|
if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.NatPunchModule.PollEvents();
|
||||||
|
_manager.PollEvents();
|
||||||
|
_manager.NatPunchModule.PollEvents();
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
if (!_manager.IsRunning)
|
||||||
|
{
|
||||||
|
Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshPresence(now);
|
||||||
|
int checks = Math.Min(
|
||||||
|
_attemptSchedule.Count,
|
||||||
|
_options.MaximumAttemptChecksPerPoll);
|
||||||
|
for (int index = 0; index < checks; index++)
|
||||||
|
{
|
||||||
|
JoinAttemptId attemptId = _attemptSchedule.Dequeue();
|
||||||
|
if (!_attempts.TryGetValue(attemptId, out PendingHostAttempt? attempt))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= attempt.Invitation.ExpiresAt)
|
||||||
|
{
|
||||||
|
CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.State == RendezvousConnectionState.Punching
|
||||||
|
&& attempt.Retry.IsDue(now))
|
||||||
|
{
|
||||||
|
if (attempt.Retry.IsExhausted)
|
||||||
|
{
|
||||||
|
CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
_mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.Host,
|
||||||
|
attempt.Invitation.MediationHandle,
|
||||||
|
attempt.Invitation.HostPunchCapability));
|
||||||
|
attempt.Retry.RecordRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
_attemptSchedule.Enqueue(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= _nextTerminalCleanupAt)
|
||||||
|
{
|
||||||
|
_cleanupScratch.Clear();
|
||||||
|
foreach (KeyValuePair<JoinAttemptId, DateTimeOffset> terminal in _terminalAttempts)
|
||||||
|
{
|
||||||
|
if (terminal.Value <= now)
|
||||||
|
{
|
||||||
|
_cleanupScratch.Add(terminal.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JoinAttemptId attemptId in _cleanupScratch)
|
||||||
|
{
|
||||||
|
_terminalAttempts.Remove(attemptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextTerminalCleanupAt = now + TimeSpan.FromSeconds(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref _polling, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stop(RendezvousHostState.Disposed, RendezvousConnectionState.Disposed);
|
||||||
|
Interlocked.Exchange(ref _latestSnapshot, null);
|
||||||
|
_attemptSchedule.Clear();
|
||||||
|
_terminalAttempts.Clear();
|
||||||
|
_cleanupScratch.Clear();
|
||||||
|
_tickets.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[RendezvousHostCoordinator {_session.ListingId}; credentials redacted]";
|
||||||
|
|
||||||
|
private void ApplySnapshots()
|
||||||
|
{
|
||||||
|
HostJoinAttempt[]? latest = Interlocked.Exchange(ref _latestSnapshot, null);
|
||||||
|
|
||||||
|
if (latest is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset now = _clock.UtcNow;
|
||||||
|
foreach (HostJoinAttempt invitation in latest)
|
||||||
|
{
|
||||||
|
if (invitation.AttemptId.Value == Guid.Empty
|
||||||
|
|| invitation.MediationHandle.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(invitation.HostPunchCapability)
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(
|
||||||
|
invitation.ConnectionTicketDigest))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.IsCancelled)
|
||||||
|
{
|
||||||
|
if (_attempts.ContainsKey(invitation.AttemptId))
|
||||||
|
{
|
||||||
|
CompleteAttempt(
|
||||||
|
invitation.AttemptId,
|
||||||
|
RendezvousConnectionState.Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
_terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.ExpiresAt <= now
|
||||||
|
|| _attempts.ContainsKey(invitation.AttemptId)
|
||||||
|
|| _terminalAttempts.ContainsKey(invitation.AttemptId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_attempts.Add(
|
||||||
|
invitation.AttemptId,
|
||||||
|
new PendingHostAttempt(
|
||||||
|
CopyAttempt(invitation),
|
||||||
|
new RendezvousPunchRetrySchedule(_options, _clock)));
|
||||||
|
_attemptSchedule.Enqueue(invitation.AttemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshPresence(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (now < _nextPresenceAt || now >= _session.ExpiresAt)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||||
|
_mediator,
|
||||||
|
NatPunchRequestTokenCodec.Encode(
|
||||||
|
NatPunchPeerRole.HostPresence,
|
||||||
|
_session.HostPresenceHandle,
|
||||||
|
_session.HostPresenceCapability));
|
||||||
|
_nextPresenceAt = now + TimeSpan.FromSeconds(_session.HostPresenceRefreshAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNatIntroductionSuccess(
|
||||||
|
IPEndPoint target,
|
||||||
|
NatAddressType addressType,
|
||||||
|
string encodedIntroduction)
|
||||||
|
{
|
||||||
|
_ = target;
|
||||||
|
_ = addressType;
|
||||||
|
if (!NatIntroductionTokenCodec.TryDecode(
|
||||||
|
encodedIntroduction,
|
||||||
|
out NatIntroductionToken? introduction)
|
||||||
|
|| introduction is null
|
||||||
|
|| !_attempts.TryGetValue(introduction.AttemptId, out PendingHostAttempt? attempt)
|
||||||
|
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||||
|
introduction.ConnectionTicket,
|
||||||
|
attempt.Invitation.ConnectionTicketDigest)
|
||||||
|
|| !_tickets.TryAuthorize(
|
||||||
|
introduction.AttemptId,
|
||||||
|
introduction.ConnectionTicket,
|
||||||
|
Min(
|
||||||
|
attempt.Invitation.ExpiresAt,
|
||||||
|
_clock.UtcNow + _options.ConnectionTicketLifetime)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt.State = RendezvousConnectionState.Connecting;
|
||||||
|
if (_deferredRequests.Remove(
|
||||||
|
introduction.AttemptId,
|
||||||
|
out DeferredConnectionRequest? deferred))
|
||||||
|
{
|
||||||
|
AcceptAuthorizedRequest(
|
||||||
|
introduction.AttemptId,
|
||||||
|
attempt,
|
||||||
|
deferred.Request,
|
||||||
|
deferred.ConnectionTicket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnConnectionRequest(ConnectionRequest request)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<byte> data = request.Data.GetRemainingBytesSpan();
|
||||||
|
if (!DirectConnectionRequestCodec.IsRendezvousRequest(data))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DirectConnectionRequestCodec.TryDecode(data, out DirectConnectionRequest? connection)
|
||||||
|
|| connection is null
|
||||||
|
|| !_attempts.TryGetValue(connection.AttemptId, out PendingHostAttempt? attempt)
|
||||||
|
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||||
|
connection.ConnectionTicket,
|
||||||
|
attempt.Invitation.ConnectionTicketDigest))
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.State == RendezvousConnectionState.Punching)
|
||||||
|
{
|
||||||
|
_deferredRequests[connection.AttemptId] = new(
|
||||||
|
request,
|
||||||
|
connection.ConnectionTicket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.State != RendezvousConnectionState.Connecting)
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AcceptAuthorizedRequest(
|
||||||
|
connection.AttemptId,
|
||||||
|
attempt,
|
||||||
|
request,
|
||||||
|
connection.ConnectionTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerConnected(NetPeer peer)
|
||||||
|
{
|
||||||
|
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
|
||||||
|
{
|
||||||
|
CompleteAttempt(attemptId, RendezvousConnectionState.Connected, peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||||
|
{
|
||||||
|
_ = disconnectInfo;
|
||||||
|
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
|
||||||
|
{
|
||||||
|
CompleteAttempt(attemptId, RendezvousConnectionState.Rejected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteAttempt(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
RendezvousConnectionState state,
|
||||||
|
NetPeer? peer = null)
|
||||||
|
{
|
||||||
|
if (!_attempts.Remove(attemptId, out PendingHostAttempt? attempt))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt.AcceptedPeer is not null)
|
||||||
|
{
|
||||||
|
_acceptedPeers.Remove(attempt.AcceptedPeer);
|
||||||
|
}
|
||||||
|
|
||||||
|
_deferredRequests.Remove(attemptId);
|
||||||
|
_tickets.Revoke(attemptId);
|
||||||
|
_terminalAttempts[attemptId] = attempt.Invitation.ExpiresAt;
|
||||||
|
AttemptCompleted?.Invoke(this, new(attemptId, state, peer));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Stop(RendezvousHostState hostState, RendezvousConnectionState attemptState)
|
||||||
|
{
|
||||||
|
if (State != RendezvousHostState.Active)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
State = hostState;
|
||||||
|
foreach (JoinAttemptId attemptId in _attempts.Keys.ToArray())
|
||||||
|
{
|
||||||
|
CompleteAttempt(attemptId, attemptState);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReleaseSubscriptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReleaseSubscriptions()
|
||||||
|
{
|
||||||
|
if (_subscriptionsReleased)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_networkEvents.RendezvousConnectionRequest -= OnConnectionRequest;
|
||||||
|
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
|
||||||
|
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
|
||||||
|
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
|
||||||
|
_subscriptionsReleased = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateInputs()
|
||||||
|
{
|
||||||
|
if (_mediator.Port is < 1 or > 65_535
|
||||||
|
|| _session.HostPresenceHandle.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsCapabilityValid(_session.HostPresenceCapability)
|
||||||
|
|| _session.HostPresenceRefreshAfterSeconds < 1
|
||||||
|
|| _session.ExpiresAt <= _clock.UtcNow)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The host traversal inputs are invalid.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HostJoinAttempt CopyAttempt(HostJoinAttempt attempt) => new()
|
||||||
|
{
|
||||||
|
AttemptId = attempt.AttemptId,
|
||||||
|
MediationHandle = attempt.MediationHandle,
|
||||||
|
HostPunchCapability = attempt.HostPunchCapability,
|
||||||
|
ConnectionTicketDigest = attempt.ConnectionTicketDigest,
|
||||||
|
IsCancelled = attempt.IsCancelled,
|
||||||
|
ExpiresAt = attempt.ExpiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) =>
|
||||||
|
left <= right ? left : right;
|
||||||
|
|
||||||
|
private void AcceptAuthorizedRequest(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
PendingHostAttempt attempt,
|
||||||
|
ConnectionRequest request,
|
||||||
|
string connectionTicket)
|
||||||
|
{
|
||||||
|
ConnectionTicketConsumptionResult consumption = _tickets.Consume(
|
||||||
|
attemptId,
|
||||||
|
connectionTicket);
|
||||||
|
if (consumption != ConnectionTicketConsumptionResult.Accepted)
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NetPeer peer = request.Accept();
|
||||||
|
attempt.AcceptedPeer = peer;
|
||||||
|
_acceptedPeers[peer] = attemptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PendingHostAttempt(
|
||||||
|
HostJoinAttempt invitation,
|
||||||
|
RendezvousPunchRetrySchedule retry)
|
||||||
|
{
|
||||||
|
internal HostJoinAttempt Invitation { get; } = invitation;
|
||||||
|
internal RendezvousPunchRetrySchedule Retry { get; } = retry;
|
||||||
|
internal RendezvousConnectionState State { get; set; } = RendezvousConnectionState.Punching;
|
||||||
|
internal NetPeer? AcceptedPeer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DeferredConnectionRequest(
|
||||||
|
ConnectionRequest request,
|
||||||
|
string connectionTicket)
|
||||||
|
{
|
||||||
|
internal ConnectionRequest Request { get; } = request;
|
||||||
|
internal string ConnectionTicket { get; } = connectionTicket;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using LiteNetLib;
|
||||||
|
using LiteNetLib.Utils;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousNetListener : INetEventListener
|
||||||
|
{
|
||||||
|
private NetManager? _manager;
|
||||||
|
|
||||||
|
public EventBasedNetListener GameplayEvents { get; } = new();
|
||||||
|
public EventBasedNatPunchListener PunchEvents { get; } = new();
|
||||||
|
|
||||||
|
public NetManager CreateManager()
|
||||||
|
{
|
||||||
|
if (_manager is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"This Rendezvous listener is already bound to a LiteNetLib manager.");
|
||||||
|
}
|
||||||
|
|
||||||
|
NetManager manager = new(this) { NatPunchEnabled = true };
|
||||||
|
manager.NatPunchModule.Init(PunchEvents);
|
||||||
|
_manager = manager;
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal event Action<NetPeer>? RendezvousPeerConnected;
|
||||||
|
internal event Action<NetPeer, DisconnectInfo>? RendezvousPeerDisconnected;
|
||||||
|
internal event Action<ConnectionRequest>? RendezvousConnectionRequest;
|
||||||
|
|
||||||
|
internal void ValidateManager(NetManager manager)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(_manager, manager))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The LiteNetLib manager must be created by this Rendezvous listener.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPeerConnected(NetPeer peer)
|
||||||
|
{
|
||||||
|
RendezvousPeerConnected?.Invoke(peer);
|
||||||
|
((INetEventListener)GameplayEvents).OnPeerConnected(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||||
|
{
|
||||||
|
RendezvousPeerDisconnected?.Invoke(peer, disconnectInfo);
|
||||||
|
((INetEventListener)GameplayEvents).OnPeerDisconnected(peer, disconnectInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnNetworkError(IPEndPoint endPoint, SocketError socketError) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkError(endPoint, socketError);
|
||||||
|
|
||||||
|
public void OnNetworkReceive(
|
||||||
|
NetPeer peer,
|
||||||
|
NetPacketReader reader,
|
||||||
|
byte channelNumber,
|
||||||
|
DeliveryMethod deliveryMethod) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkReceive(
|
||||||
|
peer,
|
||||||
|
reader,
|
||||||
|
channelNumber,
|
||||||
|
deliveryMethod);
|
||||||
|
|
||||||
|
public void OnNetworkReceiveUnconnected(
|
||||||
|
IPEndPoint remoteEndPoint,
|
||||||
|
NetPacketReader reader,
|
||||||
|
UnconnectedMessageType messageType) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkReceiveUnconnected(
|
||||||
|
remoteEndPoint,
|
||||||
|
reader,
|
||||||
|
messageType);
|
||||||
|
|
||||||
|
public void OnNetworkLatencyUpdate(NetPeer peer, int latency) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNetworkLatencyUpdate(peer, latency);
|
||||||
|
|
||||||
|
public void OnConnectionRequest(ConnectionRequest request)
|
||||||
|
{
|
||||||
|
int position = request.Data.Position;
|
||||||
|
bool isRendezvous = DirectConnectionRequestCodec.IsRendezvousRequest(
|
||||||
|
request.Data.GetRemainingBytesSpan());
|
||||||
|
request.Data.SetPosition(position);
|
||||||
|
if (!isRendezvous)
|
||||||
|
{
|
||||||
|
((INetEventListener)GameplayEvents).OnConnectionRequest(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Action<ConnectionRequest>? handler = RendezvousConnectionRequest;
|
||||||
|
if (handler is null)
|
||||||
|
{
|
||||||
|
request.RejectForce([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnMessageDelivered(NetPeer peer, object userData) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnMessageDelivered(peer, userData);
|
||||||
|
|
||||||
|
public void OnNtpResponse(NtpPacket packet) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnNtpResponse(packet);
|
||||||
|
|
||||||
|
public void OnPeerAddressChanged(NetPeer peer, IPEndPoint previousAddress) =>
|
||||||
|
((INetEventListener)GameplayEvents).OnPeerAddressChanged(peer, previousAddress);
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ public sealed class CreateJoinAttemptResponse
|
|||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string ClientPunchCapability { get; set; } = string.Empty;
|
public string ClientPunchCapability { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string ConnectionTicketDigest { get; set; } = string.Empty;
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
public NetworkEndpoint? DedicatedFallback { get; set; }
|
public NetworkEndpoint? DedicatedFallback { get; set; }
|
||||||
@@ -53,6 +56,12 @@ public sealed class HostJoinAttempt
|
|||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public string HostPunchCapability { get; set; } = string.Empty;
|
public string HostPunchCapability { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public string ConnectionTicketDigest { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonRequired]
|
||||||
|
public bool IsCancelled { get; set; }
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public DateTimeOffset ExpiresAt { get; set; }
|
public DateTimeOffset ExpiresAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
public sealed class NatIntroductionToken
|
||||||
|
{
|
||||||
|
public JoinAttemptId AttemptId { get; set; }
|
||||||
|
public string ConnectionTicket { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"[NatIntroductionToken {AttemptId}; ticket redacted]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NatIntroductionTokenCodec
|
||||||
|
{
|
||||||
|
public const int EncodedLength = ContractLimits.DerivedCredentialCharacters;
|
||||||
|
|
||||||
|
private const int DecodedLength = 32;
|
||||||
|
private const int AttemptIdLength = 16;
|
||||||
|
private const int AuthenticatorLength = DecodedLength - AttemptIdLength;
|
||||||
|
|
||||||
|
public static string Encode(JoinAttemptId attemptId, string derivedAuthenticator)
|
||||||
|
{
|
||||||
|
if (attemptId.Value == Guid.Empty
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(derivedAuthenticator)
|
||||||
|
|| !TryDecodeBase64Url(derivedAuthenticator, out byte[]? authenticator)
|
||||||
|
|| authenticator.Length != DecodedLength)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The NAT introduction token fields are invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] payload = new byte[DecodedLength];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!attemptId.Value.TryWriteBytes(payload.AsSpan(0, AttemptIdLength)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticator.AsSpan(0, AuthenticatorLength).CopyTo(payload.AsSpan(AttemptIdLength));
|
||||||
|
return EncodeBase64Url(payload);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(authenticator);
|
||||||
|
CryptographicOperations.ZeroMemory(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryDecode(string? encoded, out NatIntroductionToken? token)
|
||||||
|
{
|
||||||
|
token = null;
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(encoded)
|
||||||
|
|| !TryDecodeBase64Url(encoded!, out byte[]? payload)
|
||||||
|
|| payload.Length != DecodedLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Guid attemptId = new(payload.AsSpan(0, AttemptIdLength));
|
||||||
|
if (attemptId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
token = new NatIntroductionToken
|
||||||
|
{
|
||||||
|
AttemptId = new JoinAttemptId(attemptId),
|
||||||
|
ConnectionTicket = encoded!,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ComputeDigest(string connectionTicket)
|
||||||
|
{
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("The connection ticket is invalid.", nameof(connectionTicket));
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] encoded = Encoding.ASCII.GetBytes(connectionTicket);
|
||||||
|
byte[] digest;
|
||||||
|
using (SHA256 sha256 = SHA256.Create())
|
||||||
|
{
|
||||||
|
digest = sha256.ComputeHash(encoded);
|
||||||
|
}
|
||||||
|
CryptographicOperations.ZeroMemory(encoded);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return EncodeBase64Url(digest);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(digest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool MatchesDigest(string? connectionTicket, string? expectedDigest)
|
||||||
|
{
|
||||||
|
if (!ContractValidation.IsConnectionTicketValid(connectionTicket)
|
||||||
|
|| !ContractValidation.IsConnectionTicketValid(expectedDigest))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] actual = Encoding.ASCII.GetBytes(ComputeDigest(connectionTicket!));
|
||||||
|
byte[] expected = Encoding.ASCII.GetBytes(expectedDigest!);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(actual);
|
||||||
|
CryptographicOperations.ZeroMemory(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDecodeBase64Url(string? encoded, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = [];
|
||||||
|
if (encoded is null || encoded.Length != EncodedLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(
|
||||||
|
encoded.Replace('-', '+').Replace('_', '/') + "=");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EncodeBase64Url(byte[] value) =>
|
||||||
|
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
}
|
||||||
@@ -66,7 +66,15 @@ internal sealed class JoinAttemptService(
|
|||||||
string derivationSalt = capabilities.CreateDerivationSalt();
|
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||||
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt);
|
||||||
string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt);
|
JoinAttemptId attemptId = new(capabilities.DeriveGuid(
|
||||||
|
"join-attempt-id",
|
||||||
|
clientSubject,
|
||||||
|
request.IdempotencyKey,
|
||||||
|
requestFingerprint,
|
||||||
|
derivationSalt));
|
||||||
|
string connectionTicket = NatIntroductionTokenCodec.Encode(
|
||||||
|
attemptId,
|
||||||
|
Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt));
|
||||||
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|
if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket)
|
||||||
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|
|| !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint)
|
||||||
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|
|| !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint)
|
||||||
@@ -75,12 +83,6 @@ internal sealed class JoinAttemptService(
|
|||||||
throw new InvalidOperationException("Derived join credentials violated their contract invariants.");
|
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(
|
MediationHandle mediationHandle = new(capabilities.DeriveGuid(
|
||||||
"join-mediation-handle",
|
"join-mediation-handle",
|
||||||
clientSubject,
|
clientSubject,
|
||||||
@@ -126,6 +128,8 @@ internal sealed class JoinAttemptService(
|
|||||||
AttemptId = persisted.AttemptId,
|
AttemptId = persisted.AttemptId,
|
||||||
MediationHandle = persisted.MediationHandle,
|
MediationHandle = persisted.MediationHandle,
|
||||||
ClientPunchCapability = clientCapability,
|
ClientPunchCapability = clientCapability,
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
CreateConnectionTicket(persisted)),
|
||||||
ExpiresAt = persisted.ExpiresAt,
|
ExpiresAt = persisted.ExpiresAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -203,7 +207,7 @@ internal sealed class JoinAttemptService(
|
|||||||
StoredJoinAttempt attempt)
|
StoredJoinAttempt attempt)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(attempt);
|
ArgumentNullException.ThrowIfNull(attempt);
|
||||||
if (!attempt.IntroductionConsumed)
|
if (!attempt.IntroductionConsumed || attempt.IsCancelled)
|
||||||
{
|
{
|
||||||
return new(RendezvousErrorCode.Conflict);
|
return new(RendezvousErrorCode.Conflict);
|
||||||
}
|
}
|
||||||
@@ -213,12 +217,7 @@ internal sealed class JoinAttemptService(
|
|||||||
return new(RendezvousErrorCode.Expired);
|
return new(RendezvousErrorCode.Expired);
|
||||||
}
|
}
|
||||||
|
|
||||||
string ticket = Derive(
|
string ticket = CreateConnectionTicket(attempt);
|
||||||
"connection-ticket",
|
|
||||||
attempt.ClientSubject,
|
|
||||||
attempt.IdempotencyKey,
|
|
||||||
attempt.RequestFingerprint,
|
|
||||||
attempt.CapabilityDerivationSalt);
|
|
||||||
if (!ContractValidation.IsConnectionTicketValid(ticket)
|
if (!ContractValidation.IsConnectionTicketValid(ticket)
|
||||||
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|
|| !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint)
|
||||||
|| fingerprint != attempt.ConnectionTicketFingerprint)
|
|| fingerprint != attempt.ConnectionTicketFingerprint)
|
||||||
@@ -249,10 +248,23 @@ internal sealed class JoinAttemptService(
|
|||||||
AttemptId = attempt.AttemptId,
|
AttemptId = attempt.AttemptId,
|
||||||
MediationHandle = attempt.MediationHandle,
|
MediationHandle = attempt.MediationHandle,
|
||||||
HostPunchCapability = capability,
|
HostPunchCapability = capability,
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
CreateConnectionTicket(attempt)),
|
||||||
|
IsCancelled = attempt.IsCancelled,
|
||||||
ExpiresAt = attempt.ExpiresAt,
|
ExpiresAt = attempt.ExpiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string CreateConnectionTicket(StoredJoinAttempt attempt) =>
|
||||||
|
NatIntroductionTokenCodec.Encode(
|
||||||
|
attempt.AttemptId,
|
||||||
|
Derive(
|
||||||
|
"connection-ticket",
|
||||||
|
attempt.ClientSubject,
|
||||||
|
attempt.IdempotencyKey,
|
||||||
|
attempt.RequestFingerprint,
|
||||||
|
attempt.CapabilityDerivationSalt));
|
||||||
|
|
||||||
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
|
private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request)
|
||||||
{
|
{
|
||||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ internal sealed record StoredJoinAttempt
|
|||||||
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
||||||
public required bool IntroductionConsumed { get; init; }
|
public required bool IntroductionConsumed { get; init; }
|
||||||
public required bool ConnectionTicketConsumed { get; init; }
|
public required bool ConnectionTicketConsumed { get; init; }
|
||||||
|
public required bool IsCancelled { get; init; }
|
||||||
|
|
||||||
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
|
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
IReadOnlyList<StoredJoinAttempt> attempts = _attempts.Values
|
IReadOnlyList<StoredJoinAttempt> attempts = _attempts.Values
|
||||||
.Where(entry => entry.Command.ListingId == query.ListingId
|
.Where(entry => entry.Command.ListingId == query.ListingId
|
||||||
&& !entry.IntroductionConsumed
|
&& (!entry.IntroductionConsumed || entry.IsCancelled)
|
||||||
&& (!query.AfterAttemptId.HasValue
|
&& (!query.AfterAttemptId.HasValue
|
||||||
|| entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0))
|
|| entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0))
|
||||||
.OrderBy(static entry => entry.Command.AttemptId.Value)
|
.OrderBy(static entry => entry.Command.AttemptId.Value)
|
||||||
@@ -451,12 +451,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.NotFound);
|
return new(StoreResultCode.NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt.IntroductionConsumed)
|
if (attempt.IsCancelled)
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.Conflict);
|
return new(StoreResultCode.Success, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
RemoveAttempt(command.AttemptId);
|
attempt.IsCancelled = true;
|
||||||
return new(StoreResultCode.Success, true);
|
return new(StoreResultCode.Success, true);
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
@@ -480,7 +480,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId)
|
if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId)
|
||||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|
||||||
|| attempt.Deadline <= now)
|
|| attempt.Deadline <= now
|
||||||
|
|| attempt.IsCancelled)
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.NotFound);
|
return new(StoreResultCode.NotFound);
|
||||||
}
|
}
|
||||||
@@ -532,7 +533,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
|
|
||||||
if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId)
|
if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId)
|
||||||
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|
|| !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt)
|
||||||
|| attempt.Deadline <= now)
|
|| attempt.Deadline <= now
|
||||||
|
|| attempt.IsCancelled)
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.NotFound);
|
return new(StoreResultCode.NotFound);
|
||||||
}
|
}
|
||||||
@@ -590,6 +592,11 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
return new(StoreResultCode.Conflict);
|
return new(StoreResultCode.Conflict);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attempt.IsCancelled)
|
||||||
|
{
|
||||||
|
return new(StoreResultCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now)
|
if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now)
|
||||||
{
|
{
|
||||||
return new(StoreResultCode.Expired);
|
return new(StoreResultCode.Expired);
|
||||||
@@ -874,6 +881,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
ClientEndpoint = entry.ClientEndpoint,
|
ClientEndpoint = entry.ClientEndpoint,
|
||||||
IntroductionConsumed = entry.IntroductionConsumed,
|
IntroductionConsumed = entry.IntroductionConsumed,
|
||||||
ConnectionTicketConsumed = entry.ConnectionTicketConsumed,
|
ConnectionTicketConsumed = entry.ConnectionTicketConsumed,
|
||||||
|
IsCancelled = entry.IsCancelled,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
|
private static void RemoveExpired(Dictionary<string, TimeSpan> entries, TimeSpan now)
|
||||||
@@ -1020,6 +1028,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
|||||||
public AttemptEndpointBinding? ClientEndpoint { get; set; }
|
public AttemptEndpointBinding? ClientEndpoint { get; set; }
|
||||||
public bool IntroductionConsumed { get; set; }
|
public bool IntroductionConsumed { get; set; }
|
||||||
public bool ConnectionTicketConsumed { get; set; }
|
public bool ConnectionTicketConsumed { get; set; }
|
||||||
|
public bool IsCancelled { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record IdempotencyEntry(
|
private sealed record IdempotencyEntry(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ internal sealed record NatIntroductionPlan(
|
|||||||
IPEndPoint HostPublic,
|
IPEndPoint HostPublic,
|
||||||
IPEndPoint ClientLocal,
|
IPEndPoint ClientLocal,
|
||||||
IPEndPoint ClientPublic,
|
IPEndPoint ClientPublic,
|
||||||
string ConnectionTicket)
|
string IntroductionToken)
|
||||||
{
|
{
|
||||||
public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]";
|
public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]";
|
||||||
}
|
}
|
||||||
@@ -185,7 +185,7 @@ internal sealed class NatMediationProcessor(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value.Ticket));
|
introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value));
|
||||||
return NatMediationResult.Introduced;
|
return NatMediationResult.Introduced;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is SocketException
|
catch (Exception exception) when (exception is SocketException
|
||||||
@@ -198,7 +198,7 @@ internal sealed class NatMediationProcessor(
|
|||||||
|
|
||||||
private static NatIntroductionPlan CreatePlan(
|
private static NatIntroductionPlan CreatePlan(
|
||||||
IntroductionEndpoints endpoints,
|
IntroductionEndpoints endpoints,
|
||||||
string connectionTicket)
|
ConnectionTicketGrant ticket)
|
||||||
{
|
{
|
||||||
IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint);
|
IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint);
|
||||||
IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint);
|
IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint);
|
||||||
@@ -209,7 +209,12 @@ internal sealed class NatMediationProcessor(
|
|||||||
IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate
|
IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate
|
||||||
? ToIpEndpoint(clientCandidate)
|
? ToIpEndpoint(clientCandidate)
|
||||||
: clientPublic;
|
: clientPublic;
|
||||||
return new(hostLocal, hostPublic, clientLocal, clientPublic, connectionTicket);
|
return new(
|
||||||
|
hostLocal,
|
||||||
|
hostPublic,
|
||||||
|
clientLocal,
|
||||||
|
clientPublic,
|
||||||
|
ticket.Ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryCreateObservedEndpoint(
|
private static bool TryCreateObservedEndpoint(
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ internal sealed partial class UdpMediatorService : BackgroundService
|
|||||||
plan.HostPublic,
|
plan.HostPublic,
|
||||||
plan.ClientLocal,
|
plan.ClientLocal,
|
||||||
plan.ClientPublic,
|
plan.ClientPublic,
|
||||||
plan.ConnectionTicket);
|
plan.IntroductionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
|
private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0)
|
||||||
|
|||||||
@@ -0,0 +1,616 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using LiteNetLib;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousCoordinatorBehaviorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NatIntroductionAloneDoesNotCompleteTheClientAttempt()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness harness = new(clock);
|
||||||
|
int completions = 0;
|
||||||
|
harness.Coordinator.Completed += (_, _) => completions++;
|
||||||
|
|
||||||
|
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
NatAddressType.External,
|
||||||
|
harness.IntroductionToken);
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.Connecting, harness.Coordinator.State);
|
||||||
|
Assert.False(harness.Coordinator.IsCompleted);
|
||||||
|
Assert.Equal(0, completions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClientRejectsASyntacticallyValidIntroductionWithTheWrongTicket()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness harness = new(clock);
|
||||||
|
string forged = NatIntroductionTokenCodec.Encode(
|
||||||
|
harness.AttemptId,
|
||||||
|
Credential('F'));
|
||||||
|
|
||||||
|
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
NatAddressType.External,
|
||||||
|
forged);
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.Punching, harness.Coordinator.State);
|
||||||
|
Assert.False(harness.Coordinator.IsCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness harness = new(clock);
|
||||||
|
List<RendezvousConnectionState> completions = [];
|
||||||
|
harness.Coordinator.Completed += (_, completion) => completions.Add(completion.State);
|
||||||
|
|
||||||
|
harness.Coordinator.Cancel();
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
NatAddressType.External,
|
||||||
|
harness.IntroductionToken);
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.Cancelled, harness.Coordinator.State);
|
||||||
|
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExhaustedPunchBudgetTimesOutExactlyOnceUnderAFakeClock()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
MaximumPunchRequests = 1,
|
||||||
|
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||||
|
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||||
|
JitterRatio = 0,
|
||||||
|
});
|
||||||
|
int completions = 0;
|
||||||
|
harness.Coordinator.Completed += (_, _) => completions++;
|
||||||
|
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
clock.Advance(TimeSpan.FromMilliseconds(10));
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
clock.Advance(TimeSpan.FromMinutes(1));
|
||||||
|
harness.Coordinator.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.TimedOut, harness.Coordinator.State);
|
||||||
|
Assert.Equal(1, completions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManagerShutdownAndDisposalEachReleaseTheirTerminalPathOnce()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness stopped = new(clock);
|
||||||
|
int stoppedCompletions = 0;
|
||||||
|
stopped.Coordinator.Completed += (_, _) => stoppedCompletions++;
|
||||||
|
stopped.Manager.Stop();
|
||||||
|
|
||||||
|
stopped.Coordinator.Poll();
|
||||||
|
stopped.Coordinator.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.ManagerStopped, stopped.Coordinator.State);
|
||||||
|
Assert.Equal(1, stoppedCompletions);
|
||||||
|
|
||||||
|
using ClientHarness disposed = new(clock);
|
||||||
|
int disposedCompletions = 0;
|
||||||
|
disposed.Coordinator.Completed += (_, _) => disposedCompletions++;
|
||||||
|
disposed.Coordinator.Dispose();
|
||||||
|
disposed.Coordinator.Dispose();
|
||||||
|
((INatPunchListener)disposed.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
NatAddressType.External,
|
||||||
|
disposed.IntroductionToken);
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.Disposed, disposed.Coordinator.State);
|
||||||
|
Assert.Equal(1, disposedCompletions);
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => disposed.Coordinator.Poll());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DirectConnectionRejectionProducesOneTerminalTransition()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
using ClientHarness client = new(clock);
|
||||||
|
EventBasedNetListener rejectingEvents = new();
|
||||||
|
rejectingEvents.ConnectionRequestEvent += request => request.Reject();
|
||||||
|
NetManager rejectingHost = new(rejectingEvents);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(rejectingHost.Start(0));
|
||||||
|
int completions = 0;
|
||||||
|
client.Coordinator.Completed += (_, _) => completions++;
|
||||||
|
((INatPunchListener)client.PunchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, rejectingHost.LocalPort),
|
||||||
|
NatAddressType.External,
|
||||||
|
client.IntroductionToken);
|
||||||
|
|
||||||
|
DateTime deadline = DateTime.UtcNow.AddSeconds(2);
|
||||||
|
while (!client.Coordinator.IsCompleted && DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
rejectingHost.PollEvents();
|
||||||
|
client.Coordinator.Poll();
|
||||||
|
await Task.Delay(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(RendezvousConnectionState.Rejected, client.Coordinator.State);
|
||||||
|
Assert.Equal(1, completions);
|
||||||
|
client.Coordinator.Poll();
|
||||||
|
Assert.Equal(1, completions);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
rejectingHost.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsynchronizedLiteNetCallbacksAreRejectedAtConstruction()
|
||||||
|
{
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
manager.UnsyncedEvents = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 9_050),
|
||||||
|
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CoordinatorRejectsAManagerCreatedByAnotherRoutingListener()
|
||||||
|
{
|
||||||
|
RendezvousNetListener managerEvents = new();
|
||||||
|
NetManager manager = managerEvents.CreateManager();
|
||||||
|
RendezvousNetListener mismatchedEvents = new();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
|
||||||
|
manager,
|
||||||
|
mismatchedEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 9_050),
|
||||||
|
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishedSessionTimingRemainsValidDuringConcurrentRenewalReads()
|
||||||
|
{
|
||||||
|
DateTimeOffset firstExpiry = new(2030, 1, 1, 0, 1, 0, TimeSpan.Zero);
|
||||||
|
DateTimeOffset secondExpiry = new(2030, 1, 1, 0, 2, 0, TimeSpan.Zero);
|
||||||
|
PublishedSession session = CreateSession(firstExpiry);
|
||||||
|
|
||||||
|
Task writer = Task.Run(() =>
|
||||||
|
{
|
||||||
|
for (int index = 0; index < 10_000; index++)
|
||||||
|
{
|
||||||
|
session.ExpiresAt = index % 2 == 0 ? firstExpiry : secondExpiry;
|
||||||
|
session.LeaseRenewAfterSeconds = index % 2 == 0 ? 10 : 20;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Task reader = Task.Run(() =>
|
||||||
|
{
|
||||||
|
for (int index = 0; index < 10_000; index++)
|
||||||
|
{
|
||||||
|
DateTimeOffset expiry = session.ExpiresAt;
|
||||||
|
int renewAfter = session.LeaseRenewAfterSeconds;
|
||||||
|
Assert.True(expiry == firstExpiry || expiry == secondExpiry);
|
||||||
|
Assert.True(renewAfter is 10 or 20 or 30);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.WhenAll(writer, reader);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostDoesNotMistakeAnIntroducedAttemptForCancellationWhenItLeavesPolling()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000111"));
|
||||||
|
HostJoinAttempt invitation = new()
|
||||||
|
{
|
||||||
|
AttemptId = attemptId,
|
||||||
|
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000112")),
|
||||||
|
HostPunchCapability = Credential('H'),
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
NatIntroductionTokenCodec.Encode(attemptId, Credential('T'))),
|
||||||
|
ExpiresAt = clock.UtcNow + TimeSpan.FromSeconds(30),
|
||||||
|
};
|
||||||
|
MutableJoinClient joins = new([invitation]);
|
||||||
|
using ConnectionTicketValidator tickets = new(16, clock);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||||
|
clock,
|
||||||
|
tickets);
|
||||||
|
int completions = 0;
|
||||||
|
host.AttemptCompleted += (_, _) => completions++;
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
Assert.Equal(1, host.PendingAttemptCount);
|
||||||
|
|
||||||
|
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_003),
|
||||||
|
NatAddressType.External,
|
||||||
|
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')));
|
||||||
|
joins.Attempts = [];
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(1, host.PendingAttemptCount);
|
||||||
|
Assert.Equal(0, completions);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostCancellationSnapshotRevokesAnAuthorizedTicketAndCompletesOnce()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000131"));
|
||||||
|
string ticket = NatIntroductionTokenCodec.Encode(attemptId, Credential('T'));
|
||||||
|
HostJoinAttempt invitation = CreateHostAttempt(
|
||||||
|
attemptId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000132")),
|
||||||
|
ticket,
|
||||||
|
clock.UtcNow + TimeSpan.FromSeconds(30));
|
||||||
|
MutableJoinClient joins = new([invitation]);
|
||||||
|
using ConnectionTicketValidator tickets = new(16, clock);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||||
|
clock,
|
||||||
|
tickets);
|
||||||
|
List<RendezvousConnectionState> completions = [];
|
||||||
|
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_003),
|
||||||
|
NatAddressType.External,
|
||||||
|
ticket);
|
||||||
|
|
||||||
|
invitation.IsCancelled = true;
|
||||||
|
joins.Attempts = [invitation];
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
host.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(0, host.PendingAttemptCount);
|
||||||
|
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
|
||||||
|
Assert.Equal(
|
||||||
|
ConnectionTicketConsumptionResult.Revoked,
|
||||||
|
tickets.Consume(attemptId, ticket));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostAppliesOnlyTheLatestUnpolledSnapshot()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000141"));
|
||||||
|
HostJoinAttempt invitation = CreateHostAttempt(
|
||||||
|
attemptId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000142")),
|
||||||
|
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')),
|
||||||
|
clock.UtcNow + TimeSpan.FromSeconds(30));
|
||||||
|
MutableJoinClient joins = new([invitation]);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||||
|
clock,
|
||||||
|
null);
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
joins.Attempts = [];
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
|
||||||
|
host.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(0, host.PendingAttemptCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DisposingHostDuringRefreshDropsTheLateSnapshot()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
BlockingJoinClient joins = new();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions { JitterRatio = 0 },
|
||||||
|
clock,
|
||||||
|
null);
|
||||||
|
Task<RendezvousClientResult<int>> refresh = host.RefreshJoinAttemptsAsync();
|
||||||
|
await joins.WaitUntilCalled;
|
||||||
|
|
||||||
|
host.Dispose();
|
||||||
|
joins.Complete([]);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await refresh);
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => host.Poll());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostBoundsAttemptExpiryChecksPerPoll()
|
||||||
|
{
|
||||||
|
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||||
|
RendezvousNetListener networkEvents = new();
|
||||||
|
NetManager manager = networkEvents.CreateManager();
|
||||||
|
JoinAttemptId firstId = new(Guid.Parse("00000000-0000-0000-0000-000000000151"));
|
||||||
|
JoinAttemptId secondId = new(Guid.Parse("00000000-0000-0000-0000-000000000152"));
|
||||||
|
DateTimeOffset expiresAt = clock.UtcNow + TimeSpan.FromSeconds(1);
|
||||||
|
MutableJoinClient joins = new([
|
||||||
|
CreateHostAttempt(
|
||||||
|
firstId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000153")),
|
||||||
|
NatIntroductionTokenCodec.Encode(firstId, Credential('T')),
|
||||||
|
expiresAt),
|
||||||
|
CreateHostAttempt(
|
||||||
|
secondId,
|
||||||
|
new(Guid.Parse("00000000-0000-0000-0000-000000000154")),
|
||||||
|
NatIntroductionTokenCodec.Encode(secondId, Credential('U')),
|
||||||
|
expiresAt),
|
||||||
|
]);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(manager.Start(0));
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
manager,
|
||||||
|
networkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_002),
|
||||||
|
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
|
||||||
|
joins,
|
||||||
|
new RendezvousCoordinatorOptions
|
||||||
|
{
|
||||||
|
MaximumAttemptChecksPerPoll = 1,
|
||||||
|
JitterRatio = 0,
|
||||||
|
},
|
||||||
|
clock,
|
||||||
|
null);
|
||||||
|
List<RendezvousConnectionState> completions = [];
|
||||||
|
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
clock.Advance(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
host.Poll();
|
||||||
|
|
||||||
|
Assert.Equal(1, host.PendingAttemptCount);
|
||||||
|
Assert.Equal([RendezvousConnectionState.TimedOut], completions);
|
||||||
|
host.Poll();
|
||||||
|
Assert.Equal(0, host.PendingAttemptCount);
|
||||||
|
Assert.Equal(
|
||||||
|
[RendezvousConnectionState.TimedOut, RendezvousConnectionState.TimedOut],
|
||||||
|
completions);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateJoinAttemptResponse CreateAttempt(DateTimeOffset expiresAt) => new()
|
||||||
|
{
|
||||||
|
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000101")),
|
||||||
|
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000102")),
|
||||||
|
ClientPunchCapability = Credential('C'),
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
NatIntroductionTokenCodec.Encode(
|
||||||
|
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000101")),
|
||||||
|
Credential('T'))),
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PublishedSession CreateSession(DateTimeOffset expiresAt) => new(new RegisterSessionResponse
|
||||||
|
{
|
||||||
|
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000121")),
|
||||||
|
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000122")),
|
||||||
|
LeaseToken = "lease-token",
|
||||||
|
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000123")),
|
||||||
|
HostPresenceCapability = Credential('P'),
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
LeaseRenewAfterSeconds = 30,
|
||||||
|
HostPresenceRefreshAfterSeconds = 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
private static HostJoinAttempt CreateHostAttempt(
|
||||||
|
JoinAttemptId attemptId,
|
||||||
|
MediationHandle mediationHandle,
|
||||||
|
string connectionTicket,
|
||||||
|
DateTimeOffset expiresAt) => new()
|
||||||
|
{
|
||||||
|
AttemptId = attemptId,
|
||||||
|
MediationHandle = mediationHandle,
|
||||||
|
HostPunchCapability = Credential('H'),
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(connectionTicket),
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
|
||||||
|
|
||||||
|
private sealed class ClientHarness : IDisposable
|
||||||
|
{
|
||||||
|
internal ClientHarness(
|
||||||
|
ManualCoordinatorClock clock,
|
||||||
|
RendezvousCoordinatorOptions? options = null)
|
||||||
|
{
|
||||||
|
NetworkEvents = new();
|
||||||
|
PunchEvents = NetworkEvents.PunchEvents;
|
||||||
|
Manager = NetworkEvents.CreateManager();
|
||||||
|
Assert.True(Manager.Start(0));
|
||||||
|
CreateJoinAttemptResponse attempt = CreateAttempt(clock.UtcNow + TimeSpan.FromSeconds(30));
|
||||||
|
AttemptId = attempt.AttemptId;
|
||||||
|
IntroductionToken = NatIntroductionTokenCodec.Encode(
|
||||||
|
attempt.AttemptId,
|
||||||
|
Credential('T'));
|
||||||
|
Coordinator = new(
|
||||||
|
Manager,
|
||||||
|
NetworkEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_001),
|
||||||
|
attempt,
|
||||||
|
options,
|
||||||
|
clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal RendezvousNetListener NetworkEvents { get; }
|
||||||
|
internal EventBasedNatPunchListener PunchEvents { get; }
|
||||||
|
internal NetManager Manager { get; }
|
||||||
|
internal RendezvousClientCoordinator Coordinator { get; }
|
||||||
|
internal JoinAttemptId AttemptId { get; }
|
||||||
|
internal string IntroductionToken { get; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Coordinator.Dispose();
|
||||||
|
Manager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MutableJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
internal IReadOnlyList<HostJoinAttempt> Attempts { get; set; } = attempts;
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||||
|
RendezvousClientResult.Success(Attempts));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BlockingJoinClient : IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<bool> _called = new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private readonly TaskCompletionSource<IReadOnlyList<HostJoinAttempt>> _result = new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
internal Task WaitUntilCalled => _called.Task;
|
||||||
|
|
||||||
|
internal void Complete(IReadOnlyList<HostJoinAttempt> attempts) =>
|
||||||
|
_result.SetResult(attempts);
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_called.SetResult(true);
|
||||||
|
return RendezvousClientResult.Success(await _result.Task.WaitAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManualCoordinatorClock(DateTimeOffset now) :
|
||||||
|
IRendezvousCoordinatorClock,
|
||||||
|
IConnectionTicketClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow { get; private set; } = now;
|
||||||
|
|
||||||
|
internal void Advance(TimeSpan amount) => UtcNow += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
using System.Net;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||||
|
using FinalFactory.Rendezvous.Server.State;
|
||||||
|
using FinalFactory.Rendezvous.Server.Transport;
|
||||||
|
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||||
|
using LiteNetLib;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousCoordinatorIntegrationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task CallerOwnedManagersCompleteAuthenticatedDirectConnectionAndRejectTicketReplay()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(8));
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "sdk-direct-connect");
|
||||||
|
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
NatMediationProcessor processor = new(
|
||||||
|
fixture.Sessions.Store,
|
||||||
|
fixture.Sessions.Capabilities,
|
||||||
|
fixture.Service);
|
||||||
|
using UdpMediatorService mediatorService = new(
|
||||||
|
Options.Create(new UdpMediatorOptions
|
||||||
|
{
|
||||||
|
ListenAddress = IPAddress.Loopback.ToString(),
|
||||||
|
Port = 0,
|
||||||
|
PollIntervalMilliseconds = 1,
|
||||||
|
}),
|
||||||
|
NullLogger<UdpMediatorService>.Instance,
|
||||||
|
processor);
|
||||||
|
await mediatorService.StartAsync(timeout.Token);
|
||||||
|
|
||||||
|
RendezvousNetListener hostEvents = new();
|
||||||
|
RendezvousNetListener clientEvents = new();
|
||||||
|
bool gameplayConnectionRequestHandled = false;
|
||||||
|
hostEvents.GameplayEvents.ConnectionRequestEvent += _ =>
|
||||||
|
gameplayConnectionRequestHandled = true;
|
||||||
|
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
|
||||||
|
EventBasedNatPunchListener clientPunch = clientEvents.PunchEvents;
|
||||||
|
NetManager hostManager = hostEvents.CreateManager();
|
||||||
|
NetManager clientManager = clientEvents.CreateManager();
|
||||||
|
string? introductionToken = null;
|
||||||
|
string? hostIntroductionToken = null;
|
||||||
|
clientPunch.NatIntroductionSuccess += (_, _, token) => introductionToken = token;
|
||||||
|
hostPunch.NatIntroductionSuccess += (_, _, token) => hostIntroductionToken = token;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(hostManager.Start(0));
|
||||||
|
Assert.True(clientManager.Start(0));
|
||||||
|
IPEndPoint mediator = Assert.IsType<IPEndPoint>(mediatorService.LocalEndpoint);
|
||||||
|
FakeJoinClient joinClient = new([hostAttempt]);
|
||||||
|
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
|
||||||
|
using ConnectionTicketValidator tickets = new(1_024, clock);
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
hostManager,
|
||||||
|
hostEvents,
|
||||||
|
mediator,
|
||||||
|
new PublishedSession(registration),
|
||||||
|
joinClient,
|
||||||
|
FastOptions(),
|
||||||
|
clock,
|
||||||
|
tickets);
|
||||||
|
using RendezvousClientCoordinator client = new(
|
||||||
|
clientManager,
|
||||||
|
clientEvents,
|
||||||
|
mediator,
|
||||||
|
created,
|
||||||
|
FastOptions(),
|
||||||
|
clock);
|
||||||
|
List<RendezvousHostAttemptCompletedEventArgs> hostCompletions = [];
|
||||||
|
List<RendezvousConnectionCompletedEventArgs> clientCompletions = [];
|
||||||
|
host.AttemptCompleted += (_, completion) => hostCompletions.Add(completion);
|
||||||
|
client.Completed += (_, completion) => clientCompletions.Add(completion);
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync(timeout.Token)).IsSuccess);
|
||||||
|
|
||||||
|
while ((!client.IsCompleted || hostCompletions.Count == 0)
|
||||||
|
&& !timeout.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
host.Poll();
|
||||||
|
client.Poll();
|
||||||
|
await Task.Delay(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
client.State == RendezvousConnectionState.Connected,
|
||||||
|
$"Client ended in {client.State}; host pending={host.PendingAttemptCount}; "
|
||||||
|
+ $"host completions={hostCompletions.Count}; introduction={introductionToken is not null}; "
|
||||||
|
+ $"host introduction={hostIntroductionToken is not null}; "
|
||||||
|
+ $"client digest={NatIntroductionTokenCodec.MatchesDigest(introductionToken, created.ConnectionTicketDigest)}; "
|
||||||
|
+ $"host digest={NatIntroductionTokenCodec.MatchesDigest(hostIntroductionToken, hostAttempt.ConnectionTicketDigest)}.");
|
||||||
|
Assert.NotNull(client.ConnectedPeer);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousConnectionState.Connected,
|
||||||
|
Assert.Single(clientCompletions).State);
|
||||||
|
RendezvousHostAttemptCompletedEventArgs hostCompletion = Assert.Single(hostCompletions);
|
||||||
|
Assert.Equal(created.AttemptId, hostCompletion.AttemptId);
|
||||||
|
Assert.Equal(RendezvousConnectionState.Connected, hostCompletion.State);
|
||||||
|
Assert.NotNull(hostCompletion.Peer);
|
||||||
|
Assert.False(gameplayConnectionRequestHandled);
|
||||||
|
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||||
|
introductionToken,
|
||||||
|
out NatIntroductionToken? introduction));
|
||||||
|
Assert.NotNull(introduction);
|
||||||
|
|
||||||
|
EventBasedNetListener replayEvents = new();
|
||||||
|
bool replayConnected = false;
|
||||||
|
replayEvents.PeerConnectedEvent += _ => replayConnected = true;
|
||||||
|
NetManager replayManager = new(replayEvents);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(replayManager.Start(0));
|
||||||
|
replayManager.Connect(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||||
|
DirectConnectionRequestCodec.Encode(
|
||||||
|
created.AttemptId,
|
||||||
|
introduction.ConnectionTicket));
|
||||||
|
DateTime replayDeadline = DateTime.UtcNow.AddSeconds(1);
|
||||||
|
while (DateTime.UtcNow < replayDeadline && !replayConnected)
|
||||||
|
{
|
||||||
|
host.Poll();
|
||||||
|
replayManager.PollEvents();
|
||||||
|
await Task.Delay(2, timeout.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.False(replayConnected);
|
||||||
|
Assert.False(gameplayConnectionRequestHandled);
|
||||||
|
Assert.Single(hostCompletions);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
replayManager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
hostManager.Stop();
|
||||||
|
clientManager.Stop();
|
||||||
|
await mediatorService.StopAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostDefersADirectRequestUntilTheMatchingNatIntroductionArrives()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "direct-before-nat");
|
||||||
|
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||||
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||||
|
ConnectionTicketGrant grant = Assert.IsType<ConnectionTicketGrant>(
|
||||||
|
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
|
||||||
|
RendezvousNetListener hostEvents = new();
|
||||||
|
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
|
||||||
|
EventBasedNetListener clientEvents = new();
|
||||||
|
bool clientConnected = false;
|
||||||
|
clientEvents.PeerConnectedEvent += _ => clientConnected = true;
|
||||||
|
NetManager hostManager = hostEvents.CreateManager();
|
||||||
|
NetManager clientManager = new(clientEvents);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(hostManager.Start(0));
|
||||||
|
Assert.True(clientManager.Start(0));
|
||||||
|
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
|
||||||
|
FakeJoinClient joins = new([hostAttempt]);
|
||||||
|
using ConnectionTicketValidator tickets = new(16, clock);
|
||||||
|
using RendezvousHostCoordinator host = new(
|
||||||
|
hostManager,
|
||||||
|
hostEvents,
|
||||||
|
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||||
|
new PublishedSession(registration),
|
||||||
|
joins,
|
||||||
|
FastOptions(),
|
||||||
|
clock,
|
||||||
|
tickets);
|
||||||
|
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
|
||||||
|
host.AttemptCompleted += (_, completion) => completions.Add(completion);
|
||||||
|
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||||
|
host.Poll();
|
||||||
|
bool observedDeferredRequest = false;
|
||||||
|
hostEvents.RendezvousConnectionRequest += _ =>
|
||||||
|
{
|
||||||
|
observedDeferredRequest = host.DeferredRequestCount == 1;
|
||||||
|
((INatPunchListener)hostPunch).OnNatIntroductionSuccess(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, clientManager.LocalPort),
|
||||||
|
NatAddressType.External,
|
||||||
|
grant.Ticket);
|
||||||
|
};
|
||||||
|
|
||||||
|
clientManager.Connect(
|
||||||
|
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||||
|
DirectConnectionRequestCodec.Encode(created.AttemptId, grant.Ticket));
|
||||||
|
DateTime connectedDeadline = DateTime.UtcNow.AddSeconds(1);
|
||||||
|
while ((!clientConnected || completions.Count == 0)
|
||||||
|
&& DateTime.UtcNow < connectedDeadline)
|
||||||
|
{
|
||||||
|
host.Poll();
|
||||||
|
clientManager.PollEvents();
|
||||||
|
await Task.Delay(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
clientConnected,
|
||||||
|
$"Deferred observed={observedDeferredRequest}; deferred={host.DeferredRequestCount}; "
|
||||||
|
+ $"pending={host.PendingAttemptCount}; completions={completions.Count}.");
|
||||||
|
Assert.True(observedDeferredRequest);
|
||||||
|
Assert.Equal(0, host.DeferredRequestCount);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousConnectionState.Connected,
|
||||||
|
Assert.Single(completions).State);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
hostManager.Stop();
|
||||||
|
clientManager.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RendezvousCoordinatorOptions FastOptions() => new()
|
||||||
|
{
|
||||||
|
MaximumPunchRequests = 20,
|
||||||
|
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||||
|
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(100),
|
||||||
|
JitterRatio = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class FakeJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
|
||||||
|
{
|
||||||
|
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||||
|
CreateJoinAttemptRequest request,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||||
|
CreateJoinAttemptResponse attempt,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||||
|
string? cursor = null,
|
||||||
|
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||||
|
PublishedSession session,
|
||||||
|
int maximumPages = 100,
|
||||||
|
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||||
|
RendezvousClientResult.Success(attempts));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedCoordinatorClock(DateTimeOffset now) :
|
||||||
|
IRendezvousCoordinatorClock,
|
||||||
|
IConnectionTicketClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow { get; } = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||||
|
|
||||||
|
public sealed class RendezvousJoinClientTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task JoinIssuanceRetriesTheSameIdempotentPayloadAndCancellationUsesCapability()
|
||||||
|
{
|
||||||
|
CreateJoinAttemptResponse created = CreateAttempt();
|
||||||
|
RecordingHandler handler = new(
|
||||||
|
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
|
||||||
|
JsonResponse(HttpStatusCode.Created, created),
|
||||||
|
new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||||
|
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousJoinClient client = new(
|
||||||
|
http,
|
||||||
|
new RendezvousClientOptions { JitterRatio = 0 },
|
||||||
|
new ImmediateDelay());
|
||||||
|
CreateJoinAttemptRequest request = new()
|
||||||
|
{
|
||||||
|
IdempotencyKey = "stable-join-key",
|
||||||
|
GameId = new("space-game"),
|
||||||
|
EnvironmentId = new("production"),
|
||||||
|
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000201")),
|
||||||
|
ProtocolVersion = 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
RendezvousClientResult<CreateJoinAttemptResponse> result = await client.CreateAsync(request);
|
||||||
|
RendezvousClientResult<bool> cancelled = await client.CancelAsync(created);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
Assert.True(cancelled.IsSuccess, cancelled.Message);
|
||||||
|
Assert.Equal(handler.Requests[0].Body, handler.Requests[1].Body);
|
||||||
|
Assert.Contains("stable-join-key", handler.Requests[0].Body, StringComparison.Ordinal);
|
||||||
|
RecordedRequest cancellation = handler.Requests[2];
|
||||||
|
Assert.Equal(HttpMethod.Delete, cancellation.Method);
|
||||||
|
Assert.Equal(
|
||||||
|
created.ClientPunchCapability,
|
||||||
|
cancellation.Headers["X-Rendezvous-Client-Punch-Capability"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HostInvitationPollingFollowsCursorsWithTheLeaseToken()
|
||||||
|
{
|
||||||
|
HostJoinAttempt first = CreateHostAttempt("00000000-0000-0000-0000-000000000211");
|
||||||
|
HostJoinAttempt second = CreateHostAttempt("00000000-0000-0000-0000-000000000212");
|
||||||
|
RecordingHandler handler = new(
|
||||||
|
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
|
||||||
|
{
|
||||||
|
Items = [first],
|
||||||
|
NextCursor = "next page+cursor",
|
||||||
|
}),
|
||||||
|
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
|
||||||
|
{
|
||||||
|
Items = [second],
|
||||||
|
}));
|
||||||
|
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
|
||||||
|
RendezvousJoinClient client = new(http);
|
||||||
|
PublishedSession session = new(new RegisterSessionResponse
|
||||||
|
{
|
||||||
|
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000220")),
|
||||||
|
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000221")),
|
||||||
|
LeaseToken = "lease-secret",
|
||||||
|
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000222")),
|
||||||
|
HostPresenceCapability = Credential('P'),
|
||||||
|
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||||
|
LeaseRenewAfterSeconds = 15,
|
||||||
|
HostPresenceRefreshAfterSeconds = 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
|
||||||
|
await client.BrowseAllForHostAsync(session);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess, result.Message);
|
||||||
|
Assert.Equal([first.AttemptId, second.AttemptId], result.Value!.Select(item => item.AttemptId));
|
||||||
|
Assert.Equal(2, handler.Requests.Count);
|
||||||
|
Assert.All(handler.Requests, request =>
|
||||||
|
Assert.Equal("lease-secret", request.Headers["X-Rendezvous-Lease-Token"]));
|
||||||
|
Assert.Contains("cursor=next%20page%2Bcursor", handler.Requests[1].Uri.Query, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateJoinAttemptResponse CreateAttempt() => new()
|
||||||
|
{
|
||||||
|
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")),
|
||||||
|
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000203")),
|
||||||
|
ClientPunchCapability = Credential('C'),
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
NatIntroductionTokenCodec.Encode(
|
||||||
|
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000202")),
|
||||||
|
Credential('T'))),
|
||||||
|
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static HostJoinAttempt CreateHostAttempt(string id) => new()
|
||||||
|
{
|
||||||
|
AttemptId = new(Guid.Parse(id)),
|
||||||
|
MediationHandle = new(Guid.NewGuid()),
|
||||||
|
HostPunchCapability = Credential('H'),
|
||||||
|
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
|
||||||
|
NatIntroductionTokenCodec.Encode(new JoinAttemptId(Guid.Parse(id)), Credential('T'))),
|
||||||
|
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
|
||||||
|
|
||||||
|
private static HttpResponseMessage JsonResponse<T>(HttpStatusCode status, T value) => new(status)
|
||||||
|
{
|
||||||
|
Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options)),
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class RecordingHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||||
|
|
||||||
|
internal List<RecordedRequest> Requests { get; } = [];
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> headers = request.Headers.ToDictionary(
|
||||||
|
static item => item.Key,
|
||||||
|
static item => string.Join(",", item.Value),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
Requests.Add(new(
|
||||||
|
request.Method,
|
||||||
|
request.RequestUri!,
|
||||||
|
headers,
|
||||||
|
request.Content is null
|
||||||
|
? string.Empty
|
||||||
|
: await request.Content.ReadAsStringAsync(cancellationToken)));
|
||||||
|
return _responses.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RecordedRequest(
|
||||||
|
HttpMethod Method,
|
||||||
|
Uri Uri,
|
||||||
|
IReadOnlyDictionary<string, string> Headers,
|
||||||
|
string Body);
|
||||||
|
|
||||||
|
private sealed class ImmediateDelay : IRendezvousDelay
|
||||||
|
{
|
||||||
|
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using FinalFactory.Rendezvous.Client;
|
||||||
|
using FinalFactory.Rendezvous.Contracts;
|
||||||
|
|
||||||
|
namespace FinalFactory.Rendezvous.Tests.Contracts;
|
||||||
|
|
||||||
|
public sealed class TraversalTokenCodecTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IntroductionTokenBindsAttemptAndRedactsTheFixedTicket()
|
||||||
|
{
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000301"));
|
||||||
|
string authenticator = Credential('T');
|
||||||
|
|
||||||
|
string encoded = NatIntroductionTokenCodec.Encode(attemptId, authenticator);
|
||||||
|
|
||||||
|
Assert.Equal(NatIntroductionTokenCodec.EncodedLength, encoded.Length);
|
||||||
|
Assert.True(encoded.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters);
|
||||||
|
Assert.True(NatIntroductionTokenCodec.TryDecode(encoded, out NatIntroductionToken? decoded));
|
||||||
|
Assert.NotNull(decoded);
|
||||||
|
Assert.Equal(attemptId, decoded.AttemptId);
|
||||||
|
Assert.Equal(encoded, decoded.ConnectionTicket);
|
||||||
|
Assert.DoesNotContain(encoded, decoded.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
|
||||||
|
{
|
||||||
|
string valid = NatIntroductionTokenCodec.Encode(
|
||||||
|
new JoinAttemptId(Guid.Parse("abcdef00-0000-0000-0000-000000000302")),
|
||||||
|
Credential('T'));
|
||||||
|
|
||||||
|
Assert.False(NatIntroductionTokenCodec.TryDecode(null, out _));
|
||||||
|
Assert.False(NatIntroductionTokenCodec.TryDecode(valid[..^1], out _));
|
||||||
|
Assert.False(NatIntroductionTokenCodec.TryDecode(valid[..^1] + "!", out _));
|
||||||
|
Assert.False(NatIntroductionTokenCodec.TryDecode(new string('A', 43), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectConnectionRequestRoundTripsFixedBoundedPayloadAndRedactsTicket()
|
||||||
|
{
|
||||||
|
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000303"));
|
||||||
|
string ticket = Credential('D');
|
||||||
|
|
||||||
|
byte[] encoded = DirectConnectionRequestCodec.Encode(attemptId, ticket);
|
||||||
|
|
||||||
|
Assert.Equal(DirectConnectionRequestCodec.EncodedLength, encoded.Length);
|
||||||
|
Assert.True(DirectConnectionRequestCodec.TryDecode(encoded, out DirectConnectionRequest? decoded));
|
||||||
|
Assert.NotNull(decoded);
|
||||||
|
Assert.Equal(attemptId, decoded.AttemptId);
|
||||||
|
Assert.Equal(ticket, decoded.ConnectionTicket);
|
||||||
|
Assert.DoesNotContain(ticket, decoded.ToString(), StringComparison.Ordinal);
|
||||||
|
|
||||||
|
encoded[0] ^= 0xff;
|
||||||
|
Assert.False(DirectConnectionRequestCodec.TryDecode(encoded, out _));
|
||||||
|
Assert.False(DirectConnectionRequestCodec.TryDecode(encoded.AsSpan(1), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
|
||||||
|
}
|
||||||
@@ -91,14 +91,16 @@ public sealed class JoinAttemptHttpEndpointTests
|
|||||||
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
|
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
|
||||||
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
|
||||||
|
|
||||||
using HttpRequestMessage emptyPollRequest = new(
|
using HttpRequestMessage cancelledPollRequest = new(
|
||||||
HttpMethod.Get,
|
HttpMethod.Get,
|
||||||
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
|
||||||
emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
cancelledPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
|
||||||
using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest);
|
using HttpResponseMessage cancelledPollResponse = await host.HttpClient.SendAsync(cancelledPollRequest);
|
||||||
BrowseHostJoinAttemptsResponse empty = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
BrowseHostJoinAttemptsResponse cancelledPoll = Assert.IsType<BrowseHostJoinAttemptsResponse>(
|
||||||
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
await cancelledPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
|
||||||
Assert.Empty(empty.Items);
|
HostJoinAttempt cancelledAttempt = Assert.Single(cancelledPoll.Items);
|
||||||
|
Assert.Equal(created.AttemptId, cancelledAttempt.AttemptId);
|
||||||
|
Assert.True(cancelledAttempt.IsCancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||||
|
|||||||
@@ -152,12 +152,47 @@ public sealed class JoinAttemptServiceTests
|
|||||||
Assert.True(fixture.Service.Cancel(
|
Assert.True(fixture.Service.Cancel(
|
||||||
created.AttemptId,
|
created.AttemptId,
|
||||||
created.ClientPunchCapability).Succeeded);
|
created.ClientPunchCapability).Succeeded);
|
||||||
Assert.Empty(fixture.Service.BrowseForHost(
|
Assert.True(fixture.Service.Cancel(
|
||||||
|
created.AttemptId,
|
||||||
|
created.ClientPunchCapability).Succeeded);
|
||||||
|
HostJoinAttempt cancelled = Assert.Single(fixture.Service.BrowseForHost(
|
||||||
registration.ListingId,
|
registration.ListingId,
|
||||||
ContractLimits.ContractVersion,
|
ContractLimits.ContractVersion,
|
||||||
registration.LeaseToken,
|
registration.LeaseToken,
|
||||||
10,
|
10,
|
||||||
null).Value!.Items);
|
null).Value!.Items);
|
||||||
|
Assert.Equal(created.AttemptId, cancelled.AttemptId);
|
||||||
|
Assert.True(cancelled.IsCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationAfterIntroductionRevokesTicketIssuanceAndConsumption()
|
||||||
|
{
|
||||||
|
using JoinAttemptFixture fixture = new();
|
||||||
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
|
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
|
||||||
|
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||||
|
JoinAttemptServiceResult<ConnectionTicketGrant> issued =
|
||||||
|
fixture.Service.IssueConnectionTicket(introduction.Attempt);
|
||||||
|
Assert.True(issued.Succeeded);
|
||||||
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
|
issued.Value!.Ticket,
|
||||||
|
out SecretFingerprint ticketFingerprint));
|
||||||
|
|
||||||
|
Assert.True(fixture.Service.Cancel(
|
||||||
|
created.AttemptId,
|
||||||
|
created.ClientPunchCapability).Succeeded);
|
||||||
|
|
||||||
|
StoredJoinAttempt cancelled = fixture.GetAttempt(registration, created.AttemptId);
|
||||||
|
Assert.True(cancelled.IsCancelled);
|
||||||
|
Assert.Equal(
|
||||||
|
RendezvousErrorCode.Conflict,
|
||||||
|
fixture.Service.IssueConnectionTicket(cancelled).Error);
|
||||||
|
Assert.Equal(
|
||||||
|
StoreResultCode.Conflict,
|
||||||
|
fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
|
created.AttemptId,
|
||||||
|
ticketFingerprint)).Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -56,11 +56,16 @@ public sealed class NatMediationProcessorTests
|
|||||||
Assert.Equal(Endpoint("192.168.1.11", 42_000), plan.ClientLocal);
|
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", 51_000), plan.HostPublic);
|
||||||
Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic);
|
Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic);
|
||||||
Assert.Equal(43, plan.ConnectionTicket.Length);
|
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||||
Assert.DoesNotContain(plan.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
|
plan.IntroductionToken,
|
||||||
|
out NatIntroductionToken? introduction));
|
||||||
|
Assert.NotNull(introduction);
|
||||||
|
Assert.Equal(attempt.AttemptId, introduction.AttemptId);
|
||||||
|
Assert.Equal(43, introduction.ConnectionTicket.Length);
|
||||||
|
Assert.DoesNotContain(introduction.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
|
||||||
|
|
||||||
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
|
||||||
plan.ConnectionTicket,
|
introduction.ConnectionTicket,
|
||||||
out SecretFingerprint ticketFingerprint));
|
out SecretFingerprint ticketFingerprint));
|
||||||
Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new(
|
||||||
attempt.AttemptId,
|
attempt.AttemptId,
|
||||||
@@ -187,7 +192,7 @@ public sealed class NatMediationProcessorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CancellationCannotReportSuccessAfterIntroductionIsConsumed()
|
public async Task CancellationAfterIntroductionCreatesAHostRevocationTombstone()
|
||||||
{
|
{
|
||||||
using JoinAttemptFixture fixture = new();
|
using JoinAttemptFixture fixture = new();
|
||||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||||
@@ -209,9 +214,16 @@ public sealed class NatMediationProcessorTests
|
|||||||
attempt.AttemptId,
|
attempt.AttemptId,
|
||||||
attempt.ClientCapability);
|
attempt.ClientCapability);
|
||||||
|
|
||||||
Assert.Equal(RendezvousErrorCode.Conflict, cancelled.Error);
|
Assert.True(cancelled.Succeeded);
|
||||||
sink.Release();
|
sink.Release();
|
||||||
Assert.Equal(NatMediationResult.Introduced, await completion);
|
Assert.Equal(NatMediationResult.Introduced, await completion);
|
||||||
|
HostJoinAttempt cancelledAttempt = Assert.Single(fixture.Service.BrowseForHost(
|
||||||
|
registration.ListingId,
|
||||||
|
ContractLimits.ContractVersion,
|
||||||
|
registration.LeaseToken,
|
||||||
|
ContractLimits.BrowserPageMaxItems,
|
||||||
|
null).Value!.Items);
|
||||||
|
Assert.True(cancelledAttempt.IsCancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -159,7 +159,12 @@ public sealed class UdpMediatorServiceTests
|
|||||||
string hostTicket = Assert.Single(hostTickets.Distinct(StringComparer.Ordinal));
|
string hostTicket = Assert.Single(hostTickets.Distinct(StringComparer.Ordinal));
|
||||||
string clientTicket = Assert.Single(clientTickets.Distinct(StringComparer.Ordinal));
|
string clientTicket = Assert.Single(clientTickets.Distinct(StringComparer.Ordinal));
|
||||||
Assert.Equal(hostTicket, clientTicket);
|
Assert.Equal(hostTicket, clientTicket);
|
||||||
Assert.Equal(43, hostTicket.Length);
|
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||||
|
hostTicket,
|
||||||
|
out NatIntroductionToken? introduction));
|
||||||
|
Assert.NotNull(introduction);
|
||||||
|
Assert.Equal(created.AttemptId, introduction.AttemptId);
|
||||||
|
Assert.Equal(43, introduction.ConnectionTicket.Length);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -229,7 +234,9 @@ public sealed class UdpMediatorServiceTests
|
|||||||
Assert.True(
|
Assert.True(
|
||||||
hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length
|
hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length
|
||||||
<= clientDatagram.Length * 2,
|
<= clientDatagram.Length * 2,
|
||||||
"The completing authenticated contribution exceeded the 2.0 response-byte budget.");
|
$"The completing authenticated contribution exceeded the 2.0 response-byte budget: "
|
||||||
|
+ $"responses={hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length}, "
|
||||||
|
+ $"request={clientDatagram.Length}.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,8 +12,23 @@ TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator
|
|||||||
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
|
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
|
||||||
METHOD System.String ToString()
|
METHOD System.String ToString()
|
||||||
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
|
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequest
|
||||||
|
CTOR ()
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
||||||
|
PROP System.String ConnectionTicket {get;set;}
|
||||||
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequestCodec
|
||||||
|
FIELD System.Int32 EncodedLength=63
|
||||||
|
METHOD System.Byte[] Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
|
||||||
|
METHOD System.Boolean IsRendezvousRequest(System.ReadOnlySpan<System.Byte> encoded)
|
||||||
|
METHOD System.Boolean TryDecode(System.ReadOnlySpan<System.Byte> encoded, FinalFactory.Rendezvous.Client.DirectConnectionRequest& request)
|
||||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
|
||||||
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
|
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousJoinClient
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.HostJoinAttempt>>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse>> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken)
|
||||||
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
|
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
|
||||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
|
||||||
@@ -41,6 +56,17 @@ TYPE FinalFactory.Rendezvous.Client.PublishedSession
|
|||||||
PROP System.String LeaseToken {get;}
|
PROP System.String LeaseToken {get;}
|
||||||
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;}
|
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;}
|
||||||
METHOD System.String ToString()
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousClientCoordinator
|
||||||
|
CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options)
|
||||||
|
PROP LiteNetLib.NetPeer ConnectedPeer {get;}
|
||||||
|
PROP System.Boolean IsCompleted {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
|
||||||
|
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs> Completed
|
||||||
|
METHOD System.Void Cancel()
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Void Dispose()
|
||||||
|
METHOD System.Void Poll()
|
||||||
|
METHOD System.String ToString()
|
||||||
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
|
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
|
||||||
CTOR ()
|
CTOR ()
|
||||||
PROP System.TimeSpan InitialRetryDelay {get;set;}
|
PROP System.TimeSpan InitialRetryDelay {get;set;}
|
||||||
@@ -56,6 +82,66 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult<T>
|
|||||||
PROP System.String Message {get;}
|
PROP System.String Message {get;}
|
||||||
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;}
|
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;}
|
||||||
PROP T Value {get;}
|
PROP T Value {get;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs
|
||||||
|
CTOR (FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
|
||||||
|
PROP LiteNetLib.NetPeer Peer {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState
|
||||||
|
ENUM Punching=1
|
||||||
|
ENUM Connecting=2
|
||||||
|
ENUM Connected=3
|
||||||
|
ENUM Cancelled=4
|
||||||
|
ENUM TimedOut=5
|
||||||
|
ENUM Rejected=6
|
||||||
|
ENUM ManagerStopped=7
|
||||||
|
ENUM Disposed=8
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions
|
||||||
|
CTOR ()
|
||||||
|
PROP System.TimeSpan ConnectionTicketLifetime {get;set;}
|
||||||
|
PROP System.TimeSpan InitialPunchRetryDelay {get;set;}
|
||||||
|
PROP System.Double JitterRatio {get;set;}
|
||||||
|
PROP System.Int32 MaximumAttemptChecksPerPoll {get;set;}
|
||||||
|
PROP System.Int32 MaximumPunchRequests {get;set;}
|
||||||
|
PROP System.TimeSpan MaximumPunchRetryDelay {get;set;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs
|
||||||
|
CTOR (FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;}
|
||||||
|
PROP LiteNetLib.NetPeer Peer {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousHostCoordinator
|
||||||
|
CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options)
|
||||||
|
PROP System.Int32 PendingAttemptCount {get;}
|
||||||
|
PROP FinalFactory.Rendezvous.Client.RendezvousHostState State {get;}
|
||||||
|
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs> AttemptCompleted
|
||||||
|
METHOD System.Void Dispose()
|
||||||
|
METHOD System.Void Poll()
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Int32>> RefreshJoinAttemptsAsync(System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousHostState
|
||||||
|
ENUM Active=1
|
||||||
|
ENUM ManagerStopped=2
|
||||||
|
ENUM Disposed=3
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousJoinClient
|
||||||
|
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.HostJoinAttempt>>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken)
|
||||||
|
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse>> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken)
|
||||||
|
TYPE FinalFactory.Rendezvous.Client.RendezvousNetListener
|
||||||
|
CTOR ()
|
||||||
|
PROP LiteNetLib.EventBasedNetListener GameplayEvents {get;}
|
||||||
|
PROP LiteNetLib.EventBasedNatPunchListener PunchEvents {get;}
|
||||||
|
METHOD LiteNetLib.NetManager CreateManager()
|
||||||
|
METHOD System.Void OnConnectionRequest(LiteNetLib.ConnectionRequest request)
|
||||||
|
METHOD System.Void OnMessageDelivered(LiteNetLib.NetPeer peer, System.Object userData)
|
||||||
|
METHOD System.Void OnNetworkError(System.Net.IPEndPoint endPoint, System.Net.Sockets.SocketError socketError)
|
||||||
|
METHOD System.Void OnNetworkLatencyUpdate(LiteNetLib.NetPeer peer, System.Int32 latency)
|
||||||
|
METHOD System.Void OnNetworkReceive(LiteNetLib.NetPeer peer, LiteNetLib.NetPacketReader reader, System.Byte channelNumber, LiteNetLib.DeliveryMethod deliveryMethod)
|
||||||
|
METHOD System.Void OnNetworkReceiveUnconnected(System.Net.IPEndPoint remoteEndPoint, LiteNetLib.NetPacketReader reader, LiteNetLib.UnconnectedMessageType messageType)
|
||||||
|
METHOD System.Void OnNtpResponse(LiteNetLib.Utils.NtpPacket packet)
|
||||||
|
METHOD System.Void OnPeerAddressChanged(LiteNetLib.NetPeer peer, System.Net.IPEndPoint previousAddress)
|
||||||
|
METHOD System.Void OnPeerConnected(LiteNetLib.NetPeer peer)
|
||||||
|
METHOD System.Void OnPeerDisconnected(LiteNetLib.NetPeer peer, LiteNetLib.DisconnectInfo disconnectInfo)
|
||||||
TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient
|
TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient
|
||||||
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
|
||||||
METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential)
|
METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential)
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse
|
|||||||
CTOR ()
|
CTOR ()
|
||||||
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
||||||
PROP System.String ClientPunchCapability {get;set;}
|
PROP System.String ClientPunchCapability {get;set;}
|
||||||
|
PROP System.String ConnectionTicketDigest {get;set;}
|
||||||
PROP System.Int32 ContractVersion {get;set;}
|
PROP System.Int32 ContractVersion {get;set;}
|
||||||
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
|
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
|
||||||
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
||||||
@@ -137,8 +138,10 @@ TYPE FinalFactory.Rendezvous.Contracts.HealthResponse
|
|||||||
TYPE FinalFactory.Rendezvous.Contracts.HostJoinAttempt
|
TYPE FinalFactory.Rendezvous.Contracts.HostJoinAttempt
|
||||||
CTOR ()
|
CTOR ()
|
||||||
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
||||||
|
PROP System.String ConnectionTicketDigest {get;set;}
|
||||||
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
PROP System.DateTimeOffset ExpiresAt {get;set;}
|
||||||
PROP System.String HostPunchCapability {get;set;}
|
PROP System.String HostPunchCapability {get;set;}
|
||||||
|
PROP System.Boolean IsCancelled {get;set;}
|
||||||
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
|
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
|
||||||
TYPE FinalFactory.Rendezvous.Contracts.JoinAttemptId
|
TYPE FinalFactory.Rendezvous.Contracts.JoinAttemptId
|
||||||
CTOR (System.Guid value)
|
CTOR (System.Guid value)
|
||||||
@@ -173,6 +176,17 @@ TYPE FinalFactory.Rendezvous.Contracts.MediationHandle
|
|||||||
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
|
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
|
||||||
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
||||||
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
|
||||||
|
TYPE FinalFactory.Rendezvous.Contracts.NatIntroductionToken
|
||||||
|
CTOR ()
|
||||||
|
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
|
||||||
|
PROP System.String ConnectionTicket {get;set;}
|
||||||
|
METHOD System.String ToString()
|
||||||
|
TYPE FinalFactory.Rendezvous.Contracts.NatIntroductionTokenCodec
|
||||||
|
FIELD System.Int32 EncodedLength=43
|
||||||
|
METHOD System.String ComputeDigest(System.String connectionTicket)
|
||||||
|
METHOD System.String Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String derivedAuthenticator)
|
||||||
|
METHOD System.Boolean MatchesDigest(System.String connectionTicket, System.String expectedDigest)
|
||||||
|
METHOD System.Boolean TryDecode(System.String encoded, FinalFactory.Rendezvous.Contracts.NatIntroductionToken& token)
|
||||||
TYPE FinalFactory.Rendezvous.Contracts.NatPunchPeerRole
|
TYPE FinalFactory.Rendezvous.Contracts.NatPunchPeerRole
|
||||||
ENUM HostPresence=1
|
ENUM HostPresence=1
|
||||||
ENUM Host=2
|
ENUM Host=2
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}
|
{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","connectionTicketDigest":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}
|
||||||
|
|||||||
Reference in New Issue
Block a user