Files
Rendezvous/src/FinalFactory.Rendezvous.Server/Program.cs
T
KyuubiYoru 99885f8c8c
quality-gate / quality (push) Failing after 1m31s
quality-gate / container (push) Has been skipped
feat(observability): add diagnostic dashboards (#27)
2026-07-17 00:22:46 +02:00

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;