369 lines
15 KiB
C#
369 lines
15 KiB
C#
using System.Collections.Frozen;
|
|
using System.Diagnostics;
|
|
using System.Net;
|
|
using FinalFactory.Rendezvous.Contracts;
|
|
|
|
namespace FinalFactory.Rendezvous.Server.State;
|
|
|
|
internal interface IWallClock
|
|
{
|
|
DateTimeOffset UtcNow { get; }
|
|
}
|
|
|
|
internal interface IMonotonicClock
|
|
{
|
|
TimeSpan Elapsed { get; }
|
|
}
|
|
|
|
internal sealed class SystemRendezvousClock : IWallClock, IMonotonicClock
|
|
{
|
|
private readonly long _origin = Stopwatch.GetTimestamp();
|
|
|
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
|
|
|
public TimeSpan Elapsed => Stopwatch.GetElapsedTime(_origin);
|
|
}
|
|
|
|
internal sealed record EphemeralStoreOptions
|
|
{
|
|
public int MaxListings { get; init; } = 25_000;
|
|
public int MaxPresenceBindings { get; init; } = 25_000;
|
|
public int MaxJoinAttempts { get; init; } = 10_000;
|
|
public int MaxReplayEntries { get; init; } = 30_000;
|
|
public int MaxRevocations { get; init; } = 10_000;
|
|
public int MaxIdempotencyEntries { get; init; } = 35_000;
|
|
public TimeSpan LeaseLifetime { get; init; } = TimeSpan.FromSeconds(60);
|
|
public TimeSpan PresenceLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
|
public TimeSpan JoinAttemptLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
|
public TimeSpan ConnectionTicketLifetime { get; init; } = TimeSpan.FromSeconds(20);
|
|
public TimeSpan ReplayLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
|
public TimeSpan IdempotencyLifetime { get; init; } = TimeSpan.FromMinutes(2);
|
|
public TimeSpan GracefulDrainLifetime { get; init; } = TimeSpan.FromSeconds(30);
|
|
|
|
public void Validate()
|
|
{
|
|
RequirePositive(MaxListings, nameof(MaxListings));
|
|
RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings));
|
|
RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts));
|
|
RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries));
|
|
RequirePositive(MaxRevocations, nameof(MaxRevocations));
|
|
RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries));
|
|
RequireDuration(LeaseLifetime, TimeSpan.FromSeconds(60), nameof(LeaseLifetime));
|
|
RequireDuration(PresenceLifetime, TimeSpan.FromSeconds(20), nameof(PresenceLifetime));
|
|
RequireDuration(JoinAttemptLifetime, TimeSpan.FromSeconds(30), nameof(JoinAttemptLifetime));
|
|
RequireDuration(ConnectionTicketLifetime, TimeSpan.FromSeconds(20), nameof(ConnectionTicketLifetime));
|
|
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
|
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
|
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
|
|
if (ConnectionTicketLifetime > JoinAttemptLifetime)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(ConnectionTicketLifetime),
|
|
"Connection tickets cannot outlive their join attempt.");
|
|
}
|
|
|
|
if (IdempotencyLifetime < LeaseLifetime || IdempotencyLifetime < JoinAttemptLifetime)
|
|
{
|
|
throw new ArgumentOutOfRangeException(
|
|
nameof(IdempotencyLifetime),
|
|
"Idempotency retention must cover every idempotent resource lifetime.");
|
|
}
|
|
}
|
|
|
|
private static void RequirePositive(int value, string name)
|
|
{
|
|
if (value <= 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(name, "Store capacity must be positive.");
|
|
}
|
|
}
|
|
|
|
private static void RequireDuration(TimeSpan value, TimeSpan maximum, string name)
|
|
{
|
|
if (value <= TimeSpan.Zero || value > maximum)
|
|
{
|
|
throw new ArgumentOutOfRangeException(name, $"Duration must be positive and no greater than {maximum}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
internal readonly record struct TenantScope(GameId GameId, EnvironmentId EnvironmentId);
|
|
|
|
internal readonly struct SecretFingerprint : IEquatable<SecretFingerprint>
|
|
{
|
|
private readonly string? _value;
|
|
|
|
public SecretFingerprint(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
|
|
{
|
|
throw new ArgumentException("Secret fingerprints must contain 1-128 characters.", nameof(value));
|
|
}
|
|
|
|
_value = value;
|
|
}
|
|
|
|
public bool IsValid => !string.IsNullOrWhiteSpace(_value) && _value.Length <= 128;
|
|
public bool Equals(SecretFingerprint other)
|
|
{
|
|
ReadOnlySpan<char> left = _value.AsSpan();
|
|
ReadOnlySpan<char> right = other._value.AsSpan();
|
|
if (left.Length != right.Length)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int difference = 0;
|
|
for (int index = 0; index < left.Length; index++)
|
|
{
|
|
difference |= left[index] ^ right[index];
|
|
}
|
|
|
|
return difference == 0;
|
|
}
|
|
|
|
public override bool Equals(object? obj) => obj is SecretFingerprint other && Equals(other);
|
|
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(_value ?? string.Empty);
|
|
public override string ToString() => "[REDACTED]";
|
|
public static bool operator ==(SecretFingerprint left, SecretFingerprint right) => left.Equals(right);
|
|
public static bool operator !=(SecretFingerprint left, SecretFingerprint right) => !left.Equals(right);
|
|
}
|
|
|
|
internal readonly record struct ObservedEndpoint
|
|
{
|
|
public ObservedEndpoint(AddressFamilyKind addressFamily, string address, int port)
|
|
{
|
|
if (!IPAddress.TryParse(address, out IPAddress? parsed)
|
|
|| (addressFamily == AddressFamilyKind.Ipv4 && parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
|
|| (addressFamily == AddressFamilyKind.Ipv6 && parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6))
|
|
{
|
|
throw new ArgumentException("The address must match the declared address family.", nameof(address));
|
|
}
|
|
|
|
if (port is < 1 or > 65_535)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(port));
|
|
}
|
|
|
|
AddressFamily = addressFamily;
|
|
Address = parsed.ToString();
|
|
Port = port;
|
|
}
|
|
|
|
public AddressFamilyKind AddressFamily { get; }
|
|
public string Address { get; }
|
|
public int Port { get; }
|
|
public bool IsValid => !string.IsNullOrEmpty(Address)
|
|
&& Port is >= 1 and <= 65_535
|
|
&& AddressFamily is AddressFamilyKind.Ipv4 or AddressFamilyKind.Ipv6;
|
|
}
|
|
|
|
internal sealed record ListingDefinition
|
|
{
|
|
public required SessionListingId ListingId { get; init; }
|
|
public required LeaseId LeaseId { get; init; }
|
|
public required TenantScope Scope { get; init; }
|
|
public required string OwnerSubject { get; init; }
|
|
public required RegionId RegionId { get; init; }
|
|
public required uint ProtocolVersion { get; init; }
|
|
public required string BuildVersion { get; init; }
|
|
public required string DisplayName { get; init; }
|
|
public required ListingVisibility Visibility { get; init; }
|
|
public required PublisherTrustMode TrustMode { get; init; }
|
|
public required int CurrentPlayers { get; init; }
|
|
public required int MaximumPlayers { get; init; }
|
|
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
|
|
public required SecretFingerprint LeaseFingerprint { get; init; }
|
|
public required MediationHandle HostPresenceHandle { get; init; }
|
|
public required SecretFingerprint HostPresenceFingerprint { get; init; }
|
|
public required string CapabilityDerivationSalt { get; init; }
|
|
}
|
|
|
|
internal sealed record StoredListing
|
|
{
|
|
public required ListingDefinition Definition { get; init; }
|
|
public required DateTimeOffset LeaseExpiresAt { get; init; }
|
|
public required long Version { get; init; }
|
|
public required bool HasFreshPresence { get; init; }
|
|
|
|
public static ListingDefinition Freeze(ListingDefinition source) => source with
|
|
{
|
|
Metadata = source.Metadata.ToFrozenDictionary(StringComparer.Ordinal),
|
|
};
|
|
}
|
|
|
|
internal sealed record CreateListingCommand(
|
|
string IdempotencyKey,
|
|
string RequestFingerprint,
|
|
ListingDefinition Listing,
|
|
int OwnerListingLimit = int.MaxValue);
|
|
|
|
internal sealed record RenewLeaseCommand(
|
|
SessionListingId ListingId,
|
|
LeaseId LeaseId,
|
|
SecretFingerprint LeaseFingerprint,
|
|
string OwnerSubject,
|
|
long ExpectedVersion);
|
|
|
|
internal sealed record UpdateListingCommand(
|
|
SessionListingId ListingId,
|
|
LeaseId LeaseId,
|
|
SecretFingerprint LeaseFingerprint,
|
|
string OwnerSubject,
|
|
string BuildVersion,
|
|
string DisplayName,
|
|
int CurrentPlayers,
|
|
int MaximumPlayers,
|
|
IReadOnlyDictionary<string, string> Metadata);
|
|
|
|
internal sealed record DeleteListingCommand(
|
|
SessionListingId ListingId,
|
|
LeaseId LeaseId,
|
|
SecretFingerprint LeaseFingerprint,
|
|
string OwnerSubject);
|
|
|
|
internal sealed record BindHostPresenceCommand(
|
|
MediationHandle Handle,
|
|
SecretFingerprint CapabilityFingerprint,
|
|
ObservedEndpoint PublicEndpoint,
|
|
ObservedEndpoint? LocalEndpoint);
|
|
|
|
internal sealed record VisibleListingQuery(
|
|
TenantScope Scope,
|
|
uint ProtocolVersion,
|
|
RegionId? RegionId,
|
|
int MaximumResults = ContractLimits.BrowserPageMaxItems,
|
|
SessionListingId? AfterListingId = null,
|
|
bool ExcludeFull = false);
|
|
|
|
internal enum AttemptPeerRole
|
|
{
|
|
Host = 1,
|
|
Client = 2,
|
|
}
|
|
|
|
internal sealed record CreateJoinAttemptCommand
|
|
{
|
|
public required string IdempotencyOwner { get; init; }
|
|
public required string IdempotencyKey { get; init; }
|
|
public required string RequestFingerprint { get; init; }
|
|
public required string ClientSubject { get; init; }
|
|
public required JoinAttemptId AttemptId { get; init; }
|
|
public required MediationHandle MediationHandle { get; init; }
|
|
public required TenantScope Scope { get; init; }
|
|
public required SessionListingId ListingId { get; init; }
|
|
public required uint ProtocolVersion { get; init; }
|
|
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
|
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
|
public required string CapabilityDerivationSalt { get; init; }
|
|
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
|
|
|
|
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
|
|
}
|
|
|
|
internal sealed record AttemptEndpointBinding(
|
|
ObservedEndpoint PublicEndpoint,
|
|
ObservedEndpoint? LocalEndpoint);
|
|
|
|
internal sealed record StoredJoinAttempt
|
|
{
|
|
public required JoinAttemptId AttemptId { get; init; }
|
|
public required MediationHandle MediationHandle { get; init; }
|
|
public required TenantScope Scope { get; init; }
|
|
public required SessionListingId ListingId { get; init; }
|
|
public required string ClientSubject { get; init; }
|
|
public required uint ProtocolVersion { get; init; }
|
|
public required string IdempotencyKey { get; init; }
|
|
public required string RequestFingerprint { get; init; }
|
|
public required string CapabilityDerivationSalt { get; init; }
|
|
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
|
|
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
|
|
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
|
|
public required DateTimeOffset ExpiresAt { get; init; }
|
|
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
|
|
public AttemptEndpointBinding? HostEndpoint { get; init; }
|
|
public AttemptEndpointBinding? ClientEndpoint { get; init; }
|
|
public required bool IntroductionConsumed { get; init; }
|
|
public required bool ConnectionTicketConsumed { get; init; }
|
|
|
|
public override string ToString() => $"[StoredJoinAttempt {AttemptId}; credentials redacted]";
|
|
}
|
|
|
|
internal sealed record HostJoinAttemptQuery(
|
|
SessionListingId ListingId,
|
|
SecretFingerprint LeaseFingerprint,
|
|
int MaximumResults,
|
|
JoinAttemptId? AfterAttemptId = null);
|
|
|
|
internal sealed record BindAttemptEndpointCommand(
|
|
MediationHandle Handle,
|
|
AttemptPeerRole Role,
|
|
SecretFingerprint CapabilityFingerprint,
|
|
ObservedEndpoint PublicEndpoint,
|
|
ObservedEndpoint? LocalEndpoint);
|
|
|
|
internal sealed record IntroductionEndpoints(
|
|
StoredJoinAttempt Attempt,
|
|
AttemptEndpointBinding Host,
|
|
AttemptEndpointBinding Client)
|
|
{
|
|
public JoinAttemptId AttemptId => Attempt.AttemptId;
|
|
}
|
|
|
|
internal sealed record CancelJoinAttemptCommand(
|
|
JoinAttemptId AttemptId,
|
|
SecretFingerprint ClientCapabilityFingerprint);
|
|
|
|
internal sealed record ConsumeConnectionTicketCommand(
|
|
JoinAttemptId AttemptId,
|
|
SecretFingerprint ConnectionTicketFingerprint);
|
|
|
|
internal sealed record ReplayConsumption(
|
|
string Namespace,
|
|
string Key,
|
|
TimeSpan? Lifetime = null);
|
|
|
|
internal enum StoreResultCode
|
|
{
|
|
Success = 0,
|
|
NotFound = 1,
|
|
Expired = 2,
|
|
Revoked = 3,
|
|
Conflict = 4,
|
|
CapacityExceeded = 5,
|
|
Draining = 6,
|
|
ReplayRejected = 7,
|
|
ServiceUnavailable = 8,
|
|
}
|
|
|
|
internal sealed record StoreResult<T>(StoreResultCode Code, T? Value = default, bool IsIdempotentReplay = false)
|
|
{
|
|
public bool Succeeded => Code == StoreResultCode.Success;
|
|
}
|
|
|
|
internal interface IEphemeralRendezvousStore
|
|
{
|
|
Guid InstanceId { get; }
|
|
bool IsAvailable { get; }
|
|
bool IsDraining { get; }
|
|
|
|
StoreResult<StoredListing> CreateListing(CreateListingCommand command, CancellationToken cancellationToken = default);
|
|
StoreResult<StoredListing> RenewLease(RenewLeaseCommand command, CancellationToken cancellationToken = default);
|
|
StoreResult<StoredListing> UpdateListing(UpdateListingCommand command, CancellationToken cancellationToken = default);
|
|
StoreResult<bool> DeleteListing(DeleteListingCommand command, CancellationToken cancellationToken = default);
|
|
StoreResult<StoredListing> GetListing(SessionListingId listingId, bool requireFreshPresence, CancellationToken cancellationToken = default);
|
|
StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(VisibleListingQuery query, CancellationToken cancellationToken = default);
|
|
StoreResult<StoredListing> BindHostPresence(BindHostPresenceCommand command, CancellationToken cancellationToken = default);
|
|
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
|
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
|
|
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
|
|
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
|
|
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
|
|
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default);
|
|
StoreResult<bool> ConsumeReplay(ReplayConsumption consumption, CancellationToken cancellationToken = default);
|
|
StoreResult<bool> RevokeListing(SessionListingId listingId, CancellationToken cancellationToken = default);
|
|
StoreResult<int> RevokePrincipal(string subject, TimeSpan lifetime, CancellationToken cancellationToken = default);
|
|
void BeginDrain(CancellationToken cancellationToken = default);
|
|
}
|