feat: add presence-gated session leases (#7)
quality-gate / quality (push) Successful in 55s
quality-gate / quality (push) Successful in 55s
Closes #7
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Http;
|
||||
@@ -14,22 +17,41 @@ internal static class ContractEndpoints
|
||||
sessions.MapPost("/", RegisterSession)
|
||||
.Accepts<RegisterSessionRequest>("application/json")
|
||||
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ApiError>(NotImplementedStatus)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("RegisterSession");
|
||||
sessions.MapPost("/{listingId}/renew", RenewLease)
|
||||
.Accepts<RenewLeaseRequest>("application/json")
|
||||
.Produces<RenewLeaseResponse>()
|
||||
.Produces<ApiError>(NotImplementedStatus)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
||||
.Produces<ApiError>(StatusCodes.Status410Gone)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("RenewSessionLease");
|
||||
sessions.MapPut("/{listingId}", UpdateSession)
|
||||
.Accepts<UpdateSessionRequest>("application/json")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(NotImplementedStatus)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("UpdateSession");
|
||||
sessions.MapDelete("/{listingId}", DeleteSession)
|
||||
.Accepts<DeleteSessionRequest>("application/json")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces<ApiError>(NotImplementedStatus)
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
||||
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("DeleteSession");
|
||||
sessions.MapGet("/", BrowseSessions)
|
||||
.Produces<BrowseSessionsResponse>()
|
||||
@@ -61,20 +83,115 @@ internal static class ContractEndpoints
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static IResult RegisterSession([FromBody] RegisterSessionRequest request) =>
|
||||
NotImplemented();
|
||||
private static IResult RegisterSession(
|
||||
[FromBody] RegisterSessionRequest request,
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryAuthenticatePublisher(
|
||||
authorizationHeader,
|
||||
credentials,
|
||||
clock,
|
||||
out AuthenticatedPrincipal? principal))
|
||||
{
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
|
||||
principal!,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult RenewLease(
|
||||
SessionListingId listingId,
|
||||
[FromBody] RenewLeaseRequest request) => NotImplemented();
|
||||
[FromBody] RenewLeaseRequest request,
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryAuthenticatePublisher(
|
||||
authorizationHeader,
|
||||
credentials,
|
||||
clock,
|
||||
out AuthenticatedPrincipal? principal))
|
||||
{
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded && result.Value is not null
|
||||
? Results.Ok(result.Value)
|
||||
: Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult UpdateSession(
|
||||
SessionListingId listingId,
|
||||
[FromBody] UpdateSessionRequest request) => NotImplemented();
|
||||
[FromBody] UpdateSessionRequest request,
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryAuthenticatePublisher(
|
||||
authorizationHeader,
|
||||
credentials,
|
||||
clock,
|
||||
out AuthenticatedPrincipal? principal))
|
||||
{
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<bool> result = sessions.Update(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult DeleteSession(
|
||||
SessionListingId listingId,
|
||||
[FromBody] DeleteSessionRequest request) => NotImplemented();
|
||||
[FromBody] DeleteSessionRequest request,
|
||||
[FromHeader(Name = "Authorization")] string? authorizationHeader,
|
||||
[FromServices] PrincipalCredentialService credentials,
|
||||
[FromServices] SessionLeaseService sessions,
|
||||
[FromServices] IWallClock clock,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryAuthenticatePublisher(
|
||||
authorizationHeader,
|
||||
credentials,
|
||||
clock,
|
||||
out AuthenticatedPrincipal? principal))
|
||||
{
|
||||
return AuthenticationRequired(httpContext);
|
||||
}
|
||||
|
||||
SessionServiceResult<bool> result = sessions.Delete(
|
||||
principal!,
|
||||
listingId,
|
||||
request,
|
||||
cancellationToken);
|
||||
return result.Succeeded ? Results.NoContent() : Error(result.Error);
|
||||
}
|
||||
|
||||
private static IResult BrowseSessions(
|
||||
[FromQuery] int contractVersion,
|
||||
@@ -109,4 +226,72 @@ internal static class ContractEndpoints
|
||||
},
|
||||
ContractJson.Options,
|
||||
statusCode: NotImplementedStatus);
|
||||
|
||||
private static bool TryAuthenticatePublisher(
|
||||
string? authorizationHeader,
|
||||
PrincipalCredentialService credentials,
|
||||
IWallClock clock,
|
||||
out AuthenticatedPrincipal? principal)
|
||||
{
|
||||
principal = null;
|
||||
const string bearerPrefix = "Bearer ";
|
||||
if (authorizationHeader is null
|
||||
|| !authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string token = authorizationHeader[bearerPrefix.Length..];
|
||||
CredentialValidationResult validation = credentials.Validate(token, clock.UtcNow);
|
||||
if (!validation.IsValid || validation.Principal is not IPublisherPrincipal)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
principal = validation.Principal;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IResult Error(RendezvousErrorCode code) => Results.Json(
|
||||
new ApiError
|
||||
{
|
||||
Code = code,
|
||||
Message = ErrorMessage(code),
|
||||
},
|
||||
ContractJson.Options,
|
||||
statusCode: ErrorStatus(code));
|
||||
|
||||
private static IResult AuthenticationRequired(HttpContext context)
|
||||
{
|
||||
context.Response.Headers.WWWAuthenticate = "Bearer";
|
||||
return Error(RendezvousErrorCode.AuthenticationRequired);
|
||||
}
|
||||
|
||||
private static int ErrorStatus(RendezvousErrorCode code) => code switch
|
||||
{
|
||||
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
|
||||
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
|
||||
RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound,
|
||||
RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
|
||||
RendezvousErrorCode.Expired => StatusCodes.Status410Gone,
|
||||
RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded =>
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
|
||||
RendezvousErrorCode.InternalError => StatusCodes.Status500InternalServerError,
|
||||
_ => StatusCodes.Status400BadRequest,
|
||||
};
|
||||
|
||||
private static string ErrorMessage(RendezvousErrorCode code) => code switch
|
||||
{
|
||||
RendezvousErrorCode.AuthenticationRequired => "A valid publisher bearer credential is required.",
|
||||
RendezvousErrorCode.Forbidden => "The publisher is not authorized for this operation.",
|
||||
RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.",
|
||||
RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.",
|
||||
RendezvousErrorCode.Expired => "The session lease has expired.",
|
||||
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
|
||||
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
|
||||
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
|
||||
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
|
||||
_ => "The session request is invalid.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Http;
|
||||
|
||||
internal sealed class RendezvousExceptionHandler : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(
|
||||
HttpContext httpContext,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (httpContext.Response.HasStarted)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool invalidRequest = exception is BadHttpRequestException or JsonException;
|
||||
httpContext.Response.StatusCode = invalidRequest
|
||||
? StatusCodes.Status400BadRequest
|
||||
: StatusCodes.Status500InternalServerError;
|
||||
await httpContext.Response.WriteAsJsonAsync(
|
||||
new ApiError
|
||||
{
|
||||
Code = invalidRequest
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.InternalError,
|
||||
Message = invalidRequest
|
||||
? "The request body, route, or query value is invalid."
|
||||
: "The service could not complete the request.",
|
||||
},
|
||||
ContractJson.Options,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Http;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Server.Transport;
|
||||
using Microsoft.OpenApi;
|
||||
@@ -13,6 +14,7 @@ bool isOpenApiGeneration = string.Equals(
|
||||
StringComparison.Ordinal);
|
||||
|
||||
builder.Services.AddOpenApi("v1", static options =>
|
||||
{
|
||||
options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
|
||||
{
|
||||
Type type = context.JsonTypeInfo.Type;
|
||||
@@ -32,16 +34,65 @@ builder.Services.AddOpenApi("v1", static options =>
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}));
|
||||
});
|
||||
options.AddDocumentTransformer(static (document, context, cancellationToken) =>
|
||||
{
|
||||
const string schemeName = "PublisherBearer";
|
||||
document.Components ??= new OpenApiComponents();
|
||||
document.Components.SecuritySchemes ??=
|
||||
new Dictionary<string, IOpenApiSecurityScheme>(StringComparer.Ordinal);
|
||||
document.Components.SecuritySchemes[schemeName] = new OpenApiSecurityScheme
|
||||
{
|
||||
Type = SecuritySchemeType.Http,
|
||||
Scheme = "bearer",
|
||||
BearerFormat = "rv1 publisher credential",
|
||||
Description = "Tenant-scoped publisher credential issued during game provisioning.",
|
||||
};
|
||||
|
||||
HashSet<string> securedOperations = new(StringComparer.Ordinal)
|
||||
{
|
||||
"RegisterSession",
|
||||
"RenewSessionLease",
|
||||
"UpdateSession",
|
||||
"DeleteSession",
|
||||
};
|
||||
OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
|
||||
foreach (OpenApiPathItem path in document.Paths.Values)
|
||||
{
|
||||
if (path.Operations is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (OpenApiOperation operation in path.Operations.Values.Where(
|
||||
operation => securedOperations.Contains(operation.OperationId ?? string.Empty)))
|
||||
{
|
||||
operation.Security ??= [];
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
[reference] = [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
});
|
||||
builder.Services.ConfigureHttpJsonOptions(static options =>
|
||||
ContractJson.Configure(options.SerializerOptions));
|
||||
builder.Services.Configure<RouteHandlerOptions>(static options =>
|
||||
options.ThrowOnBadRequest = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<RendezvousExceptionHandler>();
|
||||
|
||||
SystemRendezvousClock rendezvousClock = new();
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore stateStore = new(
|
||||
new EphemeralStoreOptions(),
|
||||
stateOptions,
|
||||
rendezvousClock,
|
||||
rendezvousClock);
|
||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
||||
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
||||
|
||||
if (isOpenApiGeneration)
|
||||
{
|
||||
@@ -63,6 +114,11 @@ else
|
||||
builder.Services.AddSingleton(provisioning.Policies);
|
||||
builder.Services.AddSingleton(provisioning.Credentials);
|
||||
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
|
||||
EphemeralCapabilityIssuer sessionCapabilities = new();
|
||||
builder.Services.AddSingleton(sessionCapabilities);
|
||||
builder.Services.AddSingleton<ISessionCapabilityService>(sessionCapabilities);
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
||||
}
|
||||
|
||||
@@ -84,6 +140,7 @@ if (!isOpenApiGeneration)
|
||||
WebApplication app = builder.Build();
|
||||
app.Lifetime.ApplicationStopping.Register(() => stateStore.BeginDrain());
|
||||
|
||||
app.UseExceptionHandler();
|
||||
app.MapOpenApi();
|
||||
app.MapRendezvousContractEndpoints();
|
||||
app.MapGet(
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Sessions;
|
||||
|
||||
internal interface ISessionCapabilityService
|
||||
{
|
||||
string CreateDerivationSalt();
|
||||
string DeriveCapability(
|
||||
string purpose,
|
||||
string ownerSubject,
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt);
|
||||
Guid DeriveGuid(
|
||||
string purpose,
|
||||
string ownerSubject,
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt);
|
||||
bool TryFingerprint(string? capability, out SecretFingerprint fingerprint);
|
||||
}
|
||||
|
||||
internal sealed class EphemeralCapabilityIssuer : ISessionCapabilityService, IDisposable
|
||||
{
|
||||
private readonly byte[] _key = RandomNumberGenerator.GetBytes(32);
|
||||
private bool _disposed;
|
||||
|
||||
public string CreateDerivationSalt()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(32);
|
||||
try
|
||||
{
|
||||
return Encode(salt);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(salt);
|
||||
}
|
||||
}
|
||||
|
||||
public string DeriveCapability(
|
||||
string purpose,
|
||||
string ownerSubject,
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt)
|
||||
{
|
||||
byte[] digest = Derive(
|
||||
purpose,
|
||||
ownerSubject,
|
||||
idempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
try
|
||||
{
|
||||
return Encode(digest);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
|
||||
public Guid DeriveGuid(
|
||||
string purpose,
|
||||
string ownerSubject,
|
||||
string idempotencyKey,
|
||||
string requestFingerprint,
|
||||
string derivationSalt)
|
||||
{
|
||||
byte[] digest = Derive(
|
||||
purpose,
|
||||
ownerSubject,
|
||||
idempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
try
|
||||
{
|
||||
Span<byte> guidBytes = digest.AsSpan(0, 16);
|
||||
guidBytes[7] = (byte)((guidBytes[7] & 0x0f) | 0x80);
|
||||
guidBytes[8] = (byte)((guidBytes[8] & 0x3f) | 0x80);
|
||||
return new Guid(guidBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryFingerprint(string? capability, out SecretFingerprint fingerprint)
|
||||
{
|
||||
fingerprint = default;
|
||||
if (_disposed
|
||||
|| capability is null
|
||||
|| capability.Length != 43
|
||||
|| capability.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;
|
||||
}
|
||||
|
||||
byte[] digest = Derive("fingerprint", capability);
|
||||
try
|
||||
{
|
||||
fingerprint = new SecretFingerprint(Encode(digest));
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
CryptographicOperations.ZeroMemory(_key);
|
||||
}
|
||||
|
||||
public override string ToString() => "[EphemeralCapabilityIssuer: key and capabilities redacted]";
|
||||
|
||||
private byte[] Derive(params string[] segments)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
using IncrementalHash hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, _key);
|
||||
Span<byte> length = stackalloc byte[sizeof(int)];
|
||||
foreach (string segment in segments)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(segment);
|
||||
byte[] encoded = Encoding.UTF8.GetBytes(segment);
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, encoded.Length);
|
||||
hmac.AppendData(length);
|
||||
hmac.AppendData(encoded);
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
}
|
||||
|
||||
return hmac.GetHashAndReset();
|
||||
}
|
||||
|
||||
private static string Encode(ReadOnlySpan<byte> bytes) => Convert
|
||||
.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Sessions;
|
||||
|
||||
internal sealed record SessionLeaseTiming(
|
||||
int LeaseRenewAfterSeconds,
|
||||
int HostPresenceRefreshAfterSeconds)
|
||||
{
|
||||
public static SessionLeaseTiming From(EphemeralStoreOptions options) => new(
|
||||
Math.Max(1, (int)(options.LeaseLifetime.TotalSeconds / 2)),
|
||||
Math.Max(1, (int)(options.PresenceLifetime.TotalSeconds / 2)));
|
||||
}
|
||||
|
||||
internal sealed record SessionServiceResult<T>(RendezvousErrorCode Error, T? Value = default)
|
||||
{
|
||||
public bool Succeeded => Error == RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
internal sealed class SessionLeaseService(
|
||||
PublisherAuthorizationService authorization,
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities,
|
||||
SessionLeaseTiming timing,
|
||||
IWallClock clock)
|
||||
{
|
||||
public SessionServiceResult<RegisterSessionResponse> Register(
|
||||
AuthenticatedPrincipal principal,
|
||||
RegisterSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = ValidateRegistration(request);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
PublisherAuthorizationResult authorized = authorization.Authorize(
|
||||
principal,
|
||||
request.GameId,
|
||||
request.EnvironmentId,
|
||||
request.RegionId,
|
||||
request.ProtocolVersion,
|
||||
request.Visibility,
|
||||
request.Metadata,
|
||||
clock.UtcNow);
|
||||
if (!authorized.IsAllowed || authorized.Context is null)
|
||||
{
|
||||
return new(MapAuthorization(authorized.Error));
|
||||
}
|
||||
|
||||
AuthorizedPublisherContext context = authorized.Context;
|
||||
string requestFingerprint = ComputeRegistrationFingerprint(request);
|
||||
string derivationSalt = capabilities.CreateDerivationSalt();
|
||||
string leaseToken = capabilities.DeriveCapability(
|
||||
"lease-token",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
string presenceCapability = capabilities.DeriveCapability(
|
||||
"host-presence",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt);
|
||||
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint leaseFingerprint)
|
||||
|| !capabilities.TryFingerprint(presenceCapability, out SecretFingerprint presenceFingerprint))
|
||||
{
|
||||
throw new InvalidOperationException("Derived session capabilities could not be fingerprinted.");
|
||||
}
|
||||
|
||||
SessionListingId listingId = new(capabilities.DeriveGuid(
|
||||
"listing-id",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
LeaseId leaseId = new(capabilities.DeriveGuid(
|
||||
"lease-id",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
MediationHandle presenceHandle = new(capabilities.DeriveGuid(
|
||||
"presence-handle",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
derivationSalt));
|
||||
int ownerLimit = context.TrustMode == PublisherTrustMode.AnonymousUnlisted
|
||||
? context.Policy.MaxAnonymousListingsPerAddress
|
||||
: context.Policy.MaxListingsPerPrincipal;
|
||||
if (ownerLimit <= 0)
|
||||
{
|
||||
return new(RendezvousErrorCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
StoreResult<StoredListing> created = store.CreateListing(new(
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
new ListingDefinition
|
||||
{
|
||||
ListingId = listingId,
|
||||
LeaseId = leaseId,
|
||||
Scope = new(context.GameId, context.EnvironmentId),
|
||||
OwnerSubject = context.Subject,
|
||||
RegionId = context.RegionId,
|
||||
ProtocolVersion = context.ProtocolVersion,
|
||||
BuildVersion = request.BuildVersion,
|
||||
DisplayName = request.DisplayName,
|
||||
Visibility = context.Visibility,
|
||||
TrustMode = context.TrustMode,
|
||||
CurrentPlayers = request.Capacity.CurrentPlayers,
|
||||
MaximumPlayers = request.Capacity.MaximumPlayers,
|
||||
Metadata = request.Metadata,
|
||||
LeaseFingerprint = leaseFingerprint,
|
||||
HostPresenceHandle = presenceHandle,
|
||||
HostPresenceFingerprint = presenceFingerprint,
|
||||
CapabilityDerivationSalt = derivationSalt,
|
||||
},
|
||||
ownerLimit), cancellationToken);
|
||||
if (!created.Succeeded || created.Value is null)
|
||||
{
|
||||
return new(MapStore(created.Code));
|
||||
}
|
||||
|
||||
ListingDefinition persisted = created.Value.Definition;
|
||||
leaseToken = capabilities.DeriveCapability(
|
||||
"lease-token",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
persisted.CapabilityDerivationSalt);
|
||||
presenceCapability = capabilities.DeriveCapability(
|
||||
"host-presence",
|
||||
context.Subject,
|
||||
request.IdempotencyKey,
|
||||
requestFingerprint,
|
||||
persisted.CapabilityDerivationSalt);
|
||||
|
||||
return new(RendezvousErrorCode.None, new RegisterSessionResponse
|
||||
{
|
||||
ListingId = persisted.ListingId,
|
||||
LeaseId = persisted.LeaseId,
|
||||
LeaseToken = leaseToken,
|
||||
HostPresenceHandle = persisted.HostPresenceHandle,
|
||||
HostPresenceCapability = presenceCapability,
|
||||
ExpiresAt = created.Value.LeaseExpiresAt,
|
||||
LeaseRenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||
HostPresenceRefreshAfterSeconds = timing.HostPresenceRefreshAfterSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
public SessionServiceResult<RenewLeaseResponse> Renew(
|
||||
AuthenticatedPrincipal principal,
|
||||
SessionListingId listingId,
|
||||
RenewLeaseRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
RendezvousErrorCode lookup = GetAuthorizedListing(
|
||||
principal,
|
||||
listingId,
|
||||
request.LeaseToken,
|
||||
cancellationToken,
|
||||
out StoredListing? listing);
|
||||
if (lookup != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(lookup);
|
||||
}
|
||||
|
||||
StoredListing ownedListing = listing!;
|
||||
PublisherAuthorizationResult authorized = AuthorizeExisting(
|
||||
principal,
|
||||
ownedListing,
|
||||
ownedListing.Definition.Metadata);
|
||||
if (!authorized.IsAllowed)
|
||||
{
|
||||
return new(MapAuthorization(authorized.Error));
|
||||
}
|
||||
|
||||
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
|
||||
StoreResult<StoredListing> renewed = store.RenewLease(new(
|
||||
listingId,
|
||||
ownedListing.Definition.LeaseId,
|
||||
fingerprint,
|
||||
ownedListing.Definition.OwnerSubject,
|
||||
ownedListing.Version), cancellationToken);
|
||||
return renewed.Succeeded && renewed.Value is not null
|
||||
? new(RendezvousErrorCode.None, new RenewLeaseResponse
|
||||
{
|
||||
ExpiresAt = renewed.Value.LeaseExpiresAt,
|
||||
RenewAfterSeconds = timing.LeaseRenewAfterSeconds,
|
||||
})
|
||||
: new(MapStore(renewed.Code));
|
||||
}
|
||||
|
||||
public SessionServiceResult<bool> Update(
|
||||
AuthenticatedPrincipal principal,
|
||||
SessionListingId listingId,
|
||||
UpdateSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = ValidateUpdate(request);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
RendezvousErrorCode lookup = GetAuthorizedListing(
|
||||
principal,
|
||||
listingId,
|
||||
request.LeaseToken,
|
||||
cancellationToken,
|
||||
out StoredListing? listing);
|
||||
if (lookup != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(lookup);
|
||||
}
|
||||
|
||||
StoredListing ownedListing = listing!;
|
||||
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
|
||||
if (!authorized.IsAllowed)
|
||||
{
|
||||
return new(MapAuthorization(authorized.Error));
|
||||
}
|
||||
|
||||
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
|
||||
StoreResult<StoredListing> updated = store.UpdateListing(new(
|
||||
listingId,
|
||||
ownedListing.Definition.LeaseId,
|
||||
fingerprint,
|
||||
ownedListing.Definition.OwnerSubject,
|
||||
request.BuildVersion,
|
||||
request.DisplayName,
|
||||
request.Capacity.CurrentPlayers,
|
||||
request.Capacity.MaximumPlayers,
|
||||
request.Metadata), cancellationToken);
|
||||
return updated.Succeeded
|
||||
? new(RendezvousErrorCode.None, true)
|
||||
: new(MapStore(updated.Code));
|
||||
}
|
||||
|
||||
public SessionServiceResult<bool> Delete(
|
||||
AuthenticatedPrincipal principal,
|
||||
SessionListingId listingId,
|
||||
DeleteSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(principal);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
|
||||
if (principal is not IPublisherPrincipal publisher
|
||||
|| !capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint))
|
||||
{
|
||||
return new(RendezvousErrorCode.Forbidden);
|
||||
}
|
||||
|
||||
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return found.Code == StoreResultCode.ServiceUnavailable
|
||||
? new(RendezvousErrorCode.ServiceUnavailable)
|
||||
: new(RendezvousErrorCode.None, true);
|
||||
}
|
||||
|
||||
StoreResult<bool> deleted = store.DeleteListing(new(
|
||||
listingId,
|
||||
found.Value.Definition.LeaseId,
|
||||
fingerprint,
|
||||
publisher.Subject), cancellationToken);
|
||||
return deleted.Succeeded || deleted.Code == StoreResultCode.NotFound
|
||||
? new(RendezvousErrorCode.None, true)
|
||||
: new(MapStore(deleted.Code));
|
||||
}
|
||||
|
||||
private RendezvousErrorCode GetAuthorizedListing(
|
||||
AuthenticatedPrincipal principal,
|
||||
SessionListingId listingId,
|
||||
string leaseToken,
|
||||
CancellationToken cancellationToken,
|
||||
out StoredListing? listing)
|
||||
{
|
||||
listing = null;
|
||||
if (principal is not IPublisherPrincipal publisher)
|
||||
{
|
||||
return RendezvousErrorCode.Forbidden;
|
||||
}
|
||||
|
||||
if (!capabilities.TryFingerprint(leaseToken, out SecretFingerprint fingerprint))
|
||||
{
|
||||
return RendezvousErrorCode.NotFound;
|
||||
}
|
||||
|
||||
StoreResult<StoredListing> found = store.GetListing(listingId, false, cancellationToken);
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return MapStore(found.Code);
|
||||
}
|
||||
|
||||
if (!string.Equals(found.Value.Definition.OwnerSubject, publisher.Subject, StringComparison.Ordinal)
|
||||
|| found.Value.Definition.LeaseFingerprint != fingerprint)
|
||||
{
|
||||
return RendezvousErrorCode.NotFound;
|
||||
}
|
||||
|
||||
listing = found.Value;
|
||||
return RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private PublisherAuthorizationResult AuthorizeExisting(
|
||||
AuthenticatedPrincipal principal,
|
||||
StoredListing listing,
|
||||
IReadOnlyDictionary<string, string> metadata) => authorization.Authorize(
|
||||
principal,
|
||||
listing.Definition.Scope.GameId,
|
||||
listing.Definition.Scope.EnvironmentId,
|
||||
listing.Definition.RegionId,
|
||||
listing.Definition.ProtocolVersion,
|
||||
listing.Definition.Visibility,
|
||||
metadata,
|
||||
clock.UtcNow);
|
||||
|
||||
private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return !ContractValidation.IsIdempotencyKeyValid(request.IdempotencyKey)
|
||||
|| string.IsNullOrEmpty(request.GameId.Value)
|
||||
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||
|| string.IsNullOrEmpty(request.RegionId.Value)
|
||||
|| request.ProtocolVersion == 0
|
||||
|| !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|
||||
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||
|| !Enum.IsDefined(request.Visibility)
|
||||
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private static RendezvousErrorCode ValidateUpdate(UpdateSessionRequest request)
|
||||
{
|
||||
RendezvousErrorCode lease = ValidateLeaseRequest(request.ContractVersion, request.LeaseToken);
|
||||
if (lease != RendezvousErrorCode.None)
|
||||
{
|
||||
return lease;
|
||||
}
|
||||
|
||||
return !ContractValidation.IsBuildVersionValid(request.BuildVersion)
|
||||
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private static RendezvousErrorCode ValidateLeaseRequest(int contractVersion, string leaseToken)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(contractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
return ContractValidation.IsOpaqueHttpCredentialValid(leaseToken)
|
||||
? RendezvousErrorCode.None
|
||||
: RendezvousErrorCode.InvalidRequest;
|
||||
}
|
||||
|
||||
private static RendezvousErrorCode MapAuthorization(PublisherAuthorizationError error) => error switch
|
||||
{
|
||||
PublisherAuthorizationError.PrincipalExpired => RendezvousErrorCode.AuthenticationRequired,
|
||||
PublisherAuthorizationError.ProtocolNotAllowed => RendezvousErrorCode.IncompatibleProtocol,
|
||||
PublisherAuthorizationError.RegionNotAllowed
|
||||
or PublisherAuthorizationError.VisibilityNotAllowed
|
||||
or PublisherAuthorizationError.AnonymousMustBeUnlisted
|
||||
or PublisherAuthorizationError.MetadataNotAllowed => RendezvousErrorCode.InvalidRequest,
|
||||
_ => RendezvousErrorCode.Forbidden,
|
||||
};
|
||||
|
||||
private static RendezvousErrorCode MapStore(StoreResultCode code) => code switch
|
||||
{
|
||||
StoreResultCode.NotFound => RendezvousErrorCode.NotFound,
|
||||
StoreResultCode.Expired => RendezvousErrorCode.Expired,
|
||||
StoreResultCode.Revoked => RendezvousErrorCode.Forbidden,
|
||||
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
|
||||
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
|
||||
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
|
||||
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable,
|
||||
_ => RendezvousErrorCode.InternalError,
|
||||
};
|
||||
|
||||
private static string ComputeRegistrationFingerprint(RegisterSessionRequest request)
|
||||
{
|
||||
RegisterSessionRequest canonical = new()
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
IdempotencyKey = request.IdempotencyKey,
|
||||
GameId = request.GameId,
|
||||
EnvironmentId = request.EnvironmentId,
|
||||
RegionId = request.RegionId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
BuildVersion = request.BuildVersion,
|
||||
DisplayName = request.DisplayName,
|
||||
Visibility = request.Visibility,
|
||||
Capacity = new SessionCapacity
|
||||
{
|
||||
CurrentPlayers = request.Capacity.CurrentPlayers,
|
||||
MaximumPlayers = request.Capacity.MaximumPlayers,
|
||||
},
|
||||
Metadata = request.Metadata
|
||||
.OrderBy(static item => item.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal),
|
||||
};
|
||||
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options);
|
||||
byte[] digest = SHA256.HashData(encoded);
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
try
|
||||
{
|
||||
return Convert.ToBase64String(digest).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(digest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,12 @@ internal sealed record EphemeralStoreOptions
|
||||
RequireDuration(ReplayLifetime, TimeSpan.FromSeconds(30), nameof(ReplayLifetime));
|
||||
RequireDuration(IdempotencyLifetime, TimeSpan.FromMinutes(10), nameof(IdempotencyLifetime));
|
||||
RequireDuration(GracefulDrainLifetime, TimeSpan.FromSeconds(30), nameof(GracefulDrainLifetime));
|
||||
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)
|
||||
@@ -74,8 +80,10 @@ internal sealed record EphemeralStoreOptions
|
||||
|
||||
internal readonly record struct TenantScope(GameId GameId, EnvironmentId EnvironmentId);
|
||||
|
||||
internal readonly record struct SecretFingerprint
|
||||
internal readonly struct SecretFingerprint : IEquatable<SecretFingerprint>
|
||||
{
|
||||
private readonly string? _value;
|
||||
|
||||
public SecretFingerprint(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length > 128)
|
||||
@@ -83,12 +91,33 @@ internal readonly record struct SecretFingerprint
|
||||
throw new ArgumentException("Secret fingerprints must contain 1-128 characters.", nameof(value));
|
||||
}
|
||||
|
||||
Value = value;
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public string Value { get; }
|
||||
public bool IsValid => !string.IsNullOrWhiteSpace(Value) && Value.Length <= 128;
|
||||
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
|
||||
@@ -138,6 +167,7 @@ internal sealed record ListingDefinition
|
||||
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
|
||||
@@ -163,12 +193,25 @@ 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);
|
||||
SecretFingerprint LeaseFingerprint,
|
||||
string OwnerSubject);
|
||||
|
||||
internal sealed record BindHostPresenceCommand(
|
||||
MediationHandle Handle,
|
||||
@@ -265,6 +308,7 @@ internal interface IEphemeralRendezvousStore
|
||||
|
||||
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);
|
||||
|
||||
@@ -80,7 +80,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return admission;
|
||||
}
|
||||
|
||||
string idempotencyKey = $"listing:{command.Listing.OwnerSubject}:{command.IdempotencyKey}";
|
||||
string idempotencyKey = $"listing:{command.Listing.Scope.GameId}:{command.Listing.Scope.EnvironmentId}:{command.Listing.OwnerSubject}:{command.IdempotencyKey}";
|
||||
if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous))
|
||||
{
|
||||
if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal))
|
||||
@@ -151,7 +151,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
|
||||
if (entry.Definition.LeaseId != command.LeaseId
|
||||
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint)
|
||||
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|
||||
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
@@ -167,6 +168,52 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<StoredListing> UpdateListing(
|
||||
UpdateListingCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<StoredListing>(_ =>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ValidateSubject(command.OwnerSubject, nameof(command.OwnerSubject));
|
||||
if (!ContractValidation.IsBuildVersionValid(command.BuildVersion)
|
||||
|| !ContractValidation.IsDisplayNameValid(command.DisplayName)
|
||||
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|
||||
|| command.CurrentPlayers < 0
|
||||
|| command.CurrentPlayers > command.MaximumPlayers
|
||||
|| !ContractValidation.IsMetadataValid(command.Metadata))
|
||||
{
|
||||
throw new ArgumentException("Listing update invariants are invalid.", nameof(command));
|
||||
}
|
||||
|
||||
if (!_available)
|
||||
{
|
||||
return new(StoreResultCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (_drainDeadline.HasValue)
|
||||
{
|
||||
return new(StoreResultCode.Draining);
|
||||
}
|
||||
|
||||
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry)
|
||||
|| entry.Definition.LeaseId != command.LeaseId
|
||||
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|
||||
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
entry.Definition = StoredListing.Freeze(entry.Definition with
|
||||
{
|
||||
BuildVersion = command.BuildVersion,
|
||||
DisplayName = command.DisplayName,
|
||||
CurrentPlayers = command.CurrentPlayers,
|
||||
MaximumPlayers = command.MaximumPlayers,
|
||||
Metadata = command.Metadata,
|
||||
});
|
||||
entry.Version++;
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<bool> DeleteListing(
|
||||
DeleteListingCommand command,
|
||||
CancellationToken cancellationToken = default) => Atomic<bool>(_ =>
|
||||
@@ -174,7 +221,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? entry)
|
||||
|| entry.Definition.LeaseId != command.LeaseId
|
||||
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint)
|
||||
|| entry.Definition.LeaseFingerprint != command.LeaseFingerprint
|
||||
|| !string.Equals(entry.Definition.OwnerSubject, command.OwnerSubject, StringComparison.Ordinal))
|
||||
{
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
@@ -287,7 +335,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return admission;
|
||||
}
|
||||
|
||||
string idempotencyKey = $"attempt:{command.IdempotencyOwner}:{command.IdempotencyKey}";
|
||||
string idempotencyKey = $"attempt:{command.Scope.GameId}:{command.Scope.EnvironmentId}:{command.IdempotencyOwner}:{command.IdempotencyKey}";
|
||||
if (_idempotency.TryGetValue(idempotencyKey, out IdempotencyEntry? previous))
|
||||
{
|
||||
if (!string.Equals(previous.RequestFingerprint, command.RequestFingerprint, StringComparison.Ordinal))
|
||||
@@ -699,7 +747,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|| listing.CurrentPlayers > listing.MaximumPlayers
|
||||
|| !ContractValidation.IsMetadataValid(listing.Metadata)
|
||||
|| !listing.LeaseFingerprint.IsValid
|
||||
|| !listing.HostPresenceFingerprint.IsValid)
|
||||
|| !listing.HostPresenceFingerprint.IsValid
|
||||
|| !IsDerivationSaltValid(listing.CapabilityDerivationSalt))
|
||||
{
|
||||
throw new ArgumentException("Listing invariants are invalid.", nameof(listing));
|
||||
}
|
||||
@@ -753,6 +802,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
private static bool IsScopeValid(TenantScope scope) =>
|
||||
!string.IsNullOrEmpty(scope.GameId.Value) && !string.IsNullOrEmpty(scope.EnvironmentId.Value);
|
||||
|
||||
private static bool IsDerivationSaltValid(string? value) => value is not null
|
||||
&& value.Length == 43
|
||||
&& value.All(static character =>
|
||||
character is >= 'A' and <= 'Z'
|
||||
or >= 'a' and <= 'z'
|
||||
or >= '0' and <= '9'
|
||||
or '-'
|
||||
or '_');
|
||||
|
||||
private static void ValidateSubject(string subject, string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject) || subject.Length > 256)
|
||||
@@ -767,7 +825,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
DateTimeOffset wallExpiresAt,
|
||||
long version)
|
||||
{
|
||||
public ListingDefinition Definition { get; } = definition;
|
||||
public ListingDefinition Definition { get; set; } = definition;
|
||||
public TimeSpan LeaseDeadline { get; set; } = leaseDeadline;
|
||||
public DateTimeOffset WallExpiresAt { get; set; } = wallExpiresAt;
|
||||
public long Version { get; set; } = version;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Sessions;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Transport;
|
||||
@@ -7,10 +10,12 @@ namespace FinalFactory.Rendezvous.Server.Transport;
|
||||
/// <summary>
|
||||
/// Owns the cancellable UDP socket used by the future NAT mediator.
|
||||
/// </summary>
|
||||
public sealed partial class UdpMediatorService : BackgroundService
|
||||
internal sealed partial class UdpMediatorService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<UdpMediatorService> _logger;
|
||||
private readonly UdpMediatorOptions _options;
|
||||
private readonly IEphemeralRendezvousStore _store;
|
||||
private readonly ISessionCapabilityService _capabilities;
|
||||
private UdpClient? _udpClient;
|
||||
|
||||
/// <summary>
|
||||
@@ -18,10 +23,14 @@ public sealed partial class UdpMediatorService : BackgroundService
|
||||
/// </summary>
|
||||
public UdpMediatorService(
|
||||
IOptions<UdpMediatorOptions> options,
|
||||
ILogger<UdpMediatorService> logger)
|
||||
ILogger<UdpMediatorService> logger,
|
||||
IEphemeralRendezvousStore store,
|
||||
ISessionCapabilityService capabilities)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_store = store;
|
||||
_capabilities = capabilities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,7 +90,10 @@ public sealed partial class UdpMediatorService : BackgroundService
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_ = await udpClient.ReceiveAsync(stoppingToken).ConfigureAwait(false);
|
||||
UdpReceiveResult received = await udpClient
|
||||
.ReceiveAsync(stoppingToken)
|
||||
.ConfigureAwait(false);
|
||||
ProcessDatagram(received.Buffer, received.RemoteEndPoint, stoppingToken);
|
||||
// Bootstrap deliberately emits no UDP response. Protocol handling lands in #11.
|
||||
}
|
||||
}
|
||||
@@ -99,6 +111,53 @@ public sealed partial class UdpMediatorService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
internal UdpPresenceProcessingResult ProcessDatagram(
|
||||
ReadOnlySpan<byte> encoded,
|
||||
IPEndPoint observedSource,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observedSource);
|
||||
if (!RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? datagram, out _)
|
||||
|| datagram is null
|
||||
|| !_capabilities.TryFingerprint(datagram.Capability, out SecretFingerprint fingerprint))
|
||||
{
|
||||
return UdpPresenceProcessingResult.Dropped;
|
||||
}
|
||||
|
||||
if (datagram.MessageType != UdpPresenceMessageType.HostPresence)
|
||||
{
|
||||
return UdpPresenceProcessingResult.ClientPresenceDeferred;
|
||||
}
|
||||
|
||||
AddressFamilyKind publicFamily = observedSource.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => AddressFamilyKind.Ipv4,
|
||||
AddressFamily.InterNetworkV6 => AddressFamilyKind.Ipv6,
|
||||
_ => 0,
|
||||
};
|
||||
if (publicFamily == 0)
|
||||
{
|
||||
return UdpPresenceProcessingResult.Dropped;
|
||||
}
|
||||
|
||||
ObservedEndpoint publicEndpoint = new(
|
||||
publicFamily,
|
||||
observedSource.Address.ToString(),
|
||||
observedSource.Port);
|
||||
ObservedEndpoint localEndpoint = new(
|
||||
datagram.AddressFamily,
|
||||
datagram.LocalAddress,
|
||||
datagram.LocalPort);
|
||||
StoreResult<StoredListing> bound = _store.BindHostPresence(new(
|
||||
datagram.MediationHandle,
|
||||
fingerprint,
|
||||
publicEndpoint,
|
||||
localEndpoint), cancellationToken);
|
||||
return bound.Succeeded
|
||||
? UdpPresenceProcessingResult.HostPresenceAccepted
|
||||
: UdpPresenceProcessingResult.HostPresenceRejected;
|
||||
}
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 1,
|
||||
Level = LogLevel.Information,
|
||||
@@ -114,3 +173,11 @@ public sealed partial class UdpMediatorService : BackgroundService
|
||||
Message = "UDP mediator stopped")]
|
||||
private static partial void LogMediatorStopped(ILogger logger);
|
||||
}
|
||||
|
||||
internal enum UdpPresenceProcessingResult
|
||||
{
|
||||
Dropped = 0,
|
||||
HostPresenceAccepted = 1,
|
||||
HostPresenceRejected = 2,
|
||||
ClientPresenceDeferred = 3,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user