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; /// /// Configures and builds the gateway web application. /// public static class GatewayApplication { private const string StaticAssetsManifestFileName = "ZB.MOM.WW.MxGateway.Server.staticwebassets.endpoints.json"; /// /// Builds a configured web application with all gateway services and middleware. /// /// Command-line arguments passed to the application. /// A configured web application ready to run. 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; } /// /// Creates a web application builder configured with gateway services. /// /// Command-line arguments passed to the application. /// A configured web application builder. 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() .MigrateAsync(default).GetAwaiter().GetResult(); var resolver = secretsProvider.GetRequiredService(); 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( "auth-store", failureStatus: null, tags: new[] { ZbHealthTags.Ready }); builder.Services.AddSingleton(); builder.AddZbTelemetry(o => { o.ServiceName = "mxgateway"; o.Meters = [GatewayMetrics.MeterName]; // "MxGateway.Server" — name unchanged if (Enum.TryParse(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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); 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(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(); 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() ?? 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() ?? 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(), 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")); } /// /// Maps gateway endpoints including gRPC services, health checks, and the dashboard. /// /// Endpoint route builder to map endpoints to. /// The same endpoint route builder for chaining. public static IEndpointRouteBuilder MapGatewayEndpoints(this IEndpointRouteBuilder endpoints) { endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath()); endpoints.MapZbHealth(); endpoints.MapZbMetrics(); endpoints.MapGrpcService(); endpoints.MapZbGalaxyRepository(); endpoints.MapGatewayDashboard(); return endpoints; } private static string ResolveStaticAssetsManifestPath() { string manifestPath = Path.Combine(AppContext.BaseDirectory, StaticAssetsManifestFileName); return File.Exists(manifestPath) ? manifestPath : StaticAssetsManifestFileName; } }