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(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 securedOperations = new(StringComparer.Ordinal) { "RegisterSession", "RenewSessionLease", "UpdateSession", "DeleteSession", }; HashSet 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(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(static options => options.ThrowOnBadRequest = true); builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); builder.WebHost.ConfigureKestrel(static options => options.Limits.MaxRequestBodySize = ContractLimits.HttpRequestMaxBytes); builder.Services .AddOptions() .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(); builder.Services .AddOptions() .BindConfiguration(AuditOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart(); AbuseProtectionOptions configuredAbuseProtection = builder.Configuration .GetSection(AbuseProtectionOptions.SectionName) .Get() ?? new AbuseProtectionOptions(); builder.Services.Configure(options => TrustedProxyForwarding.Configure(options, configuredAbuseProtection)); DeploymentOptions deploymentOptions = builder.Configuration .GetSection(DeploymentOptions.SectionName) .Get() ?? new DeploymentOptions(); if (!builder.Environment.IsDevelopment() && !isOpenApiGeneration) { IReadOnlyList 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() ?? new DiagnosticDashboardOptions(); IReadOnlyList 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() ?? new PrometheusMetricsOptions(); IReadOnlyList 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(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(stateStore); builder.Services.AddSingleton(rendezvousClock); builder.Services.AddSingleton(rendezvousClock); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); if (isOpenApiGeneration) { builder.Services.AddSingleton(new ProvisioningReadiness(false)); } else { ProvisioningOptions provisioningOptions = builder.Configuration .GetSection(ProvisioningOptions.SectionName) .Get() ?? 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(sessionCapabilities); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(new ProvisioningReadiness(true)); } builder.Services .AddOptions() .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(); if (!isOpenApiGeneration) { builder.Services.AddSingleton(); builder.Services.AddHostedService(static services => services.GetRequiredService()); // 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(); } WebApplication app = builder.Build(); if (TrustedProxyForwarding.IsEnabled(configuredAbuseProtection)) { app.UseForwardedHeaders(); } app.UseMiddleware(); app.UseExceptionHandler(); app.UseMiddleware(); app.MapOpenApi(); app.MapRendezvousContractEndpoints(); app.MapOperatorEndpoints(); app.MapRendezvousHealthEndpoints(); app.MapDiagnosticDashboardEndpoints(); app.MapPrometheusMetricsEndpoint(metricsOptions, metricsCredential); await app.RunAsync(); /// /// Entry point marker used by integration-test hosts. /// public partial class Program;