f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
370 lines
18 KiB
C#
370 lines
18 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);
|
|
|
|
ApplyDefaultSecretsStorePath(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).
|
|
// The content root is passed explicitly because this container is a throwaway
|
|
// ServiceCollection with no IHostEnvironment in it. Without it the library cannot tell
|
|
// "no content root exists" from "no host is registered", so it skips the
|
|
// under-content-root rule — and the migrator below CREATES the store before the real host
|
|
// ever validates. The boot then fails a moment later, having already left an empty
|
|
// database with its -wal/-shm siblings at the very path the rule rejects. That artifact is
|
|
// what made the 2026-08-09 outage read as "the database is there, it's just empty".
|
|
// DO NOT simplify this to the 3-argument overload: it still compiles, the app still boots
|
|
// when the path is correct, and the guard silently stops running at the one moment that
|
|
// matters.
|
|
#pragma warning disable ASP0000 // deliberate throwaway container, disposed here, shares no singletons
|
|
using (var secretsProvider = new ServiceCollection()
|
|
.AddZbSecrets(builder.Configuration, "Secrets", builder.Environment.ContentRootPath)
|
|
.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 })
|
|
// Active, not Ready: a gateway holding no sessions is legitimately ready to serve.
|
|
// See SessionHealthCheck for why zero sessions is healthy.
|
|
.AddTypeActivatedCheck<SessionHealthCheck>(
|
|
"mxaccess-sessions",
|
|
failureStatus: null,
|
|
tags: new[] { ZbHealthTags.Active });
|
|
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.
|
|
// The provider depends on GatewayGrpcAuthorizationInterceptor (registered globally above)
|
|
// having pushed the ambient identity before the library's galaxy service runs. That
|
|
// ordering is still required, but it is no longer trusted silently: with
|
|
// Authentication:Mode=ApiKey the provider denies with PermissionDenied and logs the broken
|
|
// invariant when the identity is absent, instead of resolving to the full hierarchy.
|
|
// Under Mode=Disabled identity-absent is expected and stays unscoped.
|
|
builder.Services.AddSingleton<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider,
|
|
Security.Authorization.GatewayBrowseScopeProvider>();
|
|
|
|
// The Galaxy package binds GalaxyRepositoryOptions but ships no validator or default for the
|
|
// snapshot path (A2 handoff): the gateway owns both because it is the process that writes the
|
|
// snapshot. GalaxyRepositoryOptions.SnapshotCachePath is init-only, so the default cannot be
|
|
// applied via PostConfigure — supply it as a configuration value (before the bind) when the
|
|
// shipped config leaves it blank. It resolves to the per-OS CommonApplicationData location,
|
|
// byte-identical to the removed appsettings literal on Windows.
|
|
if (string.IsNullOrWhiteSpace(builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"]))
|
|
{
|
|
builder.Configuration["MxGateway:Galaxy:SnapshotCachePath"] = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
|
"MxGateway",
|
|
"galaxy-snapshot.json");
|
|
}
|
|
|
|
builder.Services.AddZbGalaxyRepository(builder.Configuration, "MxGateway:Galaxy");
|
|
|
|
// Validate that persistence has a valid, host-rooted snapshot path.
|
|
builder.Services.AddSingleton<
|
|
Microsoft.Extensions.Options.IValidateOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>,
|
|
Configuration.GalaxyRepositoryOptionsValidator>();
|
|
builder.Services.AddOptions<ZB.MOM.WW.GalaxyRepository.GalaxyRepositoryOptions>().ValidateOnStart();
|
|
|
|
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));
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Supplies the default location of the encrypted secrets store when nothing configured one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The store used to default to a bare relative <c>mxgateway-secrets.db</c>, which resolves
|
|
/// against the working directory and therefore normally lands inside the application directory.
|
|
/// That is the shape that lost every API key on a production host: the upgrade procedure renames
|
|
/// the application directory away, the store goes with it, and a fresh empty one appears in its
|
|
/// place with no error. In development the same default writes a database into the source tree.
|
|
/// </para>
|
|
/// <para>
|
|
/// This sets a default for an <em>unset</em> key; it never relocates a value someone configured.
|
|
/// That distinction matters — <see cref="Configuration.GatewayConfigPathRules"/> deliberately
|
|
/// rejects bad configured paths rather than quietly moving them, because silently relocating a
|
|
/// credential store is worse than a boot error. Choosing where to put a value nobody specified
|
|
/// is a different act from overriding one they did.
|
|
/// </para>
|
|
/// <para>
|
|
/// The location mirrors <c>AuthenticationOptions.SqlitePath</c> so both gateway stores sit
|
|
/// together, and the mechanism is the same mechanism already used for
|
|
/// <c>MxGateway:Galaxy:SnapshotCachePath</c> below — same problem, same fix, same file. It also
|
|
/// matches what <c>docs/GatewayConfiguration.md</c> already tells operators to
|
|
/// pass to the <c>secret</c> CLI — an absolute default also removes the CLI/gateway divergence
|
|
/// that a working-directory-relative path can cause. On non-Windows hosts
|
|
/// <see cref="Environment.SpecialFolder.CommonApplicationData"/> is typically not writable by a
|
|
/// normal user, so a local run there must set <c>Secrets__SqlitePath</c> explicitly, exactly as
|
|
/// it already must for the auth store.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>This deliberately differs from the <c>ZB.MOM.WW.Secrets</c> library default</b>, which is
|
|
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/>-derived so the family's
|
|
/// cross-platform apps still boot locally without an override. The gateway keeps
|
|
/// <c>CommonApplicationData</c> because it runs as a machine-wide Windows service and its other
|
|
/// two stores — the auth database and the Galaxy snapshot — already live there; splitting them
|
|
/// would be the greater inconsistency. The value set here always wins, so the library default is
|
|
/// unreachable in this app. Do not "fix" the difference by deleting this method: that would
|
|
/// silently move the store, which is the failure this whole rule exists to prevent.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="configuration">The configuration to supply the default into.</param>
|
|
private static void ApplyDefaultSecretsStorePath(IConfiguration configuration)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(configuration["Secrets:SqlitePath"]))
|
|
{
|
|
return;
|
|
}
|
|
|
|
configuration["Secrets:SqlitePath"] = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
|
"MxGateway",
|
|
"mxgateway-secrets.db");
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|