using System.Net; using System.Text.Json; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.State; using Microsoft.AspNetCore.Mvc; namespace FinalFactory.Rendezvous.Server.Http; internal static class ContractEndpoints { public static IEndpointRouteBuilder MapRendezvousContractEndpoints( this IEndpointRouteBuilder endpoints) { RouteGroupBuilder sessions = endpoints.MapGroup("/v1/sessions").WithTags("Sessions"); sessions.MapPost("/", RegisterSession) .Accepts("application/json") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status410Gone) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("RegisterSession"); sessions.MapPost("/{listingId}/renew", RenewLease) .Accepts("application/json") .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status410Gone) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("RenewSessionLease"); sessions.MapPut("/{listingId}", UpdateSession) .Accepts("application/json") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("UpdateSession"); sessions.MapDelete("/{listingId}", DeleteSession) .Accepts("application/json") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("DeleteSession"); sessions.MapGet("/", BrowseSessions) .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("BrowseSessions"); sessions.MapGet("/stream", StreamSessions) .Produces(StatusCodes.Status200OK, contentType: "text/event-stream") .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("StreamSessions"); sessions.MapGet("/{listingId}", GetSession) .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("GetSession"); sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts) .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("BrowseHostJoinAttempts"); RouteGroupBuilder attempts = endpoints .MapGroup("/v1/join-attempts") .WithTags("Join attempts"); attempts.MapPost("/", CreateJoinAttempt) .Accepts("application/json") .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status410Gone) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("CreateJoinAttempt"); attempts.MapDelete("/{attemptId}", CancelJoinAttempt) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("CancelJoinAttempt"); attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome) .Accepts("application/json") .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status413PayloadTooLarge) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("ReportConnectionOutcome"); return endpoints; } private static IResult RegisterSession( [FromBody] RegisterSessionRequest request, [FromHeader(Name = "Authorization")] string? authorizationHeader, [FromServices] PrincipalCredentialService credentials, [FromServices] SessionLeaseService sessions, [FromServices] AbuseProtectionService abuseProtection, [FromServices] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) { if (!TryAuthenticatePublisher( authorizationHeader, credentials, clock, out AuthenticatedPrincipal? principal)) { return AuthenticationRequired(httpContext); } IPublisherPrincipal publisher = (IPublisherPrincipal)principal!; if (!TryAcquireIdentity( abuseProtection, httpContext, "RegisterSession", Tenant(publisher.GameId, publisher.EnvironmentId), publisher.Subject, null, out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { SessionServiceResult 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, [FromHeader(Name = "Authorization")] string? authorizationHeader, [FromServices] PrincipalCredentialService credentials, [FromServices] SessionLeaseService sessions, [FromServices] AbuseProtectionService abuseProtection, [FromServices] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) { if (!TryAuthenticatePublisher( authorizationHeader, credentials, clock, out AuthenticatedPrincipal? principal)) { return AuthenticationRequired(httpContext); } IPublisherPrincipal publisher = (IPublisherPrincipal)principal!; if (!TryAcquireIdentity( abuseProtection, httpContext, "RenewSessionLease", Tenant(publisher.GameId, publisher.EnvironmentId), publisher.Subject, listingId.ToString(), out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { SessionServiceResult 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, [FromHeader(Name = "Authorization")] string? authorizationHeader, [FromServices] PrincipalCredentialService credentials, [FromServices] SessionLeaseService sessions, [FromServices] AbuseProtectionService abuseProtection, [FromServices] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) { if (!TryAuthenticatePublisher( authorizationHeader, credentials, clock, out AuthenticatedPrincipal? principal)) { return AuthenticationRequired(httpContext); } IPublisherPrincipal publisher = (IPublisherPrincipal)principal!; if (!TryAcquireIdentity( abuseProtection, httpContext, "UpdateSession", Tenant(publisher.GameId, publisher.EnvironmentId), publisher.Subject, listingId.ToString(), out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { SessionServiceResult result = sessions.Update( principal!, listingId, request, cancellationToken); return result.Succeeded ? Results.NoContent() : Error(result.Error); } } private static IResult DeleteSession( SessionListingId listingId, [FromBody] DeleteSessionRequest request, [FromHeader(Name = "Authorization")] string? authorizationHeader, [FromServices] PrincipalCredentialService credentials, [FromServices] SessionLeaseService sessions, [FromServices] AbuseProtectionService abuseProtection, [FromServices] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) { if (!TryAuthenticatePublisher( authorizationHeader, credentials, clock, out AuthenticatedPrincipal? principal)) { return AuthenticationRequired(httpContext); } IPublisherPrincipal publisher = (IPublisherPrincipal)principal!; if (!TryAcquireIdentity( abuseProtection, httpContext, "DeleteSession", Tenant(publisher.GameId, publisher.EnvironmentId), publisher.Subject, listingId.ToString(), out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { SessionServiceResult result = sessions.Delete( principal!, listingId, request, cancellationToken); return result.Succeeded ? Results.NoContent() : Error(result.Error); } } private static IResult BrowseSessions( [FromQuery] int contractVersion, [FromQuery] string gameId, [FromQuery] string environmentId, [FromQuery] uint protocolVersion, [FromQuery] string? regionId, [FromQuery] int? pageSize, [FromQuery] bool? excludeFull, [FromQuery] string? cursor, [FromServices] SessionBrowserService browser, [FromServices] AbuseProtectionService abuseProtection, HttpContext httpContext, 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); } if (!TryAcquireIdentity( abuseProtection, httpContext, "BrowseSessions", Tenant(parsedGameId, parsedEnvironmentId), null, null, out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { BrowserServiceResult 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 async Task StreamSessions( [FromQuery] int contractVersion, [FromQuery] string gameId, [FromQuery] string environmentId, [FromQuery] uint protocolVersion, [FromQuery] string? regionId, [FromQuery] bool? excludeFull, [FromQuery] string? streamCursor, [FromHeader(Name = "Last-Event-ID")] string? lastEventId, [FromServices] SessionStreamService streams, [FromServices] AbuseProtectionService abuseProtection, HttpContext httpContext, 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); } if (!TryAcquireIdentity( abuseProtection, httpContext, "StreamSessions", Tenant(parsedGameId, parsedEnvironmentId), null, null, out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { BrowserServiceResult subscribed = streams.Subscribe(new() { ContractVersion = contractVersion, GameId = parsedGameId, EnvironmentId = parsedEnvironmentId, ProtocolVersion = protocolVersion, RegionId = regionId is null ? null : new RegionId(regionId), ExcludeFull = excludeFull ?? false, }, string.IsNullOrEmpty(lastEventId) ? streamCursor : lastEventId); if (!subscribed.Succeeded || subscribed.Value is null) { return Error(subscribed.Error); } using SessionStreamSubscription subscription = subscribed.Value; using CancellationTokenSource duration = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken); duration.CancelAfter(streams.MaximumConnectionDuration); HttpResponse response = httpContext.Response; response.StatusCode = StatusCodes.Status200OK; response.ContentType = "text/event-stream"; response.Headers.CacheControl = "no-cache, no-store"; response.Headers["X-Accel-Buffering"] = "no"; await response.StartAsync(duration.Token).ConfigureAwait(false); try { while (!duration.IsCancellationRequested) { SessionStreamReadResult read = streams.Read(subscription); if (read.RequiresReset) { await WriteSseAsync(response, streams.ResetEvent(subscription), duration.Token) .ConfigureAwait(false); await response.Body.FlushAsync(duration.Token).ConfigureAwait(false); break; } if (read.Events.Count > 0) { foreach (SessionStreamEvent item in read.Events) { await WriteSseAsync(response, item, duration.Token).ConfigureAwait(false); } await response.Body.FlushAsync(duration.Token).ConfigureAwait(false); continue; } bool changed = await streams.WaitForChangeAsync(subscription, duration.Token) .ConfigureAwait(false); if (!changed) { await WriteSseAsync( response, streams.KeepaliveEvent(subscription), duration.Token).ConfigureAwait(false); await response.Body.FlushAsync(duration.Token).ConfigureAwait(false); } } } catch (OperationCanceledException) when (duration.IsCancellationRequested) { } return Results.Empty; } } private static async Task WriteSseAsync( HttpResponse response, SessionStreamEvent item, CancellationToken cancellationToken) { string eventName = item.Kind switch { SessionStreamEventKind.SessionUpsert => "session_upsert", SessionStreamEventKind.SessionRemove => "session_remove", SessionStreamEventKind.Reset => "reset", _ => "keepalive", }; string data = JsonSerializer.Serialize(item, ContractJson.Options); await response.WriteAsync( $"id: {item.Cursor}\nevent: {eventName}\ndata: {data}\n\n", cancellationToken).ConfigureAwait(false); } private static IResult GetSession( SessionListingId listingId, [FromQuery] int contractVersion, [FromQuery] string gameId, [FromQuery] string environmentId, [FromQuery] uint protocolVersion, [FromServices] SessionBrowserService browser, [FromServices] AbuseProtectionService abuseProtection, HttpContext httpContext, 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); } if (!TryAcquireIdentity( abuseProtection, httpContext, "GetSession", Tenant(parsedGameId, parsedEnvironmentId), null, listingId.ToString(), out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { BrowserServiceResult 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, [FromQuery] int contractVersion, [FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken, [FromQuery] int? pageSize, [FromQuery] string? cursor, [FromServices] JoinAttemptService attempts, [FromServices] AbuseProtectionService abuseProtection, HttpContext httpContext, CancellationToken cancellationToken) { if (!TryAcquireIdentity( abuseProtection, httpContext, "BrowseHostJoinAttempts", null, AbuseProtectionService.FingerprintSecret(leaseToken ?? string.Empty), listingId.ToString(), out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { JoinAttemptServiceResult result = attempts.BrowseForHost( listingId, contractVersion, leaseToken, pageSize ?? ContractLimits.BrowserPageMaxItems, cursor, cancellationToken); return result.Succeeded && result.Value is not null ? Results.Ok(result.Value) : Error(result.Error); } } private static IResult CreateJoinAttempt( [FromBody] CreateJoinAttemptRequest request, [FromServices] JoinAttemptService attempts, [FromServices] AbuseProtectionService abuseProtection, HttpContext httpContext, CancellationToken cancellationToken) { if (httpContext.Connection.RemoteIpAddress is not IPAddress remoteAddress) { return Error(RendezvousErrorCode.InvalidRequest); } string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress); if (!TryAcquireIdentity( abuseProtection, httpContext, "CreateJoinAttempt", Tenant(request.GameId, request.EnvironmentId), clientSubject, request.ListingId.ToString(), out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { JoinAttemptServiceResult result = attempts.Create( clientSubject, request, cancellationToken); return result.Succeeded && result.Value is not null ? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value) : Error(result.Error); } } private static IResult CancelJoinAttempt( JoinAttemptId attemptId, [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability, [FromServices] JoinAttemptService attempts, [FromServices] AbuseProtectionService abuseProtection, HttpContext httpContext, CancellationToken cancellationToken) { if (!TryAcquireIdentity( abuseProtection, httpContext, "CancelJoinAttempt", null, AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty), attemptId.ToString(), out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { JoinAttemptServiceResult result = attempts.Cancel( attemptId, clientPunchCapability, cancellationToken); return result.Succeeded ? Results.NoContent() : Error(result.Error); } } private static IResult ReportConnectionOutcome( JoinAttemptId attemptId, [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability, [FromBody] ReportConnectionOutcomeRequest request, [FromServices] ConnectionOutcomeService outcomes, [FromServices] AbuseProtectionService abuseProtection, HttpContext httpContext, CancellationToken cancellationToken) { if (!TryAcquireIdentity( abuseProtection, httpContext, "ReportConnectionOutcome", null, AbuseProtectionService.FingerprintSecret(clientPunchCapability ?? string.Empty), attemptId.ToString(), out AbuseProtectionService.AbuseLease? abuseLease)) { return RateLimited(httpContext); } using (abuseLease) { ConnectionOutcomeServiceResult result = outcomes.Report( attemptId, clientPunchCapability, request, cancellationToken); return result.Succeeded && result.Value is not null ? Results.Ok(result.Value) : Error(result.Error); } } 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 bool TryAcquireIdentity( AbuseProtectionService abuseProtection, HttpContext httpContext, string operation, string? tenant, string? principal, string? resource, out AbuseProtectionService.AbuseLease? lease) { if (abuseProtection.TryAcquireHttpIdentity( operation, httpContext.Connection.RemoteIpAddress, tenant, principal, resource, out lease, out int retryAfterSeconds)) { return true; } httpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString( System.Globalization.CultureInfo.InvariantCulture); return false; } private static string Tenant(GameId gameId, EnvironmentId environmentId) => $"{gameId.Value}/{environmentId.Value}"; private static IResult Error(RendezvousErrorCode code, int? retryAfterSeconds = null) => Results.Json( new ApiError { Code = code, Message = ErrorMessage(code), RetryAfterSeconds = retryAfterSeconds, }, ContractJson.Options, statusCode: ErrorStatus(code)); private static IResult AuthenticationRequired(HttpContext context) { context.Response.Headers.WWWAuthenticate = "Bearer"; return Error(RendezvousErrorCode.AuthenticationRequired); } private static IResult RateLimited(HttpContext context) { int? retryAfterSeconds = int.TryParse( context.Response.Headers.RetryAfter, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int parsed) ? Math.Clamp(parsed, 1, 60) : null; return Error(RendezvousErrorCode.RateLimited, retryAfterSeconds); } private static int ErrorStatus(RendezvousErrorCode code) => code switch { RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized, RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden, RendezvousErrorCode.NotFound => StatusCodes.Status404NotFound, RendezvousErrorCode.Conflict or RendezvousErrorCode.IncompatibleProtocol or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict, RendezvousErrorCode.Expired or RendezvousErrorCode.StaleHost => 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.StaleHost => "The session has no fresh host presence.", RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.", RendezvousErrorCode.RateLimited => "The request rate limit was exceeded.", 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.", }; }