5fdd8a570a
0.6.0 put the store-path rules in the shared library, but the guard was not
running at the moment that matters here.
CreateBuilder resolves ${secret:} references before the host exists, using a
throwaway ServiceCollection that contains no IHostEnvironment — and it runs the
store migrator, which creates the database. The library resolved the content
root from IHostEnvironment alone, so it could not distinguish "no content root"
from "no host registered" and skipped the under-content-root rule entirely. The
store was created at the rejected path; the boot then failed a moment later when
the real host validated. The leftover empty database with its -wal/-shm siblings
is exactly the artifact that made the 2026-08-09 credential loss read as "the
database is there, it's just empty".
The pin alone does not close this. An app with a correctly configured path shows
no symptom and is still unprotected, because the guard simply is not running when
the store is created. 0.6.1 adds a 4-argument AddZbSecrets overload taking the
content root explicitly, and the call site has to use it. The in-host
registration below needs nothing.
Verified by removing the fix rather than by observing a clean boot — which is how
this survived its first release. With the 3-argument overload the new test fails
by finding a created database at
src/ZB.MOM.WW.MxGateway.Server/probe-secrets-*.db: inside the source tree, since
that is what the content root resolves to under test.
Two things about the test itself, both of which it would have been easy to get
subtly wrong:
It asserts no-file-created before asserting that startup threw. "It threw" is the
weaker claim, and asserting it first masks the stronger one — the run that proved
this defect would have reported "no exception was thrown" and said nothing about
the database sitting in the source tree.
The accepting case asserts the database *is* created, not merely that nothing
threw. A not-null builder is close to a tautology once no exception escaped, and
it would still pass if the pre-host container stopped opening the store at all —
which would also quietly void the rejecting case, since that one can only observe
a file the migration would otherwise have written. The two assertions hold each
other up.
Found by HistorianGateway's adoption, which probed the rejected paths instead of
observing a successful boot.
364 lines
18 KiB
C#
364 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.
|
|
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 (SEC-33).
|
|
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 (SEC-33).
|
|
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 one SEC-33 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;
|
|
}
|
|
}
|