271 lines
12 KiB
C#
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;
|
|
}
|
|
}
|