Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs
T
Joseph Doherty e088dfabf7
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m12s
ci / java (push) Successful in 2m0s
ci / portable (push) Successful in 6m51s
fix(secrets): honor env-supplied LDAP bind password in live LDAP integration suite; clarify pre-host migration
2026-07-16 11:24:23 -04:00

271 lines
12 KiB
C#

using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Configuration;
using ZB.MOM.WW.GalaxyRepository.DependencyInjection;
using ZB.MOM.WW.Health;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Configuration;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
using ZB.MOM.WW.Secrets.Ui;
using ZB.MOM.WW.Telemetry;
using ZB.MOM.WW.Telemetry.Serilog;
namespace ZB.MOM.WW.MxGateway.Server;
/// <summary>
/// Configures and builds the gateway web application.
/// </summary>
public static class GatewayApplication
{
private const string StaticAssetsManifestFileName = "ZB.MOM.WW.MxGateway.Server.staticwebassets.endpoints.json";
/// <summary>
/// Builds a configured web application with all gateway services and middleware.
/// </summary>
/// <param name="args">Command-line arguments passed to the application.</param>
/// <returns>A configured web application ready to run.</returns>
public static WebApplication Build(string[] args)
{
WebApplicationBuilder builder = CreateBuilder(args);
WebApplication app = builder.Build();
app.UseGatewayRequestLoggingScope();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
// The rate limiter must run after routing has selected the endpoint (so its
// RequireRateLimiting policy metadata is visible) and before the endpoint executes.
app.UseRateLimiter();
app.UseAntiforgery();
app.MapGatewayEndpoints();
return app;
}
/// <summary>
/// Creates a web application builder configured with gateway services.
/// </summary>
/// <param name="args">Command-line arguments passed to the application.</param>
/// <returns>A configured web application builder.</returns>
public static WebApplicationBuilder CreateBuilder(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = args,
ContentRootPath = ResolveContentRootPath(),
});
StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration);
// Resolve ${secret:...} references in configuration BEFORE any config consumer (TLS, Kestrel,
// GatewayOptions/Ldap/Galaxy validators) reads a value, using a standalone secrets provider
// (envelope-decrypted via the master key). A token referencing a missing secret fails fast
// here (SecretNotFoundException); config with no tokens is untouched (no-op), so this is safe
// to always run. CreateBuilder is synchronous and single-shot at bootstrap, so the two awaits
// are driven via GetAwaiter().GetResult() (no sync-context deadlock risk during host startup).
#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
using (var secretsProvider = new ServiceCollection()
.AddZbSecrets(builder.Configuration, "Secrets")
.BuildServiceProvider())
#pragma warning restore ASP0000
{
// Intentionally unconditional: the pre-host expander below needs the secrets
// store schema to exist before the first ${secret:} resolve. Secrets:RunMigrationsOnStartup
// governs only the runtime SecretsMigrationHostedService, not this bootstrap path.
secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>()
.MigrateAsync(default).GetAwaiter().GetResult();
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
new SecretReferenceExpander(resolver)
.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, default)
.GetAwaiter().GetResult();
}
ConfigureSelfSignedTls(builder);
builder.AddZbSerilog(o => o.ServiceName = "mxgateway");
builder.Services.AddGatewayConfiguration(builder.Configuration);
builder.Services.AddZbSecrets(builder.Configuration, "Secrets");
builder.Services.AddSqliteAuthStore(builder.Configuration);
builder.Services.AddGatewayGrpcAuthorization();
AddLoginRateLimiter(builder);
builder.Services.AddHealthChecks()
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
"auth-store",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready });
builder.Services.AddSingleton<GatewayMetrics>();
builder.AddZbTelemetry(o =>
{
o.ServiceName = "mxgateway";
o.Meters = [GatewayMetrics.MeterName]; // "MxGateway.Server" — name unchanged
if (Enum.TryParse<ZbExporter>(builder.Configuration["MxGateway:Telemetry:Exporter"], ignoreCase: true, out var exporter))
o.Exporter = exporter;
var otlp = builder.Configuration["MxGateway:Telemetry:OtlpEndpoint"];
if (!string.IsNullOrWhiteSpace(otlp))
o.OtlpEndpoint = otlp;
});
builder.Services.AddSingleton<ILogRedactor, GatewayLogRedactorSeam>();
builder.Services.AddSingleton<MxAccessGrpcMapper>();
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
builder.Services.AddWorkerProcessLauncher();
builder.Services.AddGatewaySessions();
builder.Services.AddGatewayAlarms();
builder.Services.AddGatewayDashboard(builder.Configuration);
// Register the shared Secrets UI authorization policies (secrets:manage + secrets:reveal)
// additively so they compose with the dashboard's existing AddAuthorization block. The
// mounted /admin/secrets Blazor page carries [Authorize(Policy = "secrets:manage")].
builder.Services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
// Register the gateway's browse-scope provider before AddZbGalaxyRepository so the
// library's TryAddSingleton default (NullGalaxyBrowseScopeProvider) does not win.
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
Security.Authorization.GatewayBrowseScopeProvider>();
builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy");
return builder;
}
// Registers the named fixed-window rate-limiter policy applied to POST /auth/login. The
// limit knobs are read from MxGateway:Security at startup; the per-request partition is keyed on
// the remote IP (see DashboardEndpointRouteBuilderExtensions.GetLoginRateLimitPartition).
private static void AddLoginRateLimiter(WebApplicationBuilder builder)
{
SecurityOptions security =
builder.Configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>()
?? new SecurityOptions();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy(
DashboardEndpointRouteBuilderExtensions.LoginRateLimiterPolicy,
httpContext => DashboardEndpointRouteBuilderExtensions.GetLoginRateLimitPartition(
httpContext,
security));
});
}
private static void ConfigureSelfSignedTls(WebApplicationBuilder builder)
{
if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration))
{
return;
}
Configuration.TlsOptions tlsOptions =
builder.Configuration.GetSection("MxGateway:Tls").Get<Configuration.TlsOptions>()
?? new Configuration.TlsOptions();
using ILoggerFactory loggerFactory = LoggerFactory.Create(logging =>
{
logging.AddConfiguration(builder.Configuration.GetSection("Logging"));
logging.AddConsole();
});
Security.Tls.SelfSignedCertificateProvider provider = new(
tlsOptions,
loggerFactory.CreateLogger<Security.Tls.SelfSignedCertificateProvider>(),
TimeProvider.System);
X509Certificate2 certificate = provider.LoadOrCreate();
builder.WebHost.ConfigureKestrel(options =>
// The certificate is intentionally owned by Kestrel for the application
// lifetime; it is not disposed here.
options.ConfigureHttpsDefaults(https => https.ServerCertificate = certificate));
}
private static string ResolveContentRootPath()
{
string? configuredContentRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT");
if (!string.IsNullOrWhiteSpace(configuredContentRootPath)
&& IsServerContentRoot(configuredContentRootPath))
{
return configuredContentRootPath;
}
string currentDirectory = Environment.CurrentDirectory;
if (IsServerContentRoot(currentDirectory))
{
return currentDirectory;
}
string baseDirectory = AppContext.BaseDirectory;
if (IsServerContentRoot(baseDirectory))
{
return baseDirectory;
}
string? discoveredContentRootPath = DiscoverServerContentRoot(currentDirectory)
?? DiscoverServerContentRoot(baseDirectory);
return discoveredContentRootPath ?? baseDirectory;
}
private static string? DiscoverServerContentRoot(string startPath)
{
DirectoryInfo? directory = new(startPath);
while (directory is not null)
{
if (IsServerContentRoot(directory.FullName))
{
return directory.FullName;
}
string serverProjectPath = Path.Combine(directory.FullName, "src", "ZB.MOM.WW.MxGateway.Server");
if (IsServerContentRoot(serverProjectPath))
{
return serverProjectPath;
}
directory = directory.Parent;
}
return null;
}
private static bool IsServerContentRoot(string path)
{
return File.Exists(Path.Combine(path, "appsettings.json"))
&& Directory.Exists(Path.Combine(path, "wwwroot"));
}
/// <summary>
/// Maps gateway endpoints including gRPC services, health checks, and the dashboard.
/// </summary>
/// <param name="endpoints">Endpoint route builder to map endpoints to.</param>
/// <returns>The same endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder MapGatewayEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
endpoints.MapZbHealth();
endpoints.MapZbMetrics();
endpoints.MapGrpcService<MxAccessGatewayService>();
endpoints.MapZbGalaxyRepository();
endpoints.MapGatewayDashboard();
return endpoints;
}
private static string ResolveStaticAssetsManifestPath()
{
string manifestPath = Path.Combine(AppContext.BaseDirectory, StaticAssetsManifestFileName);
return File.Exists(manifestPath) ? manifestPath : StaticAssetsManifestFileName;
}
}