Files
Rendezvous/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs
T
KyuubiYoru 1baa1055dc
quality-gate / quality (push) Successful in 1m1s
feat: implement scoped join attempts and tickets (#10)
Closes #10
2026-07-16 06:56:30 +02:00

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);
}