feat: add bounded compatible session browser (#8)
quality-gate / quality (push) Successful in 57s

Closes #8
This commit is contained in:
KyuubiYoru
2026-07-16 06:06:29 +02:00
parent 49564c7e7e
commit a9a2b3db35
14 changed files with 786 additions and 10 deletions
@@ -0,0 +1,181 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed class SessionBrowserCursorCodec : IDisposable
{
private const string Prefix = "rvc1";
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
private bool _disposed;
public string Encode(VisibleListingQuery query, SessionListingId after, DateTimeOffset now)
{
ObjectDisposedException.ThrowIf(_disposed, this);
BrowserCursorPayload payload = new()
{
GameId = query.Scope.GameId.Value,
EnvironmentId = query.Scope.EnvironmentId.Value,
ProtocolVersion = query.ProtocolVersion,
RegionId = query.RegionId?.Value,
ExcludeFull = query.ExcludeFull,
AfterListingId = after.ToString(),
ExpiresAtUnixSeconds = now.AddMinutes(5).ToUnixTimeSeconds(),
};
string encoded = EncodeBytes(JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string content = $"{Prefix}.{encoded}";
byte[] signature = HMACSHA256.HashData(_key, Encoding.ASCII.GetBytes(content));
try
{
string cursor = $"{content}.{EncodeBytes(signature)}";
return ContractValidation.IsCursorValid(cursor)
? cursor
: throw new InvalidOperationException("The browser cursor exceeds its contract limit.");
}
finally
{
CryptographicOperations.ZeroMemory(signature);
}
}
public bool TryDecode(
string? cursor,
TenantScope scope,
uint protocolVersion,
RegionId? regionId,
bool excludeFull,
DateTimeOffset now,
out SessionListingId? after)
{
after = null;
if (cursor is null)
{
return true;
}
if (_disposed || !ContractValidation.IsCursorValid(cursor))
{
return false;
}
string[] segments = cursor.Split('.');
if (segments.Length != 3 || !string.Equals(segments[0], Prefix, StringComparison.Ordinal))
{
return false;
}
byte[] expected = HMACSHA256.HashData(
_key,
Encoding.ASCII.GetBytes($"{segments[0]}.{segments[1]}"));
if (!TryDecodeBytes(segments[2], out byte[] supplied))
{
CryptographicOperations.ZeroMemory(expected);
return false;
}
bool validSignature = supplied.Length == expected.Length
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
CryptographicOperations.ZeroMemory(supplied);
CryptographicOperations.ZeroMemory(expected);
if (!validSignature || !TryDecodeBytes(segments[1], out byte[] encodedPayload))
{
return false;
}
BrowserCursorPayload? payload;
try
{
payload = JsonSerializer.Deserialize<BrowserCursorPayload>(
encodedPayload,
ContractJson.Options);
}
catch (JsonException)
{
payload = null;
}
finally
{
CryptographicOperations.ZeroMemory(encodedPayload);
}
if (payload is null
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|| !string.Equals(payload.GameId, scope.GameId.Value, StringComparison.Ordinal)
|| !string.Equals(payload.EnvironmentId, scope.EnvironmentId.Value, StringComparison.Ordinal)
|| payload.ProtocolVersion != protocolVersion
|| !string.Equals(payload.RegionId, regionId?.Value, StringComparison.Ordinal)
|| payload.ExcludeFull != excludeFull
|| !SessionListingId.TryParse(payload.AfterListingId, out SessionListingId listingId))
{
return false;
}
after = listingId;
return true;
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
CryptographicOperations.ZeroMemory(_key);
}
}
public override string ToString() => "[SessionBrowserCursorCodec: key and cursors redacted]";
private static string EncodeBytes(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
private static bool TryDecodeBytes(string value, out byte[] bytes)
{
bytes = [];
if (string.IsNullOrEmpty(value)
|| value.Any(static character =>
character is not (>= 'A' and <= 'Z')
and not (>= 'a' and <= 'z')
and not (>= '0' and <= '9')
and not '-'
and not '_'))
{
return false;
}
string padded = value.Replace('-', '+').Replace('_', '/');
padded += (padded.Length % 4) switch { 0 => "", 2 => "==", 3 => "=", _ => "!" };
try
{
bytes = Convert.FromBase64String(padded);
return true;
}
catch (FormatException)
{
return false;
}
}
}
internal sealed class BrowserCursorPayload
{
[JsonRequired]
public string GameId { get; set; } = string.Empty;
[JsonRequired]
public string EnvironmentId { get; set; } = string.Empty;
[JsonRequired]
public uint ProtocolVersion { get; set; }
public string? RegionId { get; set; }
[JsonRequired]
public bool ExcludeFull { get; set; }
[JsonRequired]
public string AfterListingId { get; set; } = string.Empty;
[JsonRequired]
public long ExpiresAtUnixSeconds { get; set; }
}
@@ -0,0 +1,157 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Browser;
internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
{
public bool Succeeded => Error == RendezvousErrorCode.None;
}
internal sealed class SessionBrowserService(
IEphemeralRendezvousStore store,
SessionBrowserCursorCodec cursors,
IWallClock clock)
{
public BrowserServiceResult<BrowseSessionsResponse> Browse(
BrowseSessionsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode validation = Validate(request);
if (validation != RendezvousErrorCode.None)
{
return new(validation);
}
TenantScope scope = new(request.GameId, request.EnvironmentId);
if (!cursors.TryDecode(
request.Cursor,
scope,
request.ProtocolVersion,
request.RegionId,
request.ExcludeFull,
clock.UtcNow,
out SessionListingId? after))
{
return new(RendezvousErrorCode.InvalidRequest);
}
VisibleListingQuery query = new(
scope,
request.ProtocolVersion,
request.RegionId,
request.PageSize + 1,
after,
request.ExcludeFull);
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
query,
cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return new(found.Code == StoreResultCode.ServiceUnavailable
? RendezvousErrorCode.ServiceUnavailable
: RendezvousErrorCode.InternalError);
}
List<SessionListing> items = found.Value
.Take(request.PageSize)
.Select(ToContract)
.ToList();
bool hasMore = found.Value.Count > request.PageSize;
while (items.Count > 0)
{
string? nextCursor = hasMore
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
: null;
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
<= ContractLimits.BrowserResponseMaxBytes)
{
return new(RendezvousErrorCode.None, response);
}
items.RemoveAt(items.Count - 1);
hasMore = true;
}
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
}
public BrowserServiceResult<GetSessionResponse> Get(
SessionListingId listingId,
GameId gameId,
EnvironmentId environmentId,
uint protocolVersion,
CancellationToken cancellationToken = default)
{
if (listingId.Value == Guid.Empty
|| string.IsNullOrEmpty(gameId.Value)
|| string.IsNullOrEmpty(environmentId.Value)
|| protocolVersion == 0)
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<StoredListing> found = store.GetListing(listingId, true, cancellationToken);
if (!found.Succeeded || found.Value is null)
{
return new(found.Code == StoreResultCode.ServiceUnavailable
? RendezvousErrorCode.ServiceUnavailable
: RendezvousErrorCode.NotFound);
}
StoredListing listing = found.Value;
if (listing.Definition.Scope != new TenantScope(gameId, environmentId)
|| listing.Definition.ProtocolVersion != protocolVersion)
{
return new(RendezvousErrorCode.NotFound);
}
return new(RendezvousErrorCode.None, new GetSessionResponse
{
Session = ToContract(listing),
});
}
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return version;
}
return string.IsNullOrEmpty(request.GameId.Value)
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|| request.ProtocolVersion == 0
|| (request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value))
|| !ContractValidation.IsPageSizeValid(request.PageSize)
|| !ContractValidation.IsCursorValid(request.Cursor)
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
private static SessionListing ToContract(StoredListing stored) => new()
{
ListingId = stored.Definition.ListingId,
GameId = stored.Definition.Scope.GameId,
EnvironmentId = stored.Definition.Scope.EnvironmentId,
RegionId = stored.Definition.RegionId,
ProtocolVersion = stored.Definition.ProtocolVersion,
BuildVersion = stored.Definition.BuildVersion,
DisplayName = stored.Definition.DisplayName,
Visibility = stored.Definition.Visibility,
PublisherTrustMode = stored.Definition.TrustMode,
Capacity = new()
{
CurrentPlayers = stored.Definition.CurrentPlayers,
MaximumPlayers = stored.Definition.MaximumPlayers,
},
Metadata = stored.Definition.Metadata.ToDictionary(
static item => item.Key,
static item => item.Value,
StringComparer.Ordinal),
};
}
@@ -1,4 +1,5 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
@@ -55,11 +56,14 @@ internal static class ContractEndpoints
.WithName("DeleteSession");
sessions.MapGet("/", BrowseSessions)
.Produces<BrowseSessionsResponse>()
.Produces<ApiError>(NotImplementedStatus)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseSessions");
sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>()
.Produces<ApiError>(NotImplementedStatus)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>()
@@ -200,9 +204,64 @@ internal static class ContractEndpoints
[FromQuery] uint protocolVersion,
[FromQuery] string? regionId,
[FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented();
[FromQuery] bool? excludeFull,
[FromQuery] string? cursor,
[FromServices] SessionBrowserService browser,
CancellationToken cancellationToken)
{
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|| (regionId is not null && !RegionId.TryParse(regionId, out _)))
{
return Error(RendezvousErrorCode.InvalidRequest);
}
private static IResult GetSession(SessionListingId listingId) => NotImplemented();
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
{
ContractVersion = contractVersion,
GameId = parsedGameId,
EnvironmentId = parsedEnvironmentId,
ProtocolVersion = protocolVersion,
RegionId = regionId is null ? null : new RegionId(regionId),
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
ExcludeFull = excludeFull ?? false,
Cursor = cursor,
}, cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult GetSession(
SessionListingId listingId,
[FromQuery] int contractVersion,
[FromQuery] string gameId,
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromServices] SessionBrowserService browser,
CancellationToken cancellationToken)
{
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
{
return Error(RendezvousErrorCode.UnsupportedContractVersion);
}
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId))
{
return Error(RendezvousErrorCode.InvalidRequest);
}
BrowserServiceResult<GetSessionResponse> result = browser.Get(
listingId,
parsedGameId,
parsedEnvironmentId,
protocolVersion,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult BrowseHostJoinAttempts(
SessionListingId listingId,
@@ -1,5 +1,6 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
@@ -119,6 +120,8 @@ else
builder.Services.AddSingleton<ISessionCapabilityService>(sessionCapabilities);
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton(new ProvisioningReadiness(true));
}
@@ -223,7 +223,9 @@ internal sealed record VisibleListingQuery(
TenantScope Scope,
uint ProtocolVersion,
RegionId? RegionId,
int MaximumResults = ContractLimits.BrowserPageMaxItems);
int MaximumResults = ContractLimits.BrowserPageMaxItems,
SessionListingId? AfterListingId = null,
bool ExcludeFull = false);
internal enum AttemptPeerRole
{
@@ -301,7 +301,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| query.ProtocolVersion == 0
|| (query.RegionId.HasValue && string.IsNullOrEmpty(query.RegionId.Value.Value))
|| query.MaximumResults <= 0
|| query.MaximumResults > ContractLimits.BrowserPageMaxItems)
|| query.MaximumResults > ContractLimits.BrowserPageMaxItems + 1)
{
throw new ArgumentOutOfRangeException(nameof(query));
}
@@ -311,6 +311,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
&& entry.Definition.ProtocolVersion == query.ProtocolVersion
&& entry.Definition.Visibility == ListingVisibility.Public
&& (!query.RegionId.HasValue || entry.Definition.RegionId == query.RegionId.Value)
&& (!query.AfterListingId.HasValue
|| entry.Definition.ListingId.Value.CompareTo(query.AfterListingId.Value.Value) > 0)
&& (!query.ExcludeFull
|| entry.Definition.CurrentPlayers < entry.Definition.MaximumPlayers)
&& _presence.ContainsKey(entry.Definition.HostPresenceHandle))
.OrderBy(static entry => entry.Definition.ListingId.Value)
.Take(query.MaximumResults)