using System.Net; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Browser; 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 { private const int NotImplementedStatus = StatusCodes.Status501NotImplemented; 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.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.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status410Gone) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("RenewSessionLease"); sessions.MapPut("/{listingId}", UpdateSession) .Accepts("application/json") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("UpdateSession"); sessions.MapDelete("/{listingId}", DeleteSession) .Accepts("application/json") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("DeleteSession"); sessions.MapGet("/", BrowseSessions) .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("BrowseSessions"); sessions.MapGet("/{listingId}", GetSession) .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("GetSession"); sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts) .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .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.Status404NotFound) .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("CreateJoinAttempt"); attempts.MapDelete("/{attemptId}", CancelJoinAttempt) .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("CancelJoinAttempt"); attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome) .Accepts("application/json") .Produces() .Produces(StatusCodes.Status501NotImplemented) .WithName("ReportConnectionOutcome"); return endpoints; } 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 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] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) { if (!TryAuthenticatePublisher( authorizationHeader, credentials, clock, out AuthenticatedPrincipal? principal)) { return AuthenticationRequired(httpContext); } 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] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) { if (!TryAuthenticatePublisher( authorizationHeader, credentials, clock, out AuthenticatedPrincipal? principal)) { return AuthenticationRequired(httpContext); } 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] IWallClock clock, HttpContext httpContext, CancellationToken cancellationToken) { if (!TryAuthenticatePublisher( authorizationHeader, credentials, clock, out AuthenticatedPrincipal? principal)) { return AuthenticationRequired(httpContext); } 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, 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); } 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 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 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, CancellationToken cancellationToken) { 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, HttpContext httpContext, CancellationToken cancellationToken) { if (httpContext.Connection.RemoteIpAddress is not IPAddress remoteAddress) { return Error(RendezvousErrorCode.InvalidRequest); } string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress); 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, CancellationToken cancellationToken) { JoinAttemptServiceResult result = attempts.Cancel( attemptId, clientPunchCapability, cancellationToken); return result.Succeeded ? Results.NoContent() : Error(result.Error); } private static IResult ReportConnectionOutcome( JoinAttemptId attemptId, [FromBody] ReportConnectionOutcomeRequest request) => NotImplemented(); private static IResult NotImplemented() => Results.Json( new ApiError { Code = RendezvousErrorCode.ServiceUnavailable, Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.", }, 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.", }; }