diff --git a/docs/api/rendezvous-v1.json b/docs/api/rendezvous-v1.json index bd9b7c5..c667b3c 100644 --- a/docs/api/rendezvous-v1.json +++ b/docs/api/rendezvous-v1.json @@ -1054,6 +1054,7 @@ "attemptId", "mediationHandle", "clientPunchCapability", + "connectionTicketDigest", "expiresAt" ], "type": "object", @@ -1071,6 +1072,9 @@ "clientPunchCapability": { "type": "string" }, + "connectionTicketDigest": { + "type": "string" + }, "expiresAt": { "type": "string", "format": "date-time" @@ -1146,6 +1150,8 @@ "attemptId", "mediationHandle", "hostPunchCapability", + "connectionTicketDigest", + "isCancelled", "expiresAt" ], "type": "object", @@ -1159,6 +1165,12 @@ "hostPunchCapability": { "type": "string" }, + "connectionTicketDigest": { + "type": "string" + }, + "isCancelled": { + "type": "boolean" + }, "expiresAt": { "type": "string", "format": "date-time" diff --git a/docs/architecture/0008-scoped-join-attempts-and-tickets.md b/docs/architecture/0008-scoped-join-attempts-and-tickets.md index aee26d5..30021e5 100644 --- a/docs/architecture/0008-scoped-join-attempts-and-tickets.md +++ b/docs/architecture/0008-scoped-join-attempts-and-tickets.md @@ -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, and a purpose/role label. Credentials are 32-byte base64url values (43 characters), below both the 192-character Rendezvous capability ceiling and LiteNetLib's -256-character NAT token ceiling. State retains keyed credential fingerprints, -derivation inputs, and salt—not issued plaintext. All diagnostic string -representations redact credentials and derivation material. +256-character NAT token ceiling. The connection ticket uses half of that payload +for its attempt ID and half for an independently derived 128-bit authenticator, so +the SDK can correlate concurrent introductions without increasing UDP response +size. State retains keyed credential fingerprints, derivation inputs, and salt—not +issued plaintext. All diagnostic string representations redact credentials and +derivation material. The client receives only its punch capability. A host polls its own listing with the lease token in `X-Rendezvous-Lease-Token` and receives only host-role capabilities through a signed, listing-bound, five-minute cursor. Replaying an identical join request returns the same live attempt; changing the request under the same owner/key conflicts. A client may cancel with its punch capability in -`X-Rendezvous-Client-Punch-Capability`; cancellation atomically removes the -attempt. Listing deletion, expiry, revocation, or process restart removes every -associated attempt and credential fingerprint. +`X-Rendezvous-Client-Punch-Capability`; cancellation atomically marks the attempt +and retains a bounded tombstone until its original expiry. Host polling returns +that tombstone so a coordinator can revoke any local ticket authorization, while +endpoint binding, introduction, ticket issuance, and ticket consumption all +reject the cancelled attempt. Listing deletion, expiry, revocation, or process +restart removes every associated attempt and credential fingerprint. Endpoint binding remains role- and capability-specific. The first endpoint observed for a role wins atomically; an exact UDP duplicate is idempotent, while @@ -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, accepts an exact ticket once under a lock, rejects altered/cross-attempt/expired/ revoked/replayed tickets, and zeroes retained digests and key material on disposal. -Issue #11 carries the ticket in the authenticated introduction; issue #12 wires -authorization and consumption into the caller-owned LiteNetLib coordinator. +Issue #11 carries the fixed-size ticket in the authenticated introduction. Issue +#12 extracts its embedded attempt ID, bounds the host's local authorization window +by both the host-polled attempt expiry and the configured ticket lifetime, then +wires one-time consumption into the caller-owned coordinator. Both peers receive +a digest of the exact expected ticket over HTTP and reject any syntactically valid +but unauthenticated introduction token. Embedding the ID prevents concurrent or +late introductions from cross-binding a valid ticket while preserving the +mediator's 2.0 response-byte amplification ceiling. ## Consequences diff --git a/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs b/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs new file mode 100644 index 0000000..00aded9 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs @@ -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> 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( + () => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/join-attempts", body), + cancellationToken); + } + + public Task> CancelAsync( + CreateJoinAttemptResponse attempt, + CancellationToken cancellationToken = default) + { + if (attempt is null) + { + throw new ArgumentNullException(nameof(attempt)); + } + return _transport.SendSafeAsync( + () => HeaderRequest( + HttpMethod.Delete, + $"v1/join-attempts/{attempt.AttemptId}", + ClientPunchCapabilityHeader, + RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt))), + cancellationToken); + } + + public Task> 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( + () => HeaderRequest( + HttpMethod.Get, + query, + LeaseTokenHeader, + RequireHeaderValue(session.LeaseToken, nameof(session))), + cancellationToken); + } + + public async Task>> 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 attempts = []; + string? cursor = null; + for (int page = 0; page < maximumPages; page++) + { + RendezvousClientResult result = + await BrowseForHostAsync( + session, + ContractLimits.BrowserPageMaxItems, + cursor, + cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess || result.Value is null) + { + return RendezvousClientResult.Failure>( + result.Error, + result.Message, + result.RetryAfterSeconds); + } + + attempts.AddRange(result.Value.Items); + cursor = result.Value.NextCursor; + if (string.IsNullOrEmpty(cursor)) + { + return RendezvousClientResult.Success>( + attempts.AsReadOnly()); + } + } + + return RendezvousClientResult.Failure>( + 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); +} diff --git a/src/FinalFactory.Rendezvous.Client/README.md b/src/FinalFactory.Rendezvous.Client/README.md index 19db5f7..0908d48 100644 --- a/src/FinalFactory.Rendezvous.Client/README.md +++ b/src/FinalFactory.Rendezvous.Client/README.md @@ -1,6 +1,6 @@ # 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`. ```csharp @@ -53,10 +53,75 @@ string presenceToken = NatPunchRequestTokenCodec.Encode( gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken); ``` -The same codec creates `Host` tokens for host-polled invitations and `Client` -tokens for a created join attempt. Always send them from the same LiteNetLib -socket that will carry the direct game connection; the mediator ignores any -caller-supplied public endpoint. +For direct connections, let the SDK drive those tokens from the same caller-owned +LiteNetLib socket that carries gameplay. Ask the routing listener to create the +bound manager, then configure and start that caller-owned manager yourself. The +factory does not open a socket, and synchronized events must remain enabled: + +```csharp +RendezvousNetListener networkEvents = new(); +NetManager gameplayNetManager = networkEvents.CreateManager(); +if (!gameplayNetManager.Start(0)) +{ + throw new InvalidOperationException("The gameplay UDP socket could not start."); +} +``` + +The host polls join invitations asynchronously; that method only queues a +snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes +LiteNetLib and dispatches its synchronized callbacks. Call it once per game +frame on the thread that owns the manager: + +```csharp +RendezvousJoinClient joins = new(http); +using RendezvousHostCoordinator host = new( + gameplayNetManager, + networkEvents, + mediatorEndPoint, + session, + joins); + +// Run periodically from the game's normal async scheduling path. +await host.RefreshJoinAttemptsAsync(cancellationToken); + +// Godot _Process, Update, or the equivalent main-thread frame callback. +host.Poll(); +``` + +Do not also call `gameplayNetManager.PollEvents()` or +`gameplayNetManager.NatPunchModule.PollEvents()` when a coordinator owns polling. +The host coordinator refreshes host presence, punches for queued invitations, +validates the introduction ticket, and accepts the direct request. Subscribe to +`AttemptCompleted`; a `Connected` result is raised only after LiteNetLib reports +the accepted peer as connected. Register ordinary gameplay callbacks on +`networkEvents.GameplayEvents`; the routing listener reserves Rendezvous direct +requests for ticket validation and forwards every other callback normally. + +The joining game first creates the HTTP attempt, then uses its own already-started +gameplay manager in the same frame loop: + +```csharp +CreateJoinAttemptResponse attempt = (await joins.CreateAsync( + createJoinRequest, + cancellationToken)).Value + ?? throw new InvalidOperationException("Join issuance failed."); +using RendezvousClientCoordinator client = new( + gameplayNetManager, + networkEvents, + mediatorEndPoint, + attempt); + +// Godot _Process, Update, or the equivalent main-thread frame callback. +client.Poll(); +``` + +NAT introduction changes the client state to `Connecting`; it is not success. +Only `Connected` supplies `ConnectedPeer`. Call `Cancel()` and then `Poll()` for +local cancellation, or `CancelAsync(joins, cancellationToken)` to also revoke the +service attempt. Terminal client paths release all event subscriptions. Disposing +a coordinator never stops or disposes the caller-owned manager and does not touch +an in-flight peer; call `Cancel()` followed by `Poll()` first when that peer must +also be disconnected. Lease renewal is explicit and caller-controlled: diff --git a/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs b/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs index 0a9e70a..9358b7e 100644 --- a/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs +++ b/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs @@ -42,6 +42,10 @@ public static class RendezvousClientResult public sealed class PublishedSession { + private readonly object _timingGate = new(); + private DateTimeOffset _expiresAt; + private int _leaseRenewAfterSeconds; + internal PublishedSession(RegisterSessionResponse response) { ListingId = response.ListingId; @@ -49,8 +53,8 @@ public sealed class PublishedSession LeaseToken = response.LeaseToken; HostPresenceHandle = response.HostPresenceHandle; HostPresenceCapability = response.HostPresenceCapability; - ExpiresAt = response.ExpiresAt; - LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds; + _expiresAt = response.ExpiresAt; + _leaseRenewAfterSeconds = response.LeaseRenewAfterSeconds; HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds; } @@ -59,8 +63,41 @@ public sealed class PublishedSession public string LeaseToken { get; } public MediationHandle HostPresenceHandle { get; } public string HostPresenceCapability { get; } - public DateTimeOffset ExpiresAt { get; internal set; } - public int LeaseRenewAfterSeconds { get; internal set; } + public DateTimeOffset ExpiresAt + { + get + { + lock (_timingGate) + { + return _expiresAt; + } + } + internal set + { + lock (_timingGate) + { + _expiresAt = value; + } + } + } + + public int LeaseRenewAfterSeconds + { + get + { + lock (_timingGate) + { + return _leaseRenewAfterSeconds; + } + } + internal set + { + lock (_timingGate) + { + _leaseRenewAfterSeconds = value; + } + } + } public int HostPresenceRefreshAfterSeconds { get; } public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]"; @@ -109,6 +146,28 @@ public interface IRendezvousSessionBrowserClient CancellationToken cancellationToken = default); } +public interface IRendezvousJoinClient +{ + Task> CreateAsync( + CreateJoinAttemptRequest request, + CancellationToken cancellationToken = default); + + Task> CancelAsync( + CreateJoinAttemptResponse attempt, + CancellationToken cancellationToken = default); + + Task> BrowseForHostAsync( + PublishedSession session, + int pageSize = ContractLimits.BrowserPageMaxItems, + string? cursor = null, + CancellationToken cancellationToken = default); + + Task>> BrowseAllForHostAsync( + PublishedSession session, + int maximumPages = 100, + CancellationToken cancellationToken = default); +} + public interface IRendezvousDelay { Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken); diff --git a/src/FinalFactory.Rendezvous.Client/Traversal/DirectConnectionRequestCodec.cs b/src/FinalFactory.Rendezvous.Client/Traversal/DirectConnectionRequestCodec.cs new file mode 100644 index 0000000..7cfacf7 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/Traversal/DirectConnectionRequestCodec.cs @@ -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 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 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; + } +} diff --git a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousClientCoordinator.cs b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousClientCoordinator.cs new file mode 100644 index 0000000..a9348dd --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousClientCoordinator.cs @@ -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? 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> 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)); + } + } +} diff --git a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousCoordinatorAbstractions.cs b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousCoordinatorAbstractions.cs new file mode 100644 index 0000000..22221f5 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousCoordinatorAbstractions.cs @@ -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 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); + } +} diff --git a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousHostCoordinator.cs b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousHostCoordinator.cs new file mode 100644 index 0000000..83e9fc5 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousHostCoordinator.cs @@ -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 _attempts = []; + private readonly Dictionary _acceptedPeers = []; + private readonly Dictionary _deferredRequests = []; + private readonly Dictionary _terminalAttempts = []; + private readonly Queue _attemptSchedule = []; + private readonly List _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? AttemptCompleted; + + public RendezvousHostState State { get; private set; } = RendezvousHostState.Active; + public int PendingAttemptCount => _attempts.Count; + internal int DeferredRequestCount => _deferredRequests.Count; + + public async Task> RefreshJoinAttemptsAsync( + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + if (Interlocked.Exchange(ref _refreshing, 1) != 0) + { + throw new InvalidOperationException("A host invitation refresh is already running."); + } + + try + { + RendezvousClientResult> result = + await _joinClient.BrowseAllForHostAsync( + _session, + cancellationToken: cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess || result.Value is null) + { + return RendezvousClientResult.Failure( + 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 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 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; + } +} diff --git a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousNetListener.cs b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousNetListener.cs new file mode 100644 index 0000000..774dda0 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousNetListener.cs @@ -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? RendezvousPeerConnected; + internal event Action? RendezvousPeerDisconnected; + internal event Action? 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? 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); +} diff --git a/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs b/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs index a55a5b8..b22f754 100644 --- a/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs +++ b/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs @@ -37,6 +37,9 @@ public sealed class CreateJoinAttemptResponse [JsonRequired] public string ClientPunchCapability { get; set; } = string.Empty; + [JsonRequired] + public string ConnectionTicketDigest { get; set; } = string.Empty; + [JsonRequired] public DateTimeOffset ExpiresAt { get; set; } public NetworkEndpoint? DedicatedFallback { get; set; } @@ -53,6 +56,12 @@ public sealed class HostJoinAttempt [JsonRequired] public string HostPunchCapability { get; set; } = string.Empty; + [JsonRequired] + public string ConnectionTicketDigest { get; set; } = string.Empty; + + [JsonRequired] + public bool IsCancelled { get; set; } + [JsonRequired] public DateTimeOffset ExpiresAt { get; set; } } diff --git a/src/FinalFactory.Rendezvous.Contracts/Udp/NatIntroductionTokenCodec.cs b/src/FinalFactory.Rendezvous.Contracts/Udp/NatIntroductionTokenCodec.cs new file mode 100644 index 0000000..93b3c53 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Contracts/Udp/NatIntroductionTokenCodec.cs @@ -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('/', '_'); +} diff --git a/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs b/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs index 90227ad..490930c 100644 --- a/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs +++ b/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs @@ -66,7 +66,15 @@ internal sealed class JoinAttemptService( string derivationSalt = capabilities.CreateDerivationSalt(); string hostCapability = Derive("join-host-punch", clientSubject, request, requestFingerprint, derivationSalt); string clientCapability = Derive("join-client-punch", clientSubject, request, requestFingerprint, derivationSalt); - string connectionTicket = Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt); + JoinAttemptId attemptId = new(capabilities.DeriveGuid( + "join-attempt-id", + clientSubject, + request.IdempotencyKey, + requestFingerprint, + derivationSalt)); + string connectionTicket = NatIntroductionTokenCodec.Encode( + attemptId, + Derive("connection-ticket", clientSubject, request, requestFingerprint, derivationSalt)); if (!CredentialLengthsAreValid(hostCapability, clientCapability, connectionTicket) || !capabilities.TryFingerprint(hostCapability, out SecretFingerprint hostFingerprint) || !capabilities.TryFingerprint(clientCapability, out SecretFingerprint clientFingerprint) @@ -75,12 +83,6 @@ internal sealed class JoinAttemptService( throw new InvalidOperationException("Derived join credentials violated their contract invariants."); } - JoinAttemptId attemptId = new(capabilities.DeriveGuid( - "join-attempt-id", - clientSubject, - request.IdempotencyKey, - requestFingerprint, - derivationSalt)); MediationHandle mediationHandle = new(capabilities.DeriveGuid( "join-mediation-handle", clientSubject, @@ -126,6 +128,8 @@ internal sealed class JoinAttemptService( AttemptId = persisted.AttemptId, MediationHandle = persisted.MediationHandle, ClientPunchCapability = clientCapability, + ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest( + CreateConnectionTicket(persisted)), ExpiresAt = persisted.ExpiresAt, }); } @@ -203,7 +207,7 @@ internal sealed class JoinAttemptService( StoredJoinAttempt attempt) { ArgumentNullException.ThrowIfNull(attempt); - if (!attempt.IntroductionConsumed) + if (!attempt.IntroductionConsumed || attempt.IsCancelled) { return new(RendezvousErrorCode.Conflict); } @@ -213,12 +217,7 @@ internal sealed class JoinAttemptService( return new(RendezvousErrorCode.Expired); } - string ticket = Derive( - "connection-ticket", - attempt.ClientSubject, - attempt.IdempotencyKey, - attempt.RequestFingerprint, - attempt.CapabilityDerivationSalt); + string ticket = CreateConnectionTicket(attempt); if (!ContractValidation.IsConnectionTicketValid(ticket) || !capabilities.TryFingerprint(ticket, out SecretFingerprint fingerprint) || fingerprint != attempt.ConnectionTicketFingerprint) @@ -249,10 +248,23 @@ internal sealed class JoinAttemptService( AttemptId = attempt.AttemptId, MediationHandle = attempt.MediationHandle, HostPunchCapability = capability, + ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest( + CreateConnectionTicket(attempt)), + IsCancelled = attempt.IsCancelled, ExpiresAt = attempt.ExpiresAt, }; } + private string CreateConnectionTicket(StoredJoinAttempt attempt) => + NatIntroductionTokenCodec.Encode( + attempt.AttemptId, + Derive( + "connection-ticket", + attempt.ClientSubject, + attempt.IdempotencyKey, + attempt.RequestFingerprint, + attempt.CapabilityDerivationSalt)); + private static RendezvousErrorCode ValidateCreate(CreateJoinAttemptRequest request) { RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion); diff --git a/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs b/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs index ecc3e19..0dd4f36 100644 --- a/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs +++ b/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs @@ -286,6 +286,7 @@ internal sealed record StoredJoinAttempt public AttemptEndpointBinding? ClientEndpoint { get; init; } public required bool IntroductionConsumed { get; init; } public required bool ConnectionTicketConsumed { get; init; } + public required bool IsCancelled { get; init; } public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]"; } diff --git a/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs b/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs index 9237714..f909566 100644 --- a/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs +++ b/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs @@ -420,7 +420,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto IReadOnlyList attempts = _attempts.Values .Where(entry => entry.Command.ListingId == query.ListingId - && !entry.IntroductionConsumed + && (!entry.IntroductionConsumed || entry.IsCancelled) && (!query.AfterAttemptId.HasValue || entry.Command.AttemptId.Value.CompareTo(query.AfterAttemptId.Value.Value) > 0)) .OrderBy(static entry => entry.Command.AttemptId.Value) @@ -451,12 +451,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto 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); }, cancellationToken); @@ -480,7 +480,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto if (!_attemptHandles.TryGetValue(command.Handle, out JoinAttemptId attemptId) || !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt) - || attempt.Deadline <= now) + || attempt.Deadline <= now + || attempt.IsCancelled) { return new(StoreResultCode.NotFound); } @@ -532,7 +533,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto if (!_attemptHandles.TryGetValue(handle, out JoinAttemptId attemptId) || !_attempts.TryGetValue(attemptId, out AttemptEntry? attempt) - || attempt.Deadline <= now) + || attempt.Deadline <= now + || attempt.IsCancelled) { return new(StoreResultCode.NotFound); } @@ -590,6 +592,11 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto return new(StoreResultCode.Conflict); } + if (attempt.IsCancelled) + { + return new(StoreResultCode.Conflict); + } + if (!attempt.TicketDeadline.HasValue || attempt.TicketDeadline.Value <= now) { return new(StoreResultCode.Expired); @@ -874,6 +881,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto ClientEndpoint = entry.ClientEndpoint, IntroductionConsumed = entry.IntroductionConsumed, ConnectionTicketConsumed = entry.ConnectionTicketConsumed, + IsCancelled = entry.IsCancelled, }; private static void RemoveExpired(Dictionary entries, TimeSpan now) @@ -1020,6 +1028,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto public AttemptEndpointBinding? ClientEndpoint { get; set; } public bool IntroductionConsumed { get; set; } public bool ConnectionTicketConsumed { get; set; } + public bool IsCancelled { get; set; } } private sealed record IdempotencyEntry( diff --git a/src/FinalFactory.Rendezvous.Server/Transport/NatMediationProcessor.cs b/src/FinalFactory.Rendezvous.Server/Transport/NatMediationProcessor.cs index 1e935f6..5b4d0bc 100644 --- a/src/FinalFactory.Rendezvous.Server/Transport/NatMediationProcessor.cs +++ b/src/FinalFactory.Rendezvous.Server/Transport/NatMediationProcessor.cs @@ -17,7 +17,7 @@ internal sealed record NatIntroductionPlan( IPEndPoint HostPublic, IPEndPoint ClientLocal, IPEndPoint ClientPublic, - string ConnectionTicket) + string IntroductionToken) { public override string ToString() => "[NatIntroductionPlan: endpoints and ticket redacted]"; } @@ -185,7 +185,7 @@ internal sealed class NatMediationProcessor( try { - introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value.Ticket)); + introductionSink.Introduce(CreatePlan(consumed.Value, ticket.Value)); return NatMediationResult.Introduced; } catch (Exception exception) when (exception is SocketException @@ -198,7 +198,7 @@ internal sealed class NatMediationProcessor( private static NatIntroductionPlan CreatePlan( IntroductionEndpoints endpoints, - string connectionTicket) + ConnectionTicketGrant ticket) { IPEndPoint hostPublic = ToIpEndpoint(endpoints.Host.PublicEndpoint); IPEndPoint clientPublic = ToIpEndpoint(endpoints.Client.PublicEndpoint); @@ -209,7 +209,12 @@ internal sealed class NatMediationProcessor( IPEndPoint clientLocal = sameNat && endpoints.Client.LocalEndpoint is { } clientCandidate ? ToIpEndpoint(clientCandidate) : clientPublic; - return new(hostLocal, hostPublic, clientLocal, clientPublic, connectionTicket); + return new( + hostLocal, + hostPublic, + clientLocal, + clientPublic, + ticket.Ticket); } private static bool TryCreateObservedEndpoint( diff --git a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs index 722d2e7..2aeac89 100644 --- a/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs +++ b/src/FinalFactory.Rendezvous.Server/Transport/UdpMediatorService.cs @@ -155,7 +155,7 @@ internal sealed partial class UdpMediatorService : BackgroundService plan.HostPublic, plan.ClientLocal, plan.ClientPublic, - plan.ConnectionTicket); + plan.IntroductionToken); } private sealed class RendezvousPacketLayer(NatMediationProcessor processor) : PacketLayerBase(0) diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorBehaviorTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorBehaviorTests.cs new file mode 100644 index 0000000..77f9e30 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorBehaviorTests.cs @@ -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 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(() => 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(() => 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(() => 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 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> refresh = host.RefreshJoinAttemptsAsync(); + await joins.WaitUntilCalled; + + host.Dispose(); + joins.Complete([]); + + await Assert.ThrowsAsync(async () => await refresh); + Assert.Throws(() => 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 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 attempts) : IRendezvousJoinClient + { + internal IReadOnlyList Attempts { get; set; } = attempts; + + public Task> CreateAsync( + CreateJoinAttemptRequest request, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> CancelAsync( + CreateJoinAttemptResponse attempt, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> BrowseForHostAsync( + PublishedSession session, + int pageSize = ContractLimits.BrowserPageMaxItems, + string? cursor = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task>> BrowseAllForHostAsync( + PublishedSession session, + int maximumPages = 100, + CancellationToken cancellationToken = default) => Task.FromResult( + RendezvousClientResult.Success(Attempts)); + } + + private sealed class BlockingJoinClient : IRendezvousJoinClient + { + private readonly TaskCompletionSource _called = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource> _result = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal Task WaitUntilCalled => _called.Task; + + internal void Complete(IReadOnlyList attempts) => + _result.SetResult(attempts); + + public Task> CreateAsync( + CreateJoinAttemptRequest request, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> CancelAsync( + CreateJoinAttemptResponse attempt, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> BrowseForHostAsync( + PublishedSession session, + int pageSize = ContractLimits.BrowserPageMaxItems, + string? cursor = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public async Task>> 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; + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorIntegrationTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorIntegrationTests.cs new file mode 100644 index 0000000..efb5ee6 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorIntegrationTests.cs @@ -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.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(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 hostCompletions = []; + List 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( + 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 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 attempts) : IRendezvousJoinClient + { + public Task> CreateAsync( + CreateJoinAttemptRequest request, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> CancelAsync( + CreateJoinAttemptResponse attempt, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> BrowseForHostAsync( + PublishedSession session, + int pageSize = ContractLimits.BrowserPageMaxItems, + string? cursor = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task>> 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; + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs new file mode 100644 index 0000000..8900f46 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs @@ -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 result = await client.CreateAsync(request); + RendezvousClientResult 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> 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(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 _responses = new(responses); + + internal List Requests { get; } = []; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Dictionary 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 Headers, + string Body); + + private sealed class ImmediateDelay : IRendezvousDelay + { + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/TraversalTokenCodecTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/TraversalTokenCodecTests.cs new file mode 100644 index 0000000..4591987 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/TraversalTokenCodecTests.cs @@ -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); +} diff --git a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs index 79e701b..6474e3c 100644 --- a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs @@ -91,14 +91,16 @@ public sealed class JoinAttemptHttpEndpointTests using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest); Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode); - using HttpRequestMessage emptyPollRequest = new( + using HttpRequestMessage cancelledPollRequest = new( HttpMethod.Get, $"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10"); - emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken); - using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest); - BrowseHostJoinAttemptsResponse empty = Assert.IsType( - await emptyPollResponse.Content.ReadFromJsonAsync(ContractJson.Options)); - Assert.Empty(empty.Items); + cancelledPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken); + using HttpResponseMessage cancelledPollResponse = await host.HttpClient.SendAsync(cancelledPollRequest); + BrowseHostJoinAttemptsResponse cancelledPoll = Assert.IsType( + await cancelledPollResponse.Content.ReadFromJsonAsync(ContractJson.Options)); + HostJoinAttempt cancelledAttempt = Assert.Single(cancelledPoll.Items); + Assert.Equal(created.AttemptId, cancelledAttempt.AttemptId); + Assert.True(cancelledAttempt.IsCancelled); } private static T AssertSuccess(RendezvousClientResult result) diff --git a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs index 1f99177..9659d19 100644 --- a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs @@ -152,12 +152,47 @@ public sealed class JoinAttemptServiceTests Assert.True(fixture.Service.Cancel( created.AttemptId, 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, ContractLimits.ContractVersion, registration.LeaseToken, 10, null).Value!.Items); + Assert.Equal(created.AttemptId, cancelled.AttemptId); + Assert.True(cancelled.IsCancelled); + } + + [Fact] + public void CancellationAfterIntroductionRevokesTicketIssuanceAndConsumption() + { + using JoinAttemptFixture fixture = new(); + (RegisterSessionResponse registration, _) = fixture.CreateHost(); + CreateJoinAttemptResponse created = fixture.Create(registration.ListingId); + IntroductionEndpoints introduction = fixture.Introduce(registration, created); + JoinAttemptServiceResult 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] diff --git a/tests/FinalFactory.Rendezvous.Tests/Server/NatMediationProcessorTests.cs b/tests/FinalFactory.Rendezvous.Tests/Server/NatMediationProcessorTests.cs index 873da39..1590f59 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Server/NatMediationProcessorTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Server/NatMediationProcessorTests.cs @@ -56,11 +56,16 @@ public sealed class NatMediationProcessorTests Assert.Equal(Endpoint("192.168.1.11", 42_000), plan.ClientLocal); Assert.Equal(Endpoint("203.0.113.20", 51_000), plan.HostPublic); Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic); - Assert.Equal(43, plan.ConnectionTicket.Length); - Assert.DoesNotContain(plan.ConnectionTicket, plan.ToString(), StringComparison.Ordinal); + Assert.True(NatIntroductionTokenCodec.TryDecode( + plan.IntroductionToken, + out NatIntroductionToken? introduction)); + Assert.NotNull(introduction); + Assert.Equal(attempt.AttemptId, introduction.AttemptId); + Assert.Equal(43, introduction.ConnectionTicket.Length); + Assert.DoesNotContain(introduction.ConnectionTicket, plan.ToString(), StringComparison.Ordinal); Assert.True(fixture.Sessions.Capabilities.TryFingerprint( - plan.ConnectionTicket, + introduction.ConnectionTicket, out SecretFingerprint ticketFingerprint)); Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new( attempt.AttemptId, @@ -187,7 +192,7 @@ public sealed class NatMediationProcessorTests } [Fact] - public async Task CancellationCannotReportSuccessAfterIntroductionIsConsumed() + public async Task CancellationAfterIntroductionCreatesAHostRevocationTombstone() { using JoinAttemptFixture fixture = new(); (RegisterSessionResponse registration, _) = fixture.CreateHost(); @@ -209,9 +214,16 @@ public sealed class NatMediationProcessorTests attempt.AttemptId, attempt.ClientCapability); - Assert.Equal(RendezvousErrorCode.Conflict, cancelled.Error); + Assert.True(cancelled.Succeeded); sink.Release(); Assert.Equal(NatMediationResult.Introduced, await completion); + HostJoinAttempt cancelledAttempt = Assert.Single(fixture.Service.BrowseForHost( + registration.ListingId, + ContractLimits.ContractVersion, + registration.LeaseToken, + ContractLimits.BrowserPageMaxItems, + null).Value!.Items); + Assert.True(cancelledAttempt.IsCancelled); } [Fact] diff --git a/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs index 8393850..bd9a437 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Server/UdpMediatorServiceTests.cs @@ -159,7 +159,12 @@ public sealed class UdpMediatorServiceTests string hostTicket = Assert.Single(hostTickets.Distinct(StringComparer.Ordinal)); string clientTicket = Assert.Single(clientTickets.Distinct(StringComparer.Ordinal)); Assert.Equal(hostTicket, clientTicket); - Assert.Equal(43, hostTicket.Length); + Assert.True(NatIntroductionTokenCodec.TryDecode( + hostTicket, + out NatIntroductionToken? introduction)); + Assert.NotNull(introduction); + Assert.Equal(created.AttemptId, introduction.AttemptId); + Assert.Equal(43, introduction.ConnectionTicket.Length); } finally { @@ -229,7 +234,9 @@ public sealed class UdpMediatorServiceTests Assert.True( hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length <= 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 { diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt index 5a9d25b..dce0b1b 100644 --- a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt +++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt @@ -12,8 +12,23 @@ TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId) METHOD System.String ToString() METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt) +TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequest + CTOR () + PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;} + PROP System.String ConnectionTicket {get;set;} + METHOD System.String ToString() +TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequestCodec + FIELD System.Int32 EncodedLength=63 + METHOD System.Byte[] Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket) + METHOD System.Boolean IsRendezvousRequest(System.ReadOnlySpan encoded) + METHOD System.Boolean TryDecode(System.ReadOnlySpan encoded, FinalFactory.Rendezvous.Client.DirectConnectionRequest& request) TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken) +TYPE FinalFactory.Rendezvous.Client.IRendezvousJoinClient + METHOD System.Threading.Tasks.Task>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken) TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient METHOD System.Threading.Tasks.Task> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) METHOD System.Threading.Tasks.Task> 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 FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;} METHOD System.String ToString() +TYPE FinalFactory.Rendezvous.Client.RendezvousClientCoordinator + CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options) + PROP LiteNetLib.NetPeer ConnectedPeer {get;} + PROP System.Boolean IsCompleted {get;} + PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;} + EVENT System.EventHandler Completed + METHOD System.Void Cancel() + METHOD System.Threading.Tasks.Task> CancelAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken) + METHOD System.Void Dispose() + METHOD System.Void Poll() + METHOD System.String ToString() TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions CTOR () PROP System.TimeSpan InitialRetryDelay {get;set;} @@ -56,6 +82,66 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult PROP System.String Message {get;} PROP System.Nullable RetryAfterSeconds {get;} PROP T Value {get;} +TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs + CTOR (FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer) + PROP LiteNetLib.NetPeer Peer {get;} + PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;} +TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState + ENUM Punching=1 + ENUM Connecting=2 + ENUM Connected=3 + ENUM Cancelled=4 + ENUM TimedOut=5 + ENUM Rejected=6 + ENUM ManagerStopped=7 + ENUM Disposed=8 +TYPE FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions + CTOR () + PROP System.TimeSpan ConnectionTicketLifetime {get;set;} + PROP System.TimeSpan InitialPunchRetryDelay {get;set;} + PROP System.Double JitterRatio {get;set;} + PROP System.Int32 MaximumAttemptChecksPerPoll {get;set;} + PROP System.Int32 MaximumPunchRequests {get;set;} + PROP System.TimeSpan MaximumPunchRetryDelay {get;set;} +TYPE FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs + CTOR (FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer) + PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;} + PROP LiteNetLib.NetPeer Peer {get;} + PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;} +TYPE FinalFactory.Rendezvous.Client.RendezvousHostCoordinator + CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options) + PROP System.Int32 PendingAttemptCount {get;} + PROP FinalFactory.Rendezvous.Client.RendezvousHostState State {get;} + EVENT System.EventHandler AttemptCompleted + METHOD System.Void Dispose() + METHOD System.Void Poll() + METHOD System.Threading.Tasks.Task> 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>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken) +TYPE FinalFactory.Rendezvous.Client.RendezvousNetListener + CTOR () + PROP LiteNetLib.EventBasedNetListener GameplayEvents {get;} + PROP LiteNetLib.EventBasedNatPunchListener PunchEvents {get;} + METHOD LiteNetLib.NetManager CreateManager() + METHOD System.Void OnConnectionRequest(LiteNetLib.ConnectionRequest request) + METHOD System.Void OnMessageDelivered(LiteNetLib.NetPeer peer, System.Object userData) + METHOD System.Void OnNetworkError(System.Net.IPEndPoint endPoint, System.Net.Sockets.SocketError socketError) + METHOD System.Void OnNetworkLatencyUpdate(LiteNetLib.NetPeer peer, System.Int32 latency) + METHOD System.Void OnNetworkReceive(LiteNetLib.NetPeer peer, LiteNetLib.NetPacketReader reader, System.Byte channelNumber, LiteNetLib.DeliveryMethod deliveryMethod) + METHOD System.Void OnNetworkReceiveUnconnected(System.Net.IPEndPoint remoteEndPoint, LiteNetLib.NetPacketReader reader, LiteNetLib.UnconnectedMessageType messageType) + METHOD System.Void OnNtpResponse(LiteNetLib.Utils.NtpPacket packet) + METHOD System.Void OnPeerAddressChanged(LiteNetLib.NetPeer peer, System.Net.IPEndPoint previousAddress) + METHOD System.Void OnPeerConnected(LiteNetLib.NetPeer peer) + METHOD System.Void OnPeerDisconnected(LiteNetLib.NetPeer peer, LiteNetLib.DisconnectInfo disconnectInfo) TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay) METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential) diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt index eca1d73..54bcc48 100644 --- a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt +++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt @@ -98,6 +98,7 @@ TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse CTOR () PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;} PROP System.String ClientPunchCapability {get;set;} + PROP System.String ConnectionTicketDigest {get;set;} PROP System.Int32 ContractVersion {get;set;} PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;} PROP System.DateTimeOffset ExpiresAt {get;set;} @@ -137,8 +138,10 @@ TYPE FinalFactory.Rendezvous.Contracts.HealthResponse TYPE FinalFactory.Rendezvous.Contracts.HostJoinAttempt CTOR () PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;} + PROP System.String ConnectionTicketDigest {get;set;} PROP System.DateTimeOffset ExpiresAt {get;set;} PROP System.String HostPunchCapability {get;set;} + PROP System.Boolean IsCancelled {get;set;} PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;} TYPE FinalFactory.Rendezvous.Contracts.JoinAttemptId CTOR (System.Guid value) @@ -173,6 +176,17 @@ TYPE FinalFactory.Rendezvous.Contracts.MediationHandle METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id) METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right) METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right) +TYPE FinalFactory.Rendezvous.Contracts.NatIntroductionToken + CTOR () + PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;} + PROP System.String ConnectionTicket {get;set;} + METHOD System.String ToString() +TYPE FinalFactory.Rendezvous.Contracts.NatIntroductionTokenCodec + FIELD System.Int32 EncodedLength=43 + METHOD System.String ComputeDigest(System.String connectionTicket) + METHOD System.String Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String derivedAuthenticator) + METHOD System.Boolean MatchesDigest(System.String connectionTicket, System.String expectedDigest) + METHOD System.Boolean TryDecode(System.String encoded, FinalFactory.Rendezvous.Contracts.NatIntroductionToken& token) TYPE FinalFactory.Rendezvous.Contracts.NatPunchPeerRole ENUM HostPresence=1 ENUM Host=2 diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/create-join-response.json b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/create-join-response.json index 41280de..93ffbb3 100644 --- a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/create-join-response.json +++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/create-join-response.json @@ -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}}