777 lines
32 KiB
C#
777 lines
32 KiB
C#
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<RegisterSessionRequest>("application/json")
|
|
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
|
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
|
.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>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
|
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
|
.Produces<ApiError>(StatusCodes.Status410Gone)
|
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
|
.WithName("RenewSessionLease");
|
|
sessions.MapPut("/{listingId}", UpdateSession)
|
|
.Accepts<UpdateSessionRequest>("application/json")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
|
.WithName("UpdateSession");
|
|
sessions.MapDelete("/{listingId}", DeleteSession)
|
|
.Accepts<DeleteSessionRequest>("application/json")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
|
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
|
|
.Produces<ApiError>(StatusCodes.Status403Forbidden)
|
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
|
.WithName("DeleteSession");
|
|
sessions.MapGet("/", BrowseSessions)
|
|
.Produces<BrowseSessionsResponse>()
|
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
|
.WithName("BrowseSessions");
|
|
sessions.MapGet("/stream", StreamSessions)
|
|
.Produces<SessionStreamEvent>(StatusCodes.Status200OK, contentType: "text/event-stream")
|
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
|
.WithName("StreamSessions");
|
|
sessions.MapGet("/{listingId}", GetSession)
|
|
.Produces<GetSessionResponse>()
|
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
|
.WithName("GetSession");
|
|
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
|
|
.Produces<BrowseHostJoinAttemptsResponse>()
|
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
|
.WithName("BrowseHostJoinAttempts");
|
|
|
|
RouteGroupBuilder attempts = endpoints
|
|
.MapGroup("/v1/join-attempts")
|
|
.WithTags("Join attempts");
|
|
attempts.MapPost("/", CreateJoinAttempt)
|
|
.Accepts<CreateJoinAttemptRequest>("application/json")
|
|
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
|
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
|
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
|
.Produces<ApiError>(StatusCodes.Status410Gone)
|
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
|
.WithName("CreateJoinAttempt");
|
|
attempts.MapDelete("/{attemptId}", CancelJoinAttempt)
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
|
.WithName("CancelJoinAttempt");
|
|
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
|
|
.Accepts<ReportConnectionOutcomeRequest>("application/json")
|
|
.Produces<ReportConnectionOutcomeResponse>()
|
|
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
|
.Produces<ApiError>(StatusCodes.Status413PayloadTooLarge)
|
|
.Produces<ApiError>(StatusCodes.Status404NotFound)
|
|
.Produces<ApiError>(StatusCodes.Status409Conflict)
|
|
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
|
.Produces<ApiError>(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<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,
|
|
[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<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,
|
|
[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<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,
|
|
[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<bool> 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<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 async Task<IResult> 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<SessionStreamSubscription> 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<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,
|
|
[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<BrowseHostJoinAttemptsResponse> 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<CreateJoinAttemptResponse> 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<bool> 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.",
|
|
};
|
|
}
|