feat(client): add rendezvous traversal coordinators (#12)
quality-gate / quality (push) Successful in 56s

This commit is contained in:
KyuubiYoru
2026-07-16 08:39:05 +02:00
parent 6d076c281a
commit b4b6072fe1
28 changed files with 2949 additions and 57 deletions
@@ -0,0 +1,148 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousJoinClient : IRendezvousJoinClient
{
private const string LeaseTokenHeader = "X-Rendezvous-Lease-Token";
private const string ClientPunchCapabilityHeader = "X-Rendezvous-Client-Punch-Capability";
private readonly RendezvousHttpTransport _transport;
public RendezvousJoinClient(
HttpClient httpClient,
RendezvousClientOptions? options = null,
IRendezvousDelay? delay = null)
{
_transport = new(httpClient, options, delay);
}
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
CreateJoinAttemptRequest body = new()
{
ContractVersion = request.ContractVersion,
IdempotencyKey = request.IdempotencyKey,
GameId = request.GameId,
EnvironmentId = request.EnvironmentId,
ListingId = request.ListingId,
ProtocolVersion = request.ProtocolVersion,
};
return _transport.SendSafeAsync<CreateJoinAttemptResponse>(
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/join-attempts", body),
cancellationToken);
}
public Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default)
{
if (attempt is null)
{
throw new ArgumentNullException(nameof(attempt));
}
return _transport.SendSafeAsync<bool>(
() => HeaderRequest(
HttpMethod.Delete,
$"v1/join-attempts/{attempt.AttemptId}",
ClientPunchCapabilityHeader,
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt))),
cancellationToken);
}
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
if (pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
{
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
string query = $"v1/sessions/{session.ListingId}/join-attempts"
+ $"?contractVersion={ContractLimits.ContractVersion}"
+ $"&pageSize={pageSize}"
+ (cursor is null ? string.Empty : $"&cursor={Uri.EscapeDataString(cursor)}");
return _transport.SendSafeAsync<BrowseHostJoinAttemptsResponse>(
() => HeaderRequest(
HttpMethod.Get,
query,
LeaseTokenHeader,
RequireHeaderValue(session.LeaseToken, nameof(session))),
cancellationToken);
}
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}
if (maximumPages is < 1 or > 1_000)
{
throw new ArgumentOutOfRangeException(nameof(maximumPages));
}
List<HostJoinAttempt> attempts = [];
string? cursor = null;
for (int page = 0; page < maximumPages; page++)
{
RendezvousClientResult<BrowseHostJoinAttemptsResponse> result =
await BrowseForHostAsync(
session,
ContractLimits.BrowserPageMaxItems,
cursor,
cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
attempts.AddRange(result.Value.Items);
cursor = result.Value.NextCursor;
if (string.IsNullOrEmpty(cursor))
{
return RendezvousClientResult.Success<IReadOnlyList<HostJoinAttempt>>(
attempts.AsReadOnly());
}
}
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
RendezvousErrorCode.CapacityExceeded,
$"Host invitation polling exceeded the configured {maximumPages}-page limit.");
}
private static HttpRequestMessage HeaderRequest(
HttpMethod method,
string uri,
string header,
string value)
{
HttpRequestMessage request = new(method, uri);
request.Headers.TryAddWithoutValidation(header, value);
return request;
}
private static string RequireHeaderValue(string value, string parameterName) =>
!string.IsNullOrWhiteSpace(value)
? value
: throw new ArgumentException("The required capability is missing.", parameterName);
}
+70 -5
View File
@@ -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:
@@ -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<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
PublishedSession session,
int pageSize = ContractLimits.BrowserPageMaxItems,
string? cursor = null,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default);
}
public interface IRendezvousDelay
{
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
@@ -0,0 +1,83 @@
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class DirectConnectionRequest
{
public JoinAttemptId AttemptId { get; set; }
public string ConnectionTicket { get; set; } = string.Empty;
public override string ToString() =>
$"[DirectConnectionRequest {AttemptId}; ticket redacted]";
}
public static class DirectConnectionRequestCodec
{
public const int EncodedLength = 63;
private const int MagicLength = 4;
private const int AttemptIdLength = 16;
private const int TicketLength = ContractLimits.DerivedCredentialCharacters;
private static readonly byte[] Magic = [(byte)'R', (byte)'V', (byte)'D', (byte)'1'];
public static bool IsRendezvousRequest(ReadOnlySpan<byte> encoded) =>
encoded.Length >= MagicLength && encoded[..MagicLength].SequenceEqual(Magic);
public static byte[] Encode(JoinAttemptId attemptId, string connectionTicket)
{
if (attemptId.Value == Guid.Empty
|| connectionTicket is null
|| connectionTicket.Length != TicketLength
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
{
throw new ArgumentException("The direct connection request fields are invalid.");
}
byte[] encoded = new byte[EncodedLength];
Magic.CopyTo(encoded, 0);
if (!attemptId.Value.TryWriteBytes(encoded.AsSpan(MagicLength, AttemptIdLength)))
{
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
}
Encoding.ASCII.GetBytes(
connectionTicket,
0,
connectionTicket.Length,
encoded,
MagicLength + AttemptIdLength);
return encoded;
}
public static bool TryDecode(
ReadOnlySpan<byte> encoded,
out DirectConnectionRequest? request)
{
request = null;
if (encoded.Length != EncodedLength
|| !encoded[..MagicLength].SequenceEqual(Magic))
{
return false;
}
Guid attemptId = new(encoded.Slice(MagicLength, AttemptIdLength));
if (attemptId == Guid.Empty)
{
return false;
}
string ticket = Encoding.ASCII.GetString(encoded[(MagicLength + AttemptIdLength)..]);
if (!ContractValidation.IsConnectionTicketValid(ticket))
{
return false;
}
request = new DirectConnectionRequest
{
AttemptId = new JoinAttemptId(attemptId),
ConnectionTicket = ticket,
};
return true;
}
}
@@ -0,0 +1,290 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousClientCoordinator : IDisposable
{
private readonly NetManager _manager;
private readonly RendezvousNetListener _networkEvents;
private readonly EventBasedNatPunchListener _punchEvents;
private readonly IPEndPoint _mediator;
private readonly CreateJoinAttemptResponse _attempt;
private readonly IRendezvousCoordinatorClock _clock;
private readonly RendezvousPunchRetrySchedule _retry;
private NetPeer? _connectingPeer;
private bool _cancelRequested;
private int _polling;
private bool _subscriptionsReleased;
private bool _disposed;
public RendezvousClientCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
CreateJoinAttemptResponse attempt,
RendezvousCoordinatorOptions? options = null)
: this(
manager,
networkEvents,
mediator,
attempt,
options,
new SystemRendezvousCoordinatorClock())
{
}
internal RendezvousClientCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
CreateJoinAttemptResponse attempt,
RendezvousCoordinatorOptions? options,
IRendezvousCoordinatorClock clock)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
_punchEvents = _networkEvents.PunchEvents;
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_attempt = attempt ?? throw new ArgumentNullException(nameof(attempt));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
RendezvousCoordinatorOptions validated = (options ?? new RendezvousCoordinatorOptions())
.CopyAndValidate();
_retry = new(validated, _clock);
RendezvousManagerGuard.Validate(_manager, _networkEvents);
if (_mediator.Port is < 1 or > 65_535
|| _attempt.AttemptId.Value == Guid.Empty
|| _attempt.MediationHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(_attempt.ClientPunchCapability)
|| !ContractValidation.IsConnectionTicketValid(_attempt.ConnectionTicketDigest)
|| _attempt.ExpiresAt <= _clock.UtcNow)
{
throw new ArgumentException("The client traversal inputs are invalid.");
}
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
}
public event EventHandler<RendezvousConnectionCompletedEventArgs>? Completed;
public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching;
public NetPeer? ConnectedPeer { get; private set; }
public bool IsCompleted => IsTerminal(State);
public void Cancel() => Volatile.Write(ref _cancelRequested, true);
public async Task<RendezvousClientResult<bool>> CancelAsync(
IRendezvousJoinClient joinClient,
CancellationToken cancellationToken = default)
{
if (joinClient is null)
{
throw new ArgumentNullException(nameof(joinClient));
}
ThrowIfDisposed();
Cancel();
return await joinClient.CancelAsync(_attempt, cancellationToken).ConfigureAwait(false);
}
public void Poll()
{
ThrowIfDisposed();
if (IsCompleted)
{
return;
}
if (Interlocked.Exchange(ref _polling, 1) != 0)
{
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
}
try
{
if (Volatile.Read(ref _cancelRequested))
{
DisconnectPendingPeer();
Complete(RendezvousConnectionState.Cancelled);
return;
}
if (!_manager.IsRunning)
{
Complete(RendezvousConnectionState.ManagerStopped);
return;
}
_manager.PollEvents();
_manager.NatPunchModule.PollEvents();
if (IsCompleted)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
if (Volatile.Read(ref _cancelRequested))
{
DisconnectPendingPeer();
Complete(RendezvousConnectionState.Cancelled);
}
else if (!_manager.IsRunning)
{
Complete(RendezvousConnectionState.ManagerStopped);
}
else if (now >= _attempt.ExpiresAt)
{
DisconnectPendingPeer();
Complete(RendezvousConnectionState.TimedOut);
}
else if (State == RendezvousConnectionState.Punching && _retry.IsDue(now))
{
if (_retry.IsExhausted)
{
Complete(RendezvousConnectionState.TimedOut);
return;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Client,
_attempt.MediationHandle,
_attempt.ClientPunchCapability));
_retry.RecordRequest();
}
}
finally
{
Volatile.Write(ref _polling, 0);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
if (!IsCompleted)
{
Complete(RendezvousConnectionState.Disposed);
}
ReleaseSubscriptions();
_disposed = true;
}
public override string ToString() =>
$"[RendezvousClientCoordinator {_attempt.AttemptId}; credentials redacted]";
private void OnNatIntroductionSuccess(
IPEndPoint target,
NatAddressType addressType,
string encodedIntroduction)
{
_ = addressType;
if (State != RendezvousConnectionState.Punching
|| !NatIntroductionTokenCodec.TryDecode(
encodedIntroduction,
out NatIntroductionToken? introduction)
|| introduction is null
|| introduction.AttemptId != _attempt.AttemptId
|| !NatIntroductionTokenCodec.MatchesDigest(
introduction.ConnectionTicket,
_attempt.ConnectionTicketDigest))
{
return;
}
byte[] connectionData = DirectConnectionRequestCodec.Encode(
introduction.AttemptId,
introduction.ConnectionTicket);
_connectingPeer = _manager.Connect(target, connectionData);
if (_connectingPeer is null
|| _connectingPeer.ConnectionState != ConnectionState.Outgoing)
{
_connectingPeer = null;
Complete(RendezvousConnectionState.Rejected);
return;
}
State = RendezvousConnectionState.Connecting;
}
private void OnPeerConnected(NetPeer peer)
{
if (State != RendezvousConnectionState.Connecting
|| !ReferenceEquals(peer, _connectingPeer))
{
return;
}
ConnectedPeer = peer;
Complete(RendezvousConnectionState.Connected, peer);
}
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
_ = disconnectInfo;
if (State == RendezvousConnectionState.Connecting
&& ReferenceEquals(peer, _connectingPeer))
{
Complete(RendezvousConnectionState.Rejected);
}
}
private void DisconnectPendingPeer()
{
if (_connectingPeer is not null && State == RendezvousConnectionState.Connecting)
{
_connectingPeer.Disconnect();
}
}
private void Complete(RendezvousConnectionState terminalState, NetPeer? peer = null)
{
if (IsCompleted)
{
return;
}
State = terminalState;
ReleaseSubscriptions();
Completed?.Invoke(this, new(terminalState, peer));
}
private void ReleaseSubscriptions()
{
if (_subscriptionsReleased)
{
return;
}
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true;
}
private static bool IsTerminal(RendezvousConnectionState state) => state is
RendezvousConnectionState.Connected
or RendezvousConnectionState.Cancelled
or RendezvousConnectionState.TimedOut
or RendezvousConnectionState.Rejected
or RendezvousConnectionState.ManagerStopped
or RendezvousConnectionState.Disposed;
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(RendezvousClientCoordinator));
}
}
}
@@ -0,0 +1,124 @@
using System.Security.Cryptography;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public enum RendezvousConnectionState
{
Punching = 1,
Connecting = 2,
Connected = 3,
Cancelled = 4,
TimedOut = 5,
Rejected = 6,
ManagerStopped = 7,
Disposed = 8,
}
public sealed class RendezvousConnectionCompletedEventArgs(
RendezvousConnectionState state,
NetPeer? peer = null) : EventArgs
{
public RendezvousConnectionState State { get; } = state;
public NetPeer? Peer { get; } = peer;
}
public sealed class RendezvousCoordinatorOptions
{
public int MaximumPunchRequests { get; set; } = 5;
public int MaximumAttemptChecksPerPoll { get; set; } = 128;
public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
public TimeSpan MaximumPunchRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20);
public double JitterRatio { get; set; } = 0.2;
internal RendezvousCoordinatorOptions CopyAndValidate()
{
if (MaximumPunchRequests is < 1 or > 20
|| MaximumAttemptChecksPerPoll is < 1 or > 1_024
|| InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10)
|| MaximumPunchRetryDelay < InitialPunchRetryDelay
|| MaximumPunchRetryDelay > TimeSpan.FromSeconds(10)
|| ConnectionTicketLifetime <= TimeSpan.Zero
|| ConnectionTicketLifetime > TimeSpan.FromSeconds(20)
|| JitterRatio is < 0 or > 1)
{
throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions));
}
return new RendezvousCoordinatorOptions
{
MaximumPunchRequests = MaximumPunchRequests,
MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll,
InitialPunchRetryDelay = InitialPunchRetryDelay,
MaximumPunchRetryDelay = MaximumPunchRetryDelay,
ConnectionTicketLifetime = ConnectionTicketLifetime,
JitterRatio = JitterRatio,
};
}
}
internal interface IRendezvousCoordinatorClock
{
DateTimeOffset UtcNow { get; }
}
internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
internal static class RendezvousManagerGuard
{
internal static void Validate(
NetManager manager,
RendezvousNetListener networkEvents)
{
networkEvents.ValidateManager(manager);
if (!manager.IsRunning)
{
throw new InvalidOperationException("The caller-owned LiteNetLib manager must be running.");
}
if (!manager.NatPunchEnabled
|| manager.UnsyncedEvents
|| manager.NatPunchModule.UnsyncedEvents)
{
throw new InvalidOperationException(
"The caller-owned manager must enable NAT punching and synchronized event dispatch.");
}
}
}
internal sealed class RendezvousPunchRetrySchedule(
RendezvousCoordinatorOptions options,
IRendezvousCoordinatorClock clock)
{
public int RequestsSent { get; private set; }
public DateTimeOffset NextRequestAt { get; private set; } = DateTimeOffset.MinValue;
public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests;
public bool IsDue(DateTimeOffset now) => now >= NextRequestAt;
public void RecordRequest()
{
int exponent = Math.Min(RequestsSent, 30);
RequestsSent++;
double milliseconds = Math.Min(
options.InitialPunchRetryDelay.TotalMilliseconds * Math.Pow(2, exponent),
options.MaximumPunchRetryDelay.TotalMilliseconds);
if (options.JitterRatio > 0)
{
Span<byte> random = stackalloc byte[1];
RandomNumberGenerator.Fill(random);
double unit = random[0] / 255d;
double multiplier = 1 - options.JitterRatio + (2 * options.JitterRatio * unit);
milliseconds = Math.Min(
milliseconds * multiplier,
options.MaximumPunchRetryDelay.TotalMilliseconds);
}
NextRequestAt = clock.UtcNow + TimeSpan.FromMilliseconds(milliseconds);
}
}
@@ -0,0 +1,547 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public enum RendezvousHostState
{
Active = 1,
ManagerStopped = 2,
Disposed = 3,
}
public sealed class RendezvousHostAttemptCompletedEventArgs(
JoinAttemptId attemptId,
RendezvousConnectionState state,
NetPeer? peer = null) : EventArgs
{
public JoinAttemptId AttemptId { get; } = attemptId;
public RendezvousConnectionState State { get; } = state;
public NetPeer? Peer { get; } = peer;
}
public sealed class RendezvousHostCoordinator : IDisposable
{
private readonly NetManager _manager;
private readonly RendezvousNetListener _networkEvents;
private readonly EventBasedNatPunchListener _punchEvents;
private readonly IPEndPoint _mediator;
private readonly PublishedSession _session;
private readonly IRendezvousJoinClient _joinClient;
private readonly RendezvousCoordinatorOptions _options;
private readonly IRendezvousCoordinatorClock _clock;
private readonly ConnectionTicketValidator _tickets;
private readonly Dictionary<JoinAttemptId, PendingHostAttempt> _attempts = [];
private readonly Dictionary<NetPeer, JoinAttemptId> _acceptedPeers = [];
private readonly Dictionary<JoinAttemptId, DeferredConnectionRequest> _deferredRequests = [];
private readonly Dictionary<JoinAttemptId, DateTimeOffset> _terminalAttempts = [];
private readonly Queue<JoinAttemptId> _attemptSchedule = [];
private readonly List<JoinAttemptId> _cleanupScratch = [];
private HostJoinAttempt[]? _latestSnapshot;
private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue;
private DateTimeOffset _nextTerminalCleanupAt = DateTimeOffset.MinValue;
private int _refreshing;
private int _polling;
private bool _subscriptionsReleased;
private int _disposed;
public RendezvousHostCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
PublishedSession session,
IRendezvousJoinClient joinClient,
RendezvousCoordinatorOptions? options = null)
: this(
manager,
networkEvents,
mediator,
session,
joinClient,
options,
new SystemRendezvousCoordinatorClock(),
null)
{
}
internal RendezvousHostCoordinator(
NetManager manager,
RendezvousNetListener networkEvents,
IPEndPoint mediator,
PublishedSession session,
IRendezvousJoinClient joinClient,
RendezvousCoordinatorOptions? options,
IRendezvousCoordinatorClock clock,
ConnectionTicketValidator? tickets)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
_punchEvents = _networkEvents.PunchEvents;
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_session = session ?? throw new ArgumentNullException(nameof(session));
_joinClient = joinClient ?? throw new ArgumentNullException(nameof(joinClient));
_options = (options ?? new RendezvousCoordinatorOptions()).CopyAndValidate();
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_tickets = tickets ?? new ConnectionTicketValidator();
RendezvousManagerGuard.Validate(_manager, _networkEvents);
ValidateInputs();
_networkEvents.RendezvousConnectionRequest += OnConnectionRequest;
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
}
public event EventHandler<RendezvousHostAttemptCompletedEventArgs>? AttemptCompleted;
public RendezvousHostState State { get; private set; } = RendezvousHostState.Active;
public int PendingAttemptCount => _attempts.Count;
internal int DeferredRequestCount => _deferredRequests.Count;
public async Task<RendezvousClientResult<int>> RefreshJoinAttemptsAsync(
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (Interlocked.Exchange(ref _refreshing, 1) != 0)
{
throw new InvalidOperationException("A host invitation refresh is already running.");
}
try
{
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
await _joinClient.BrowseAllForHostAsync(
_session,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
return RendezvousClientResult.Failure<int>(
result.Error,
result.Message,
result.RetryAfterSeconds);
}
HostJoinAttempt[] snapshot = result.Value.Select(CopyAttempt).ToArray();
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
Interlocked.Exchange(ref _latestSnapshot, snapshot);
if (Volatile.Read(ref _disposed) != 0)
{
Interlocked.Exchange(ref _latestSnapshot, null);
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
return RendezvousClientResult.Success(snapshot.Length);
}
finally
{
Volatile.Write(ref _refreshing, 0);
}
}
public void Poll()
{
ThrowIfDisposed();
if (State != RendezvousHostState.Active)
{
return;
}
if (Interlocked.Exchange(ref _polling, 1) != 0)
{
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
}
try
{
ApplySnapshots();
if (!_manager.IsRunning)
{
Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped);
return;
}
_manager.NatPunchModule.PollEvents();
_manager.PollEvents();
_manager.NatPunchModule.PollEvents();
if (State != RendezvousHostState.Active)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
if (!_manager.IsRunning)
{
Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped);
return;
}
RefreshPresence(now);
int checks = Math.Min(
_attemptSchedule.Count,
_options.MaximumAttemptChecksPerPoll);
for (int index = 0; index < checks; index++)
{
JoinAttemptId attemptId = _attemptSchedule.Dequeue();
if (!_attempts.TryGetValue(attemptId, out PendingHostAttempt? attempt))
{
continue;
}
if (now >= attempt.Invitation.ExpiresAt)
{
CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut);
continue;
}
if (attempt.State == RendezvousConnectionState.Punching
&& attempt.Retry.IsDue(now))
{
if (attempt.Retry.IsExhausted)
{
CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut);
continue;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Host,
attempt.Invitation.MediationHandle,
attempt.Invitation.HostPunchCapability));
attempt.Retry.RecordRequest();
}
_attemptSchedule.Enqueue(attemptId);
}
if (now >= _nextTerminalCleanupAt)
{
_cleanupScratch.Clear();
foreach (KeyValuePair<JoinAttemptId, DateTimeOffset> terminal in _terminalAttempts)
{
if (terminal.Value <= now)
{
_cleanupScratch.Add(terminal.Key);
}
}
foreach (JoinAttemptId attemptId in _cleanupScratch)
{
_terminalAttempts.Remove(attemptId);
}
_nextTerminalCleanupAt = now + TimeSpan.FromSeconds(1);
}
}
finally
{
Volatile.Write(ref _polling, 0);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
Stop(RendezvousHostState.Disposed, RendezvousConnectionState.Disposed);
Interlocked.Exchange(ref _latestSnapshot, null);
_attemptSchedule.Clear();
_terminalAttempts.Clear();
_cleanupScratch.Clear();
_tickets.Dispose();
}
public override string ToString() =>
$"[RendezvousHostCoordinator {_session.ListingId}; credentials redacted]";
private void ApplySnapshots()
{
HostJoinAttempt[]? latest = Interlocked.Exchange(ref _latestSnapshot, null);
if (latest is null)
{
return;
}
DateTimeOffset now = _clock.UtcNow;
foreach (HostJoinAttempt invitation in latest)
{
if (invitation.AttemptId.Value == Guid.Empty
|| invitation.MediationHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(invitation.HostPunchCapability)
|| !ContractValidation.IsConnectionTicketValid(
invitation.ConnectionTicketDigest))
{
continue;
}
if (invitation.IsCancelled)
{
if (_attempts.ContainsKey(invitation.AttemptId))
{
CompleteAttempt(
invitation.AttemptId,
RendezvousConnectionState.Cancelled);
}
_terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt;
continue;
}
if (invitation.ExpiresAt <= now
|| _attempts.ContainsKey(invitation.AttemptId)
|| _terminalAttempts.ContainsKey(invitation.AttemptId))
{
continue;
}
_attempts.Add(
invitation.AttemptId,
new PendingHostAttempt(
CopyAttempt(invitation),
new RendezvousPunchRetrySchedule(_options, _clock)));
_attemptSchedule.Enqueue(invitation.AttemptId);
}
}
private void RefreshPresence(DateTimeOffset now)
{
if (now < _nextPresenceAt || now >= _session.ExpiresAt)
{
return;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.HostPresence,
_session.HostPresenceHandle,
_session.HostPresenceCapability));
_nextPresenceAt = now + TimeSpan.FromSeconds(_session.HostPresenceRefreshAfterSeconds);
}
private void OnNatIntroductionSuccess(
IPEndPoint target,
NatAddressType addressType,
string encodedIntroduction)
{
_ = target;
_ = addressType;
if (!NatIntroductionTokenCodec.TryDecode(
encodedIntroduction,
out NatIntroductionToken? introduction)
|| introduction is null
|| !_attempts.TryGetValue(introduction.AttemptId, out PendingHostAttempt? attempt)
|| !NatIntroductionTokenCodec.MatchesDigest(
introduction.ConnectionTicket,
attempt.Invitation.ConnectionTicketDigest)
|| !_tickets.TryAuthorize(
introduction.AttemptId,
introduction.ConnectionTicket,
Min(
attempt.Invitation.ExpiresAt,
_clock.UtcNow + _options.ConnectionTicketLifetime)))
{
return;
}
attempt.State = RendezvousConnectionState.Connecting;
if (_deferredRequests.Remove(
introduction.AttemptId,
out DeferredConnectionRequest? deferred))
{
AcceptAuthorizedRequest(
introduction.AttemptId,
attempt,
deferred.Request,
deferred.ConnectionTicket);
}
}
private void OnConnectionRequest(ConnectionRequest request)
{
ReadOnlySpan<byte> data = request.Data.GetRemainingBytesSpan();
if (!DirectConnectionRequestCodec.IsRendezvousRequest(data))
{
return;
}
if (!DirectConnectionRequestCodec.TryDecode(data, out DirectConnectionRequest? connection)
|| connection is null
|| !_attempts.TryGetValue(connection.AttemptId, out PendingHostAttempt? attempt)
|| !NatIntroductionTokenCodec.MatchesDigest(
connection.ConnectionTicket,
attempt.Invitation.ConnectionTicketDigest))
{
request.RejectForce([]);
return;
}
if (attempt.State == RendezvousConnectionState.Punching)
{
_deferredRequests[connection.AttemptId] = new(
request,
connection.ConnectionTicket);
return;
}
if (attempt.State != RendezvousConnectionState.Connecting)
{
request.RejectForce([]);
return;
}
AcceptAuthorizedRequest(
connection.AttemptId,
attempt,
request,
connection.ConnectionTicket);
}
private void OnPeerConnected(NetPeer peer)
{
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
{
CompleteAttempt(attemptId, RendezvousConnectionState.Connected, peer);
}
}
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
_ = disconnectInfo;
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
{
CompleteAttempt(attemptId, RendezvousConnectionState.Rejected);
}
}
private void CompleteAttempt(
JoinAttemptId attemptId,
RendezvousConnectionState state,
NetPeer? peer = null)
{
if (!_attempts.Remove(attemptId, out PendingHostAttempt? attempt))
{
return;
}
if (attempt.AcceptedPeer is not null)
{
_acceptedPeers.Remove(attempt.AcceptedPeer);
}
_deferredRequests.Remove(attemptId);
_tickets.Revoke(attemptId);
_terminalAttempts[attemptId] = attempt.Invitation.ExpiresAt;
AttemptCompleted?.Invoke(this, new(attemptId, state, peer));
}
private void Stop(RendezvousHostState hostState, RendezvousConnectionState attemptState)
{
if (State != RendezvousHostState.Active)
{
return;
}
State = hostState;
foreach (JoinAttemptId attemptId in _attempts.Keys.ToArray())
{
CompleteAttempt(attemptId, attemptState);
}
ReleaseSubscriptions();
}
private void ReleaseSubscriptions()
{
if (_subscriptionsReleased)
{
return;
}
_networkEvents.RendezvousConnectionRequest -= OnConnectionRequest;
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true;
}
private void ValidateInputs()
{
if (_mediator.Port is < 1 or > 65_535
|| _session.HostPresenceHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(_session.HostPresenceCapability)
|| _session.HostPresenceRefreshAfterSeconds < 1
|| _session.ExpiresAt <= _clock.UtcNow)
{
throw new ArgumentException("The host traversal inputs are invalid.");
}
}
private static HostJoinAttempt CopyAttempt(HostJoinAttempt attempt) => new()
{
AttemptId = attempt.AttemptId,
MediationHandle = attempt.MediationHandle,
HostPunchCapability = attempt.HostPunchCapability,
ConnectionTicketDigest = attempt.ConnectionTicketDigest,
IsCancelled = attempt.IsCancelled,
ExpiresAt = attempt.ExpiresAt,
};
private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) =>
left <= right ? left : right;
private void AcceptAuthorizedRequest(
JoinAttemptId attemptId,
PendingHostAttempt attempt,
ConnectionRequest request,
string connectionTicket)
{
ConnectionTicketConsumptionResult consumption = _tickets.Consume(
attemptId,
connectionTicket);
if (consumption != ConnectionTicketConsumptionResult.Accepted)
{
request.RejectForce([]);
return;
}
NetPeer peer = request.Accept();
attempt.AcceptedPeer = peer;
_acceptedPeers[peer] = attemptId;
}
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
}
}
private sealed class PendingHostAttempt(
HostJoinAttempt invitation,
RendezvousPunchRetrySchedule retry)
{
internal HostJoinAttempt Invitation { get; } = invitation;
internal RendezvousPunchRetrySchedule Retry { get; } = retry;
internal RendezvousConnectionState State { get; set; } = RendezvousConnectionState.Punching;
internal NetPeer? AcceptedPeer { get; set; }
}
private sealed class DeferredConnectionRequest(
ConnectionRequest request,
string connectionTicket)
{
internal ConnectionRequest Request { get; } = request;
internal string ConnectionTicket { get; } = connectionTicket;
}
}
@@ -0,0 +1,110 @@
using System.Net;
using System.Net.Sockets;
using LiteNetLib;
using LiteNetLib.Utils;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousNetListener : INetEventListener
{
private NetManager? _manager;
public EventBasedNetListener GameplayEvents { get; } = new();
public EventBasedNatPunchListener PunchEvents { get; } = new();
public NetManager CreateManager()
{
if (_manager is not null)
{
throw new InvalidOperationException(
"This Rendezvous listener is already bound to a LiteNetLib manager.");
}
NetManager manager = new(this) { NatPunchEnabled = true };
manager.NatPunchModule.Init(PunchEvents);
_manager = manager;
return manager;
}
internal event Action<NetPeer>? RendezvousPeerConnected;
internal event Action<NetPeer, DisconnectInfo>? RendezvousPeerDisconnected;
internal event Action<ConnectionRequest>? RendezvousConnectionRequest;
internal void ValidateManager(NetManager manager)
{
if (!ReferenceEquals(_manager, manager))
{
throw new InvalidOperationException(
"The LiteNetLib manager must be created by this Rendezvous listener.");
}
}
public void OnPeerConnected(NetPeer peer)
{
RendezvousPeerConnected?.Invoke(peer);
((INetEventListener)GameplayEvents).OnPeerConnected(peer);
}
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
RendezvousPeerDisconnected?.Invoke(peer, disconnectInfo);
((INetEventListener)GameplayEvents).OnPeerDisconnected(peer, disconnectInfo);
}
public void OnNetworkError(IPEndPoint endPoint, SocketError socketError) =>
((INetEventListener)GameplayEvents).OnNetworkError(endPoint, socketError);
public void OnNetworkReceive(
NetPeer peer,
NetPacketReader reader,
byte channelNumber,
DeliveryMethod deliveryMethod) =>
((INetEventListener)GameplayEvents).OnNetworkReceive(
peer,
reader,
channelNumber,
deliveryMethod);
public void OnNetworkReceiveUnconnected(
IPEndPoint remoteEndPoint,
NetPacketReader reader,
UnconnectedMessageType messageType) =>
((INetEventListener)GameplayEvents).OnNetworkReceiveUnconnected(
remoteEndPoint,
reader,
messageType);
public void OnNetworkLatencyUpdate(NetPeer peer, int latency) =>
((INetEventListener)GameplayEvents).OnNetworkLatencyUpdate(peer, latency);
public void OnConnectionRequest(ConnectionRequest request)
{
int position = request.Data.Position;
bool isRendezvous = DirectConnectionRequestCodec.IsRendezvousRequest(
request.Data.GetRemainingBytesSpan());
request.Data.SetPosition(position);
if (!isRendezvous)
{
((INetEventListener)GameplayEvents).OnConnectionRequest(request);
return;
}
Action<ConnectionRequest>? handler = RendezvousConnectionRequest;
if (handler is null)
{
request.RejectForce([]);
return;
}
handler(request);
}
public void OnMessageDelivered(NetPeer peer, object userData) =>
((INetEventListener)GameplayEvents).OnMessageDelivered(peer, userData);
public void OnNtpResponse(NtpPacket packet) =>
((INetEventListener)GameplayEvents).OnNtpResponse(packet);
public void OnPeerAddressChanged(NetPeer peer, IPEndPoint previousAddress) =>
((INetEventListener)GameplayEvents).OnPeerAddressChanged(peer, previousAddress);
}
@@ -37,6 +37,9 @@ public sealed class CreateJoinAttemptResponse
[JsonRequired]
public string ClientPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public string ConnectionTicketDigest { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
public NetworkEndpoint? DedicatedFallback { get; set; }
@@ -53,6 +56,12 @@ public sealed class HostJoinAttempt
[JsonRequired]
public string HostPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public string ConnectionTicketDigest { get; set; } = string.Empty;
[JsonRequired]
public bool IsCancelled { get; set; }
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
@@ -0,0 +1,149 @@
using System.Security.Cryptography;
using System.Text;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class NatIntroductionToken
{
public JoinAttemptId AttemptId { get; set; }
public string ConnectionTicket { get; set; } = string.Empty;
public override string ToString() =>
$"[NatIntroductionToken {AttemptId}; ticket redacted]";
}
public static class NatIntroductionTokenCodec
{
public const int EncodedLength = ContractLimits.DerivedCredentialCharacters;
private const int DecodedLength = 32;
private const int AttemptIdLength = 16;
private const int AuthenticatorLength = DecodedLength - AttemptIdLength;
public static string Encode(JoinAttemptId attemptId, string derivedAuthenticator)
{
if (attemptId.Value == Guid.Empty
|| !ContractValidation.IsConnectionTicketValid(derivedAuthenticator)
|| !TryDecodeBase64Url(derivedAuthenticator, out byte[]? authenticator)
|| authenticator.Length != DecodedLength)
{
throw new ArgumentException("The NAT introduction token fields are invalid.");
}
byte[] payload = new byte[DecodedLength];
try
{
if (!attemptId.Value.TryWriteBytes(payload.AsSpan(0, AttemptIdLength)))
{
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
}
authenticator.AsSpan(0, AuthenticatorLength).CopyTo(payload.AsSpan(AttemptIdLength));
return EncodeBase64Url(payload);
}
finally
{
CryptographicOperations.ZeroMemory(authenticator);
CryptographicOperations.ZeroMemory(payload);
}
}
public static bool TryDecode(string? encoded, out NatIntroductionToken? token)
{
token = null;
if (!ContractValidation.IsConnectionTicketValid(encoded)
|| !TryDecodeBase64Url(encoded!, out byte[]? payload)
|| payload.Length != DecodedLength)
{
return false;
}
try
{
Guid attemptId = new(payload.AsSpan(0, AttemptIdLength));
if (attemptId == Guid.Empty)
{
return false;
}
token = new NatIntroductionToken
{
AttemptId = new JoinAttemptId(attemptId),
ConnectionTicket = encoded!,
};
return true;
}
finally
{
CryptographicOperations.ZeroMemory(payload);
}
}
public static string ComputeDigest(string connectionTicket)
{
if (!ContractValidation.IsConnectionTicketValid(connectionTicket))
{
throw new ArgumentException("The connection ticket is invalid.", nameof(connectionTicket));
}
byte[] encoded = Encoding.ASCII.GetBytes(connectionTicket);
byte[] digest;
using (SHA256 sha256 = SHA256.Create())
{
digest = sha256.ComputeHash(encoded);
}
CryptographicOperations.ZeroMemory(encoded);
try
{
return EncodeBase64Url(digest);
}
finally
{
CryptographicOperations.ZeroMemory(digest);
}
}
public static bool MatchesDigest(string? connectionTicket, string? expectedDigest)
{
if (!ContractValidation.IsConnectionTicketValid(connectionTicket)
|| !ContractValidation.IsConnectionTicketValid(expectedDigest))
{
return false;
}
byte[] actual = Encoding.ASCII.GetBytes(ComputeDigest(connectionTicket!));
byte[] expected = Encoding.ASCII.GetBytes(expectedDigest!);
try
{
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
finally
{
CryptographicOperations.ZeroMemory(actual);
CryptographicOperations.ZeroMemory(expected);
}
}
private static bool TryDecodeBase64Url(string? encoded, out byte[] bytes)
{
bytes = [];
if (encoded is null || encoded.Length != EncodedLength)
{
return false;
}
try
{
bytes = Convert.FromBase64String(
encoded.Replace('-', '+').Replace('_', '/') + "=");
return true;
}
catch (FormatException)
{
return false;
}
}
private static string EncodeBase64Url(byte[] value) =>
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -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);
@@ -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]";
}
@@ -420,7 +420,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
IReadOnlyList<StoredJoinAttempt> 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<string, TimeSpan> 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(
@@ -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(
@@ -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)