393 lines
17 KiB
C#
393 lines
17 KiB
C#
using System.Net;
|
|
using FinalFactory.Rendezvous.Contracts;
|
|
using FinalFactory.Rendezvous.Server.Abuse;
|
|
using FinalFactory.Rendezvous.Server.Browser;
|
|
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
|
|
using FinalFactory.Rendezvous.Server.Deployment;
|
|
using FinalFactory.Rendezvous.Server.Diagnostics;
|
|
using FinalFactory.Rendezvous.Server.Http;
|
|
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
|
using FinalFactory.Rendezvous.Server.Observability;
|
|
using FinalFactory.Rendezvous.Server.Operations;
|
|
using FinalFactory.Rendezvous.Server.Provisioning;
|
|
using FinalFactory.Rendezvous.Server.Sessions;
|
|
using FinalFactory.Rendezvous.Server.State;
|
|
using FinalFactory.Rendezvous.Server.Transport;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.OpenApi;
|
|
|
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
|
builder.Logging.AddFilter(
|
|
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
|
|
LogLevel.None);
|
|
bool isOpenApiGeneration = string.Equals(
|
|
System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name,
|
|
"GetDocument.Insider",
|
|
StringComparison.Ordinal);
|
|
|
|
builder.Services.AddOpenApi("v1", static options =>
|
|
{
|
|
options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
|
|
{
|
|
Type type = context.JsonTypeInfo.Type;
|
|
if (type == typeof(GameId)
|
|
|| type == typeof(EnvironmentId)
|
|
|| type == typeof(RegionId))
|
|
{
|
|
schema.Type = JsonSchemaType.String;
|
|
}
|
|
else if (type == typeof(SessionListingId)
|
|
|| type == typeof(LeaseId)
|
|
|| type == typeof(JoinAttemptId)
|
|
|| type == typeof(MediationHandle))
|
|
{
|
|
schema.Type = JsonSchemaType.String;
|
|
schema.Format = "uuid";
|
|
}
|
|
|
|
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.",
|
|
};
|
|
const string attemptSchemeName = "JoinAttemptCapability";
|
|
document.Components.SecuritySchemes[attemptSchemeName] = new OpenApiSecurityScheme
|
|
{
|
|
Type = SecuritySchemeType.ApiKey,
|
|
Name = "X-Rendezvous-Client-Punch-Capability",
|
|
In = ParameterLocation.Header,
|
|
Description = "Attempt-scoped client capability returned only to the joining caller.",
|
|
};
|
|
const string operatorSchemeName = "OperatorBearer";
|
|
document.Components.SecuritySchemes[operatorSchemeName] = new OpenApiSecurityScheme
|
|
{
|
|
Type = SecuritySchemeType.Http,
|
|
Scheme = "bearer",
|
|
BearerFormat = "rv1 operator credential",
|
|
Description = "Operator-only credential with an explicit permission set.",
|
|
};
|
|
|
|
HashSet<string> securedOperations = new(StringComparer.Ordinal)
|
|
{
|
|
"RegisterSession",
|
|
"RenewSessionLease",
|
|
"UpdateSession",
|
|
"DeleteSession",
|
|
};
|
|
HashSet<string> operatorOperations = new(StringComparer.Ordinal)
|
|
{
|
|
"GetOperatorStatus",
|
|
"RevokeOperatorListing",
|
|
"RevokeOperatorPrincipal",
|
|
"RevokeOperatorSigningKey",
|
|
"BeginOperatorDrain",
|
|
};
|
|
OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
|
|
OpenApiSecuritySchemeReference attemptReference = new(attemptSchemeName, document, null);
|
|
OpenApiSecuritySchemeReference operatorReference = new(operatorSchemeName, 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] = [],
|
|
});
|
|
}
|
|
|
|
foreach (OpenApiOperation operation in path.Operations.Values.Where(
|
|
operation => operation.OperationId is
|
|
"CancelJoinAttempt" or "ReportConnectionOutcome"))
|
|
{
|
|
operation.Security ??= [];
|
|
operation.Security.Add(new OpenApiSecurityRequirement
|
|
{
|
|
[attemptReference] = [],
|
|
});
|
|
}
|
|
|
|
foreach (OpenApiOperation operation in path.Operations.Values.Where(
|
|
operation => operatorOperations.Contains(
|
|
operation.OperationId ?? string.Empty)))
|
|
{
|
|
operation.Security ??= [];
|
|
operation.Security.Add(new OpenApiSecurityRequirement
|
|
{
|
|
[operatorReference] = [],
|
|
});
|
|
}
|
|
|
|
foreach (OpenApiOperation operation in path.Operations.Values)
|
|
{
|
|
if (operation.Responses is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach ((string status, IOpenApiResponse response) in operation.Responses)
|
|
{
|
|
if (response is not OpenApiResponse concreteResponse)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
concreteResponse.Headers ??=
|
|
new Dictionary<string, IOpenApiHeader>(StringComparer.OrdinalIgnoreCase);
|
|
concreteResponse.Headers["X-Rendezvous-Correlation-ID"] = new OpenApiHeader
|
|
{
|
|
Description = "Safe request correlation identifier generated by the service.",
|
|
Schema = new OpenApiSchema
|
|
{
|
|
Type = JsonSchemaType.String,
|
|
},
|
|
};
|
|
if (string.Equals(
|
|
status,
|
|
StatusCodes.Status429TooManyRequests.ToString(
|
|
System.Globalization.CultureInfo.InvariantCulture),
|
|
StringComparison.Ordinal))
|
|
{
|
|
concreteResponse.Headers["Retry-After"] = new OpenApiHeader
|
|
{
|
|
Description = "Whole seconds before the caller should retry (1-60).",
|
|
Schema = new OpenApiSchema
|
|
{
|
|
Type = JsonSchemaType.Integer,
|
|
Format = "int32",
|
|
},
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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>();
|
|
builder.WebHost.ConfigureKestrel(static options =>
|
|
options.Limits.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes);
|
|
|
|
builder.Services
|
|
.AddOptions<AbuseProtectionOptions>()
|
|
.BindConfiguration(AbuseProtectionOptions.SectionName)
|
|
.ValidateDataAnnotations()
|
|
.Validate(
|
|
options => options.HttpOptionalRequestsPerWindow
|
|
< options.HttpGlobalRequestsPerWindow,
|
|
"The optional HTTP request budget must leave global capacity for lease operations.")
|
|
.Validate(
|
|
options => options.HttpOptionalConcurrency < options.HttpGlobalConcurrency,
|
|
"The optional HTTP concurrency budget must leave global capacity for lease operations.")
|
|
.Validate(
|
|
options => options.HttpOptionalIpPrefixRequestsPerWindow
|
|
< options.HttpIpPrefixRequestsPerWindow,
|
|
"The optional HTTP source budget must leave capacity for lease operations.")
|
|
.Validate(
|
|
options => options.HttpOptionalIpPrefixConcurrency
|
|
< options.HttpIpPrefixConcurrency,
|
|
"The optional HTTP source concurrency must leave capacity for lease operations.")
|
|
.Validate(
|
|
options => options.CriticalTrackedKeyReserve >= 16
|
|
&& options.UdpTrackedKeyLimit + options.CriticalTrackedKeyReserve
|
|
< options.MaxTrackedKeys,
|
|
"The tracked-key reserve must leave at least 16 keys for critical operations.")
|
|
.Validate(
|
|
options => options.TrustedProxyAddresses is { Length: <= 32 } addresses
|
|
&& addresses.All(
|
|
static value => IPAddress.TryParse(value, out _)),
|
|
"Trusted proxy addresses must contain at most 32 literal IP addresses.")
|
|
.Validate(
|
|
options => options.OperatorAllowedAddresses is { Length: <= 32 } addresses
|
|
&& addresses.All(
|
|
static value => IPAddress.TryParse(value, out _)),
|
|
"Operator allowed addresses must contain at most 32 literal IP addresses.")
|
|
.ValidateOnStart();
|
|
builder.Services.AddSingleton<AbuseProtectionService>();
|
|
builder.Services
|
|
.AddOptions<AuditOptions>()
|
|
.BindConfiguration(AuditOptions.SectionName)
|
|
.ValidateDataAnnotations()
|
|
.ValidateOnStart();
|
|
AbuseProtectionOptions configuredAbuseProtection = builder.Configuration
|
|
.GetSection(AbuseProtectionOptions.SectionName)
|
|
.Get<AbuseProtectionOptions>() ?? new AbuseProtectionOptions();
|
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
TrustedProxyForwarding.Configure(options, configuredAbuseProtection));
|
|
|
|
DeploymentOptions deploymentOptions = builder.Configuration
|
|
.GetSection(DeploymentOptions.SectionName)
|
|
.Get<DeploymentOptions>() ?? new DeploymentOptions();
|
|
if (!builder.Environment.IsDevelopment() && !isOpenApiGeneration)
|
|
{
|
|
IReadOnlyList<string> deploymentErrors = deploymentOptions.ValidateProduction(
|
|
configuredAbuseProtection,
|
|
builder.Configuration["AllowedHosts"]);
|
|
if (deploymentErrors.Count > 0)
|
|
{
|
|
throw new DeploymentConfigurationException(deploymentErrors);
|
|
}
|
|
}
|
|
|
|
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(deploymentOptions));
|
|
DiagnosticDashboardOptions diagnosticDashboardOptions = builder.Configuration
|
|
.GetSection(DiagnosticDashboardOptions.SectionName)
|
|
.Get<DiagnosticDashboardOptions>() ?? new DiagnosticDashboardOptions();
|
|
IReadOnlyList<string> diagnosticErrors = diagnosticDashboardOptions.Validate();
|
|
if (diagnosticErrors.Count > 0)
|
|
{
|
|
throw new DeploymentConfigurationException(diagnosticErrors);
|
|
}
|
|
builder.Services.AddSingleton(
|
|
Microsoft.Extensions.Options.Options.Create(diagnosticDashboardOptions));
|
|
PrometheusMetricsOptions metricsOptions = builder.Configuration
|
|
.GetSection(PrometheusMetricsOptions.SectionName)
|
|
.Get<PrometheusMetricsOptions>() ?? new PrometheusMetricsOptions();
|
|
IReadOnlyList<string> metricsErrors = metricsOptions.Validate();
|
|
if (metricsErrors.Count > 0)
|
|
{
|
|
throw new DeploymentConfigurationException(metricsErrors);
|
|
}
|
|
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(metricsOptions));
|
|
MetricsAccessCredential? metricsCredential = null;
|
|
if (metricsOptions.Enabled && !isOpenApiGeneration)
|
|
{
|
|
EnvironmentSecretProvider metricsSecrets = new();
|
|
if (!MetricsAccessCredential.TryCreate(metricsOptions, metricsSecrets, out metricsCredential)
|
|
|| metricsCredential is null)
|
|
{
|
|
throw new DeploymentConfigurationException(
|
|
[$"{PrometheusMetricsOptions.SectionName}:BearerTokenSecretReference did not resolve to 32-128 visible ASCII bytes."]);
|
|
}
|
|
builder.Services.AddSingleton(metricsCredential);
|
|
}
|
|
builder.Services.Configure<HostOptions>(options =>
|
|
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
|
|
|
|
SystemRendezvousClock rendezvousClock = new();
|
|
SessionChangeJournal sessionChanges = new(new SessionChangeJournalOptions());
|
|
EphemeralStoreOptions stateOptions = new()
|
|
{
|
|
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
|
|
};
|
|
InMemoryEphemeralRendezvousStore stateStore = new(
|
|
stateOptions,
|
|
rendezvousClock,
|
|
rendezvousClock,
|
|
sessionChanges);
|
|
builder.Services.AddSingleton(stateStore);
|
|
builder.Services.AddSingleton(sessionChanges);
|
|
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
|
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
|
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
|
|
builder.Services.AddSingleton<RendezvousTelemetry>();
|
|
builder.Services.AddSingleton<AuditTrail>();
|
|
builder.Services.AddSingleton<RendezvousReadiness>();
|
|
|
|
if (isOpenApiGeneration)
|
|
{
|
|
builder.Services.AddSingleton(new ProvisioningReadiness(false));
|
|
}
|
|
else
|
|
{
|
|
ProvisioningOptions provisioningOptions = builder.Configuration
|
|
.GetSection(ProvisioningOptions.SectionName)
|
|
.Get<ProvisioningOptions>() ?? new ProvisioningOptions();
|
|
ISecretProvider secretProvider = builder.Environment.IsDevelopment()
|
|
? new EphemeralDevelopmentSecretProvider()
|
|
: new EnvironmentSecretProvider();
|
|
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
|
provisioningOptions,
|
|
secretProvider,
|
|
DateTimeOffset.UtcNow);
|
|
builder.Services.AddSingleton(provisioning);
|
|
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<SessionBrowserCursorCodec>();
|
|
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
|
builder.Services.AddSingleton<SessionBrowserService>();
|
|
builder.Services.AddSingleton<SessionStreamService>();
|
|
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
|
builder.Services.AddSingleton<JoinAttemptService>();
|
|
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
|
builder.Services.AddSingleton<ConnectionOutcomeService>();
|
|
builder.Services.AddSingleton<OperatorService>();
|
|
builder.Services.AddSingleton(new ProvisioningReadiness(true));
|
|
}
|
|
|
|
builder.Services
|
|
.AddOptions<UdpMediatorOptions>()
|
|
.BindConfiguration(UdpMediatorOptions.SectionName)
|
|
.ValidateDataAnnotations()
|
|
.Validate(
|
|
options => IPAddress.TryParse(options.ListenAddress, out IPAddress? address)
|
|
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork,
|
|
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IPv4 address.")
|
|
.Validate(
|
|
options => string.IsNullOrWhiteSpace(options.Ipv6ListenAddress)
|
|
|| (IPAddress.TryParse(options.Ipv6ListenAddress, out IPAddress? address)
|
|
&& address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6),
|
|
$"{UdpMediatorOptions.SectionName}:Ipv6ListenAddress must be an IPv6 address when configured.")
|
|
.ValidateOnStart();
|
|
builder.Services.AddSingleton<UdpMediatorService>();
|
|
if (!isOpenApiGeneration)
|
|
{
|
|
builder.Services.AddSingleton<NatMediationProcessor>();
|
|
builder.Services.AddHostedService(static services =>
|
|
services.GetRequiredService<UdpMediatorService>());
|
|
// Hosted services stop in reverse registration order. Drain must complete while
|
|
// Kestrel and the UDP mediator are still able to finish bounded in-flight work.
|
|
builder.Services.AddHostedService<GracefulDrainService>();
|
|
}
|
|
|
|
WebApplication app = builder.Build();
|
|
|
|
if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection))
|
|
{
|
|
app.UseForwardedHeaders();
|
|
}
|
|
app.UseMiddleware<TelemetryMiddleware>();
|
|
app.UseExceptionHandler();
|
|
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
|
app.MapOpenApi();
|
|
app.MapRendezvousContractEndpoints();
|
|
app.MapOperatorEndpoints();
|
|
app.MapRendezvousHealthEndpoints();
|
|
app.MapDiagnosticDashboardEndpoints();
|
|
app.MapPrometheusMetricsEndpoint(metricsOptions, metricsCredential);
|
|
|
|
await app.RunAsync();
|
|
|
|
/// <summary>
|
|
/// Entry point marker used by integration-test hosts.
|
|
/// </summary>
|
|
public partial class Program;
|