Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs
T
Joseph Doherty 00a804e6d9 guard(di): captive-dependency tripwires — scope validation + an actor field scan
Register row 50. Row 45 (fixed 9e769735) was a live captive dependency —
NotificationOutboxActor cached delivery adapters resolved from an actor-lifetime
IServiceScope, so MaxParallelDeliveries concurrent deliveries shared one EF
DbContext and central-b logged EF's "second operation was started on this context
instance" continuously. Actors take the ROOT IServiceProvider rather than typed
services, which is invisible to build-time analysis, so the bug class had no
mechanical guard at all. This adds both of the register's mitigations; neither
subsumes the other.

1. ValidateScopes + ValidateOnBuild in Development, on BOTH branches, via
   ServiceProviderValidation.Configure. WebApplication.CreateBuilder already sets
   both flags to IsDevelopment(), so on paper this restates a default — it is
   written out because that default is silent (a future move off CreateBuilder
   drops it with no diagnostic) and because this host carries three
   environment-ish signals (SCADABRIDGE_CONFIG, DOTNET_ENVIRONMENT,
   ASPNETCORE_ENVIRONMENT), so "is this node in Development" is not obvious at the
   call site. Production stays unvalidated deliberately: a validation failure is
   fail-closed, and CI now proves what a production node will not check.

   This surfaced two REAL latent violations on the SITE role — not captive
   dependencies but unconstructible descriptors, which fail ValidateOnBuild for
   the entire graph and are therefore what kept the site branch from being
   validatable at all (SiteHealthEndpointTests had pinned UseEnvironment
   ("Production") to work around exactly this):

     - ReconcileService (scoped, needs the central-only IDeploymentManagerRepository)
       moved out of the shared AddCommunication() into a new AddCentralCommunication().
     - AuditLogKpiSampleSource (scoped IKpiSampleSource, needs the central-only
       IAuditLogRepository) moved out of the shared AddAuditLog() into a new
       AddAuditLogCentralKpi().

   Both were registered on sites under the argument "harmless — only the central
   actor ever resolves it". True of the runtime, false of the container: a
   registration nothing can build is what stops the validator being usable, and it
   masks whatever real defect the validator would have found. Both are now
   central-only and SiteHealthEndpointTests boots the site branch validated again.

2. CaptiveDependencyGuard: a reflection scan over every actor class in the shipped
   assemblies (28 today; the reference graph is walked from the Host assembly
   because referenced assemblies load lazily). Fails on any instance field — auto-
   property backing fields included, they are ordinary private fields — typed as
   IServiceScope, as a DbContext, or as a service the REAL composition roots
   register Scoped. The scoped set is derived from the built Central and Site
   containers rather than hand-listed, so a lifetime change automatically re-scopes
   the check. IServiceProvider / IServiceScopeFactory fields are explicitly allowed:
   that IS the correct per-operation-scope pattern.

   No live violation exists — every actor opens a scope per operation. Two fields
   are allowlisted with their safety argument rather than exempted silently:
   AuditLogIngestActor._injectedRepository and SiteCallAuditActor._injectedRepository
   are scoped repository types assigned ONLY by explicitly test-mode constructors,
   null under production wiring (AkkaHostedService uses the IServiceProvider
   overload in both cases), and SiteCallAuditActor additionally wraps its injected
   instance in a SerializedRepository. The shape is one constructor call away from
   being row 45 again, so it is recorded, not dismissed.

Tests: 10 new. Six permanent self-tests of the guard, including positive controls
that reproduce the pre-row-45 shape (an actor caching a scoped service, an
IServiceScope, a DbContext, and a scoped auto-property) and a negative control for
the allowed root-provider pattern — a reflection guard that matches nothing passes
just as green as one that works. Plus an actor-count floor with cross-assembly
spot-checks, an allowlist-staleness test that fails any entry no longer matching a
flagged field, and the two per-role scans. Verified non-vacuous by emptying the
allowlist: the Central scan then names both real fields. Two composition-root
build tests force both validation flags on per role regardless of environment.

Doc: REQ-HOST-11 in Component-Host.md records all three rules and their guards.
2026-08-15 11:58:40 -04:00

888 lines
53 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.Health;
using ZB.MOM.WW.Health.Akka;
using ZB.MOM.WW.Health.EntityFrameworkCore;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.ScadaBridge.AuditLog;
using ZB.MOM.WW.ScadaBridge.CentralUI;
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Host;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
using ZB.MOM.WW.ScadaBridge.Host.Health;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
using ZB.MOM.WW.ScadaBridge.KpiHistory;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox;
using ZB.MOM.WW.ScadaBridge.NotificationService;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.SiteCallAudit;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Configuration;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
using ZB.MOM.WW.Secrets.Replicator.SqlServer.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;
using ZB.MOM.WW.Secrets.Ui;
using ZB.MOM.WW.Telemetry;
using Serilog;
// Terminate honestly on ANY unhandled exception, from any thread — registered before the
// first statement that can throw. In a container this process runs as PID 1, and Linux
// ignores default-action signals sent to PID 1, so the runtime's crash path (print the
// banner, then abort() → SIGABRT) can never actually kill the process: observed live, it
// printed the banner and then spun the main thread at 100% CPU with the container still
// `running`, so `restart: unless-stopped` never fired and every fail-closed boot refusal
// became a silent manual-restart outage (ScadaBridge#34, reproduced 2026-08-07).
// Environment.Exit uses the exit() syscall, which PID 1 CAN perform, and 134 preserves the
// 128+SIGABRT code the abort would have produced. This never fires under test hosts:
// WebApplicationFactory catches entry-point exceptions itself, so they are never
// "unhandled" there and the designed boot-refusal exceptions still propagate to tests.
// The docker compose additionally sets `init: true` for the crash paths that bypass this
// event entirely (Environment.FailFast, runtime-internal aborts).
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
// Print ourselves: exiting here preempts the runtime's own banner.
Console.Error.WriteLine(e.ExceptionObject);
try { Log.CloseAndFlush(); } catch { /* stderr above already has the exception */ }
Environment.Exit(134);
};
// SCADABRIDGE_CONFIG determines which role-specific config to load (Central or Site)
// DOTNET_ENVIRONMENT/ASPNETCORE_ENVIRONMENT stay as "Development" for dev tooling (static assets, EF migrations, etc.)
var scadabridgeConfig = Environment.GetEnvironmentVariable("SCADABRIDGE_CONFIG")
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? "Production";
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{scadabridgeConfig}.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args)
.Build();
// Expand ${secret:...} config references before any validator/binder sees them (Layer A).
// Throwaway provider — disposed here, shares no singletons with the host container.
//
// The expander must read the SAME store the running node will serve. On a central node in Grpc
// replication mode that store is the SHARED SQL-Server store (see SecretsRegistration,
// scadaproj#4) — an expander left on SQLite there would resolve pre-host ${secret:} references
// from a stale/empty local store, silently diverging from what the node's own hub serves. Every
// other case (sites, SqlServer mode, replication off) keeps the local SQLite path exactly as it
// always was. A blank or ${secret:}-valued connection string fails HERE, through the same
// EnsureCentralSharedStoreConnectionString check AddScadaBridgeSecrets runs — this is the
// earliest code that would otherwise hand the bad value to SqlConnection, whose generic
// "initialization string" format error would bury the designed message.
//
// Two consequences worth knowing. This block runs BEFORE Serilog exists and outside the
// try/catch below, so a SQL Server outage at central boot exits with a bare stderr stack
// trace, no structured log — honest and container-restart-retryable, just unenriched; the
// SQLite path had the same window but its only failure mode was a local file. (That exit
// is real only because of the UnhandledException handler at the top of this file — the
// runtime's own crash path wedges as container PID 1, ScadaBridge#34.) And the
// role check is deliberately NOT folded into SecretsRegistration: that class refuses to
// read the node role from configuration (a config-read role could silently turn a site
// into a hub — see SecretsNodeRole), while Program.cs must read it to branch the whole
// composition anyway, so the config read stays here at the composition root.
var expanderUsesSharedSqlStore =
string.Equals(
configuration["ScadaBridge:Node:Role"], "Central", StringComparison.OrdinalIgnoreCase)
&& SecretsRegistration.UsesGrpcHub(configuration);
// Secrets 0.6.2 validates Secrets:SqlitePath as absolute AND outside the content root, finding the
// content root from IHostEnvironment — which this throwaway ServiceCollection does not have, and
// which does not exist yet anyway (the host is not built until further down). Both the resolution
// and the registration therefore live in SecretsRegistration, where a test can enforce them; see
// AddPreHostSqliteExpander for why the content-root argument is load-bearing and silent when
// dropped, and PreHostSecretsContentRootTests for the regression guard.
var expanderServices = new ServiceCollection();
if (expanderUsesSharedSqlStore)
{
SecretsRegistration.EnsureCentralSharedStoreConnectionString(configuration);
expanderServices.AddZbSecretsSqlServerStore(configuration, "Secrets");
}
else
{
expanderServices.AddPreHostSqliteExpander(
configuration, SecretsRegistration.ResolveHostContentRoot());
}
#pragma warning disable ASP0000 // deliberate throwaway container
await using (var secretsProvider = expanderServices.BuildServiceProvider())
#pragma warning restore ASP0000
{
if (expanderUsesSharedSqlStore)
{
await secretsProvider.GetRequiredService<SqlServerSecretsStoreMigrator>()
.MigrateAsync(default);
}
else
{
await secretsProvider.GetRequiredService<SqliteSecretsStoreMigrator>()
.MigrateAsync(default);
}
var resolver = secretsProvider.GetRequiredService<ISecretResolver>();
await new SecretReferenceExpander(resolver)
.ExpandConfigurationAsync(configuration, default);
}
// Full startup validation — fail fast before any DI or actor system setup
StartupValidator.Validate(configuration);
// Read node options for Serilog enrichment
var nodeRole = configuration["ScadaBridge:Node:Role"]!;
var nodeHostname = configuration["ScadaBridge:Node:NodeHostname"] ?? "unknown";
var siteId = configuration["ScadaBridge:Node:SiteId"] ?? "central";
// Serilog structured logging.
// Minimum level is driven by ScadaBridge:Logging:MinimumLevel (LoggingOptions).
// Console and file sinks are defined in the `Serilog` configuration
// section (appsettings.json) and applied via ReadFrom.Configuration inside the
// factory — the sink set, output template, file path and rolling interval are all
// configuration-driven, not hard-coded here.
Log.Logger = ZB.MOM.WW.ScadaBridge.Host.LoggerConfigurationFactory
.Build(configuration, nodeRole, siteId, nodeHostname)
.CreateLogger();
try
{
Log.Information("Starting ScadaBridge host as {Role} on {Hostname}", nodeRole, nodeHostname);
if (nodeRole.Equals("Central", StringComparison.OrdinalIgnoreCase))
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddConfiguration(configuration);
// Captive-dependency validation (deferred register row 50). See the identical
// block in the Site branch below and ServiceProviderValidation for the full rationale.
builder.Host.UseDefaultServiceProvider(ServiceProviderValidation.Configure);
// Serilog
builder.Host.UseSerilog();
// Windows Service support (no-op when not running as a Windows Service)
builder.Host.UseWindowsService();
// Explicit Kestrel h2c listener for the central-hosted gRPC control plane
// (CentralControlService): HTTP/2-only, on its own port (default 8083, symmetric
// with the site GrpcPort — a node is either central or a site, never both). gRPC
// does NOT go through Traefik (HTTP/1 only); sites reach this port by container name.
//
// WARNING — the trap the rig caught (T1A.2 shipped it, fixed here): calling
// options.Listen*/ListenAnyIP puts Kestrel into EXPLICIT-ENDPOINTS mode, which
// SUPPRESSES the URLs from ASPNETCORE_URLS/--urls entirely — it is NOT additive.
// Central's ENTIRE HTTP/1 surface (Central UI, Management + Inbound API, and the
// /health/* endpoints Traefik + IActiveNodeGate depend on) lives on that URL
// (http://+:5000 on the rig, a different port in production). If we bind only the
// gRPC port, :5000 vanishes and central is reachable over gRPC while the UI, the
// management API and health checks are all dead — with no startup error. Unit tests
// use TestServer and never bind real Kestrel, so only a live node exposes this.
// The site branch has the same ConfigureKestrel shape but no ASPNETCORE_URLS surface
// to lose (it binds every port it needs — gRPC + metrics — explicitly). Central must
// therefore RE-BIND its HTTP port(s) here alongside the gRPC port.
var centralGrpcPort = configuration.GetValue<int>("ScadaBridge:Node:CentralGrpcPort", 8083);
// "urls" is WebHostDefaults.ServerUrlsKey — the host setting ASPNETCORE_URLS/--urls
// populate. Read it as a literal so this needs no extra Hosting using.
var httpUrls = configuration["ASPNETCORE_URLS"]
?? builder.WebHost.GetSetting("urls")
?? "http://+:5000";
var httpPorts = ParseHttpBindPorts(httpUrls);
builder.WebHost.ConfigureKestrel(options =>
{
// The HTTP/1.1 (+ HTTP/2) surface from the configured URLs, re-declared so it
// survives the explicit-endpoints switch above.
foreach (var httpPort in httpPorts)
{
options.ListenAnyIP(httpPort, listenOptions =>
{
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2;
});
}
// The gRPC control plane, HTTP/2 h2c only, on its own port.
options.ListenAnyIP(centralGrpcPort, listenOptions =>
{
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
});
});
// Shared components
builder.Services.AddClusterInfrastructure();
builder.Services.AddCommunication();
// Central-only communication services (ReconcileService — scoped, holds the
// central-only IDeploymentManagerRepository). Split out of AddCommunication by
// deferred register row 50: registering it on a site node produced an
// unconstructible descriptor that failed ValidateOnBuild for the whole site graph.
builder.Services.AddCentralCommunication();
// Per-site gRPC preshared keys. Central-only: it is the side that dials sites, and
// the only side whose key set is dynamic (sites come from the configuration
// database, so there is no fixed list of ${secret:} references to expand at boot —
// hence a runtime resolver rather than the pre-host SecretReferenceExpander a site
// node uses for its single key). Registered before the clients that consume it.
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.Communication.Grpc.ISitePskProvider, SitePskProvider>();
// Central-hosted gRPC control plane (T1A.2). CentralControlAuthInterceptor is the
// per-site sibling of the site's ControlPlaneAuthInterceptor: it verifies the Bearer
// token against the key for the site named in the required x-scadabridge-site header,
// resolved through the ISitePskProvider registered just above. Registered BY TYPE on
// AddGrpc exactly as the site branch registers its interceptors — NOT as a DI singleton,
// which would let DI hand the instance back and bypass Grpc.AspNetCore's own activation
// path (the shape that once hid a two-public-constructor defect until the rig caught it).
// CentralControlGrpcService decodes each request onto the same in-process message the
// removed ClusterClient path used to carry, and Asks CentralCommunicationActor — zero handler logic here.
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<CentralControlAuthInterceptor>();
});
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlGrpcService>();
// Shared per-site gRPC channel-pair provider (sticky failover/failback) backing the
// GrpcSiteTransport when ScadaBridge:Communication:SiteTransport=Grpc. Central-only, and a
// no-op until CentralCommunicationActor builds the gRPC transport (its failback loop starts
// on first use), so registering it unconditionally is harmless under the default Akka path.
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitePairChannelProvider>();
builder.Services.AddHealthMonitoring();
builder.Services.AddCentralHealthAggregation();
builder.Services.AddExternalSystemGateway();
builder.Services.AddNotificationService();
// Central-only components
// Notification Outbox: central owns SMTP delivery; the Email adapter reuses the
// AddNotificationService() SMTP machinery above. AddNotificationOutbox binds
// NotificationOutboxOptions via BindConfiguration, so no explicit Configure is needed.
builder.Services.AddNotificationOutbox();
// Transport — central-only bundle export/import pipeline. Binds
// TransportOptions from ScadaBridge:Transport via BindConfiguration; no
// explicit Configure needed.
builder.Services.AddTransport();
// Script-artifact invalidation bus: in-process, per-node pub/sub
// for ScriptArtifactsChanged. The Transport bundle importer publishes
// post-commit; the Inbound API's ScriptArtifactChangeSubscriber (a hosted
// service registered by AddInboundAPI below) consumes ApiMethod notifications
// and invalidates the compiled-handler cache (wired in plan R2-06 T1; the
// per-request revision check remains the correctness fallback).
// SharedScript/Template notifications currently have NO consumer — nothing at
// central caches compiled artifacts of those kinds. Central-only.
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting.IScriptArtifactChangeBus,
ZB.MOM.WW.ScadaBridge.Host.Services.InProcessScriptArtifactChangeBus>();
// Audit Log — central node owns the AuditLogIngestActor singleton +
// IAuditLogRepository. The site writer chain is still registered (lazy
// singletons) but is never resolved on a central node.
builder.Services.AddAuditLog(builder.Configuration);
// Central-only hosted service that rolls
// pf_AuditLog_Month forward monthly. Depends on IPartitionMaintenance
// (registered below by AddConfigurationDatabase).
builder.Services.AddAuditLogCentralMaintenance(builder.Configuration);
// Central-only registration backing the two
// maintenance singletons started in AkkaHostedService: the production
// ISiteEnumerator + IPullAuditEventsClient (gRPC) used by the
// SiteAuditReconciliationActor, plus the AuditLogPurgeOptions /
// SiteAuditReconciliationOptions bindings consumed by both singletons.
// Central-only by design (it dials sites), kept out of AddAuditLog.
builder.Services.AddAuditLogCentralReconciliationClient(builder.Configuration);
// Site Call Audit — central node owns the SiteCallAuditActor
// singleton. The extension itself currently registers
// nothing — actor Props are constructed inline in AkkaHostedService —
// but the call is here for symmetry with the other audit composition
// roots so future per-actor DI lands without touching Program.cs.
builder.Services.AddSiteCallAudit();
// KPI History — central-only. Binds KpiHistoryOptions from
// ScadaBridge:KpiHistory and registers the validated options consumed by
// the KpiHistoryRecorderActor cluster singleton (started in
// AkkaHostedService). Observability/best-effort: NOT readiness-gated.
builder.Services.AddKpiHistory(builder.Configuration);
// The Audit Log's IKpiSampleSource, enumerated by the recorder registered just
// above. Central-only (its IAuditLogRepository is central-only); split out of
// AddAuditLog by deferred register row 50 for the same reason as
// AddCentralCommunication above.
builder.Services.AddAuditLogCentralKpi();
builder.Services.AddTemplateEngine();
builder.Services.AddDeploymentManager();
// Host is the composition root and owns config-coupled wiring: register the
// shared LDAP auth (binds LdapOptions + IValidateOptions<LdapOptions> with
// ValidateOnStart + ILdapAuthService singleton) here, then AddSecurity() for the
// config-free remainder. AddSecurity is a component library and takes no
// IConfiguration (Options pattern only). Behaviour-preserving: identical
// registrations to the previous AddSecurity(builder.Configuration) call.
builder.Services.AddZbLdapAuth(
builder.Configuration,
ZB.MOM.WW.ScadaBridge.Security.ServiceCollectionExtensions.LdapSectionPath);
// Dev disable-login flag (config-coupled, so read + bound here at the composition root,
// mirroring AddZbLdapAuth). Default false. See AuthDisableLoginOptions / disable-login design doc.
builder.Services.AddOptions<ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions>()
.Bind(builder.Configuration.GetSection(
ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName));
var disableLogin = builder.Configuration
.GetSection(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName)
.GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.DisableLogin));
// Fail-fast guard: DisableLogin outside Development requires the explicit
// AllowOutsideDevelopment acknowledgement key. See DisableLoginGuard.
ZB.MOM.WW.ScadaBridge.Security.Auth.DisableLoginGuard.Validate(
disableLogin,
builder.Environment.EnvironmentName,
builder.Configuration
.GetSection(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName)
.GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.AllowOutsideDevelopment)));
builder.Services.AddSecurity(disableLogin);
builder.Services.AddCentralUI();
// Local SQLite store by default. With Secrets:Replication:Enabled true it becomes either a
// local store synced against a shared SQL-Server hub (Mode=SqlServer, and only when a hub
// connection string is present) or — the production topology, scadaproj#3 — the HOST half
// of the pull-only gRPC secrets hub (Mode=Grpc), which central serves and sites sweep.
// The Central role never registers the sweep client; passing the role explicitly is what
// makes that a compile-time property of this composition root rather than a config guess.
// See SecretsRegistration for why the SQL gate exists (eager options validation), why the
// hub credential can never itself come from the hub, and why gRPC mode fails closed.
builder.Services.AddScadaBridgeSecrets(builder.Configuration, SecretsNodeRole.Central);
// Secrets UI authorization: adds the named policies secrets:manage + secrets:reveal
// (role-based) consumed by the /admin/secrets page. AddSecretsAuthorization only ADDS
// these two policies via Configure<AuthorizationOptions> — it composes additively with
// AddScadaBridgeAuthorization (invoked inside AddSecurity above) without clobbering.
// The library's AdminRole ("Administrator") satisfies both policies, and ScadaBridge
// builds identities with RoleClaimType = ZbClaimTypes.Role, so an Administrator is
// authorized for both manage + reveal. Done Host-side to keep Secrets.Ui out of Security.
builder.Services.Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization());
// Secrets.Ui components audit through the SHARED ZB.MOM.WW.Audit.IAuditWriter seam,
// which is deliberately distinct from the repo's own Commons IAuditWriter — bridge it
// to the central direct-write path (ICentralAuditWriter → dbo.AuditLog), NOT the site
// SQLite chain, which is never resolved on central and has no forwarder here
// (ScadaBridge#22; component activation 500'd without this registration).
builder.Services.AddSingleton<ZB.MOM.WW.Audit.IAuditWriter>(
sp => new ZB.MOM.WW.ScadaBridge.Host.Services.CentralSharedSeamAuditWriter(
sp.GetRequiredService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ICentralAuditWriter>()));
builder.Services.AddInboundAPI();
// Inbound-API auth re-arch: the shared ZB.MOM.WW.Auth.ApiKeys verifier +
// SQLite store + startup migration are now the SOLE inbound-API auth path.
// The POST /api/{methodName} endpoint authenticates Bearer tokens
// (sbk_<keyId>_<secret>) and authorizes by scope == method name through this
// verifier. The legacy peppered-HMAC X-API-Key path — the SQL Server ApiKey
// entity, ApiKeyValidator, and IApiKeyHasher — was retired; the
// ScadaBridge:InboundApi:ApiKeyPepper config key is now consumed only as the
// library verifier's pepper secret (PepperSecretName below).
//
// ApiKeyOptions is an init-only record, so the contract-mandated values
// are injected as in-memory configuration UNDER the bound section path
// (ScadaBridge:InboundApi:ApiKeyStore) rather than mutated post-bind:
// - TokenPrefix = "sbk" (the inbound token prefix)
// - PepperSecretName points at the EXISTING inbound-API pepper config key
// (reused so one secret backs both the legacy and new path during the
// additive window)
// - RunMigrationsOnStartup = true (hosted service creates the schema)
// - SqlitePath defaults under the content root's data/ directory, but any
// value already supplied via appsettings/env wins (AddInMemoryCollection
// is registered last, but only fills keys the operator did not set
// because we read the existing value first).
const string apiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore";
var configuredSqlitePath = builder.Configuration[$"{apiKeyStoreSection}:SqlitePath"];
var apiKeyStoreDefaults = new Dictionary<string, string?>
{
[$"{apiKeyStoreSection}:TokenPrefix"] = "sbk",
[$"{apiKeyStoreSection}:PepperSecretName"] = "ScadaBridge:InboundApi:ApiKeyPepper",
[$"{apiKeyStoreSection}:RunMigrationsOnStartup"] = "true",
[$"{apiKeyStoreSection}:SqlitePath"] = string.IsNullOrWhiteSpace(configuredSqlitePath)
? Path.Combine(builder.Environment.ContentRootPath, "data", "inbound-api-keys.sqlite")
: configuredSqlitePath,
};
builder.Configuration.AddInMemoryCollection(apiKeyStoreDefaults);
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
// Inbound-API key re-arch, additive: expose the library admin facade
// (ApiKeyAdminCommands) and the app-side management seam (IInboundApiKeyAdmin)
// in the SAME container as AddZbApiKeyAuth, so CLI + CentralUI later create /
// list / enable / disable / delete inbound keys and edit their method-scopes
// through one shared path. AddZbApiKeyAuth registers the stores/pepper/migrator
// but NOT ApiKeyAdminCommands itself, so it is composed here. CentralUI resolves
// from this same provider (it is registered via AddCentralUI() above), so the
// seam is reachable from both the ManagementActor and CentralUI pages — exactly
// as IInboundApiRepository already is.
builder.Services.AddSingleton(sp => new ZB.MOM.WW.Auth.ApiKeys.Admin.ApiKeyAdminCommands(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyOptions>>().Value,
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAdminStore>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAuditStore>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.IApiKeyPepperProvider>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>(),
TimeProvider.System));
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security.IInboundApiKeyAdmin,
LibraryInboundApiKeyAdmin>();
builder.Services.AddManagementService();
var configDbConnectionString = configuration["ScadaBridge:Database:ConfigurationDb"]
?? throw new InvalidOperationException("ScadaBridge:Database:ConfigurationDb connection string is required for Central role.");
builder.Services.AddConfigurationDatabase(configDbConnectionString);
// Health checks for readiness gating — shared ZB.MOM.WW.Health probes.
// Ready tier (/health/ready): database + akka-cluster + required-singletons.
// Active tier (/health/active): active-node = oldest Up member (singleton host) AND
// database reachable (review 01 [High]) — a DB-dead active node drops from Traefik
// rotation. The active-node check is the shared ActiveNodeHealthCheck, which selects by age
// as of Health 0.3.0; through 0.2.1 it selected by leadership, which diverges from singleton
// placement after a restart and names both sides during a partition, so this host kept a
// private copy until the shared one was fixed.
// The Akka checks resolve ActorSystem from DI via the singleton bridge registered below;
// the DatabaseHealthCheck<TContext> resolves a scoped ScadaBridgeDbContext (no factory).
builder.Services.AddHealthChecks()
.AddTypeActivatedCheck<DatabaseHealthCheck<ScadaBridgeDbContext>>(
"database",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active })
.AddTypeActivatedCheck<AkkaClusterHealthCheck>(
"akka-cluster",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready },
args: AkkaClusterStatusPolicy.Default)
// Readiness ALSO reflects "required cluster singletons running"
// (REQ-HOST-4a). Probes each central singleton's local ClusterSingletonProxy
// with a bounded Identify and degrades to Unhealthy if any required singleton
// is unreachable. Registered inside the Central-role branch (this is it) so the
// check is naturally role-scoped — site nodes never run it. It resolves
// ActorSystem from DI per probe, like the akka-cluster check above, and is
// leadership-agnostic so a ready standby still reports ready (the proxy reaches
// the singleton from either node).
.AddTypeActivatedCheck<RequiredSingletonsHealthCheck>(
"required-singletons",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready })
// Oldest-Up-member check (review 01 [High]), unscoped: every central member competes for
// the one active slot. Since ZB.MOM.WW.Health 0.3.0 this IS the shared check — it selects
// by age, so the host-local OldestNodeActiveHealthCheck that existed only to avoid the old
// leader-based selection is gone. Resolves ActorSystem from DI, like the akka-cluster
// check above.
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
"active-node",
failureStatus: null,
tags: new[] { ZbHealthTags.Active });
// Akka.NET bootstrap via hosted service
builder.Services.AddSingleton<AkkaHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// Bridge the AkkaHostedService-owned ActorSystem to DI as a SINGLETON via
// GetOrCreateActorSystem(). The shared ZB.MOM.WW.Health Akka checks resolve ActorSystem
// from DI, per probe, inside a child scope. ActorSystem is IDisposable, so a TRANSIENT
// (or scoped) bridge is captured-and-disposed by each probe's scope — disposing the live
// system mid-flight (CoordinatedShutdown/ActorSystemTerminateReason) and wedging the
// central report pages at the 30s Ask timeout. A singleton is resolved from the root and
// never disposed by a child scope; routing through GetOrCreateActorSystem (instead of a
// plain singleton factory over .ActorSystem) means the first resolve CREATES the system
// rather than caching a null if a probe wins the startup race.
builder.Services.AddSingleton<Akka.Actor.ActorSystem>(sp =>
sp.GetRequiredService<AkkaHostedService>().GetOrCreateActorSystem());
// Simultaneous-cold-start split-brain guard (Gitea #33, dark switch
// ScadaBridge:Cluster:BootstrapGuard:Enabled, default off). Registered AFTER the
// AkkaHostedService/ActorSystem bridge so its StartAsync resolves the system the main path
// built; when the guard is off it no-ops (Akka auto-joined from config seeds), so registering
// it unconditionally is safe. When on, the node started unjoined (empty HOCON seed list, see
// AkkaHostedService.BuildHocon) and this coordinator issues the single reachability-gated
// JoinSeedNodes. It resolves the ActorSystem lazily via GetOrCreateActorSystem so it never
// races startup or caches a null.
builder.Services.AddHostedService(sp => new ClusterBootstrapCoordinator(
() => sp.GetRequiredService<AkkaHostedService>().GetOrCreateActorSystem(),
sp.GetRequiredService<IOptions<ClusterOptions>>(),
sp.GetRequiredService<IOptions<NodeOptions>>(),
sp.GetRequiredService<ILogger<ClusterBootstrapCoordinator>>()));
// Register the production IActiveNodeGate implementation so
// standby-node gating is actually enforced (the InboundApiEndpointFilter
// consults IActiveNodeGate and defaults to "allow" when none is registered,
// which leaves the design's "central cluster only (active node)" guarantee
// unenforced in deployed binaries). The gate is backed by the same oldest-member
// evaluator (ClusterActivityEvaluator, which delegates to the shared ClusterActiveNode) as
// the active-node check above, so the inbound API and the /health/active endpoint Traefik
// routes against agree on which node is active.
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.InboundAPI.IActiveNodeGate, ActiveNodeGate>();
// Admin-triggered manual failover of the central pair (Health page control,
// decision 2026-07-22). Central-only: the seam is declared in CentralUI so that
// project stays Akka-free, and only this branch has a cluster to act on.
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.CentralUI.Services.IManualFailoverService, AkkaManualFailoverService>();
// Cluster node status provider scoped to the Central role — feeds the
// CentralHealthReportLoop so the central cluster appears on /monitoring/health.
builder.Services.AddSingleton<IClusterNodeProvider>(sp =>
{
var akkaService = sp.GetRequiredService<AkkaHostedService>();
return new AkkaClusterNodeProvider(akkaService, "Central");
});
// Options binding
SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration);
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaBridge:Security"));
builder.Services.AddOptions<InboundApiOptions>()
.Bind(builder.Configuration.GetSection("ScadaBridge:InboundApi"))
.ValidateOnStart();
builder.Services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<InboundApiOptions>, InboundApiOptionsValidator>());
builder.Services.Configure<DeploymentManagerOptions>(
builder.Configuration.GetSection(ZB.MOM.WW.ScadaBridge.DeploymentManager.ServiceCollectionExtensions.OptionsSection));
var app = builder.Build();
// Apply or validate database migrations (skip when running in test harness)
if (!string.Equals(configuration["ScadaBridge:Database:SkipMigrations"], "true", StringComparison.OrdinalIgnoreCase))
{
var isDevelopment = app.Environment.IsDevelopment()
|| string.Equals(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), "Development", StringComparison.OrdinalIgnoreCase);
var migrationLogger = app.Services
.GetRequiredService<ILoggerFactory>()
.CreateLogger(typeof(MigrationHelper).FullName!);
// Tolerate a database that is briefly unreachable at boot
// (e.g. app and DB containers starting together) with a bounded
// exponential backoff before failing fatally.
// Only connection-class (transient) faults are retried — a
// schema-version mismatch is permanent and must fail fast on attempt 1.
// Thread the host's ApplicationStopping token into both the
// migration call itself and the inter-attempt Task.Delay so a SIGTERM
// during the bounded-retry window (~2 min worst-case) tears down
// cleanly instead of being ignored until the loop exhausts.
await StartupRetry.ExecuteWithRetryAsync(
"database-migration",
async ct =>
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
await MigrationHelper.ApplyOrValidateMigrationsAsync(dbContext, isDevelopment, migrationLogger, ct);
},
maxAttempts: 8,
initialDelay: TimeSpan.FromSeconds(2),
migrationLogger,
isTransient: StartupRetry.IsTransientDatabaseFault,
cancellationToken: app.Lifetime.ApplicationStopping);
}
// Middleware pipeline
// Trusted-proxy forwarded headers (arch-review R2 N2): behind Traefik every client
// shares the proxy's IP, collapsing the LoginThrottle's {username}|{ip} keys onto
// one address (per-IP isolation gone + cheap username-lockout DoS). When enabled,
// X-Forwarded-For from the CONFIGURED proxies only is honored. MUST run first —
// everything downstream keys on Connection.RemoteIpAddress.
var forwardedOptions = builder.Configuration
.GetSection(ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions.SectionName)
.Get<ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions>() ?? new();
if (ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSetup.Build(forwardedOptions) is { } fho)
app.UseForwardedHeaders(fho);
app.UseWebSockets();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
// Emit one InboundRequest/InboundAuthFailure
// audit row per call into the inbound API. Placed AFTER UseAuthentication/
// UseAuthorization so any HttpContext.User the framework populates is in
// place, and scoped to the /api/ prefix so it never observes the Central UI,
// Management API, SignalR hubs, or health endpoints. The endpoint handler
// is responsible for stashing the resolved API key name on
// HttpContext.Items (see AuditWriteMiddleware.AuditActorItemKey) AFTER its
// in-handler API key validation succeeds.
// Scope the audit middleware to the inbound API method
// route (/api/{methodName}) and explicitly exclude the management/audit
// sub-trees that share the /api prefix. Without these exclusions the
// middleware would emit a spurious ApiInbound audit row for every
// /api/audit/query and /api/audit/export call (and would treat audit-log
// reads as inbound script invocations — recursive write-on-read). The
// POST-only filter rules out the GET routes on /api/audit, /api/centralui,
// /api/script-analysis even if a future route is added under those
// prefixes with the same verb; the explicit prefix excludes still belt-
// and-brace POST-y additions there.
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/api")
&& !ctx.Request.Path.StartsWithSegments("/api/audit")
&& !ctx.Request.Path.StartsWithSegments("/api/centralui")
&& !ctx.Request.Path.StartsWithSegments("/api/script-analysis")
&& !ctx.Request.Path.StartsWithSegments("/api/management")
&& HttpMethods.IsPost(ctx.Request.Method),
branch => branch.UseAuditWriteMiddleware());
// Map the canonical three-tier health endpoints in one call:
// /health/ready — Ready-tagged checks (database + akka-cluster + required-singletons).
// REQ-HOST-4a defines readiness as cluster membership + DB connectivity,
// explicitly NOT active-node status, so the active-node check is excluded
// (a fully operational standby central node still reports ready).
// /health/active — Active-tagged checks (active-node = oldest Up member + database
// reachable); returns 200 only on the singleton-host node with a live
// DB; used by Traefik for routing.
// /healthz — bare process liveness; runs no checks (always 200 while the process
// is up). New tier added by adopting the shared library.
// All three are anonymous and use the canonical ZbHealthWriter JSON output.
app.MapZbHealth();
// Observability — mount the always-on Prometheus /metrics scrape endpoint.
// AddZbTelemetry (in SiteServiceRegistration.BindSharedOptions) wires the OTel
// Resource + standard instrumentation + Prometheus exporter; this exposes them.
// Requires endpoint routing (app.UseRouting() above).
app.MapZbMetrics();
// Central-hosted gRPC control plane (T1A.2) — the site→central CentralControlService.
// Runs on the dedicated h2c listener configured above (default :8083), gated by
// CentralControlAuthInterceptor and readiness-gated by the service itself (Unavailable
// until AkkaHostedService hands the CentralCommunicationActor over via SetReady). It
// shares the app's endpoint routing with the HTTP/1 surface; Kestrel steers each
// connection to the right pipeline by listener/protocol.
app.MapGrpcService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlGrpcService>();
// The pull-only secrets hub (scadaproj#3), on the SAME h2c listener and the same addressing
// convention — a site dials central's CentralGrpcPort, never central's database. It shares
// the listener with CentralControlService the way the site's LocalDb sync endpoint shares
// its one: two disjoint service prefixes, two independent fail-closed gates. The
// CentralControlAuthInterceptor registered on AddGrpc is prefix-scoped to
// CentralControlService and passes hub calls straight through; the hub's own
// SecretsHubAuthInterceptor — attached per-service by AddZbSecretsGrpcHub — is what gates
// it, on the shared Secrets:GrpcHub:BearerToken.
//
// A NO-OP unless this node registered the hub. That is not an optimisation: mapping the
// service without registering it would map a hub whose interceptor was never attached,
// i.e. an unauthenticated endpoint serving every secret central holds. Both this call and
// the registration above route through SecretsRegistration.UsesGrpcHub so they cannot
// drift apart.
app.MapScadaBridgeSecretsHub(app.Configuration);
app.MapStaticAssets();
app.MapCentralUI<ZB.MOM.WW.ScadaBridge.Host.Components.App>();
app.MapInboundAPI();
app.MapManagementAPI();
// Audit Log: CLI-facing /api/audit/{query,export} routes. Same
// Basic-Auth + LDAP mechanism as /management; gated on the OperationalAudit
// / AuditExport role sets.
app.MapAuditAPI();
// Notify-and-fetch deploy: site-facing token-gated fetch of a staged
// deployment's flattened config. Machine-to-machine — AllowAnonymous, gated
// solely by the per-deployment X-Deployment-Token (no central FallbackPolicy).
app.MapDeploymentConfigAPI();
app.MapHub<ZB.MOM.WW.ScadaBridge.ManagementService.DebugStreamHub>("/hubs/debug-stream");
// Compile and register all Inbound API method scripts at startup
using (var scope = app.Services.CreateScope())
{
var apiRepo = scope.ServiceProvider.GetRequiredService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.IInboundApiRepository>();
var executor = app.Services.GetRequiredService<ZB.MOM.WW.ScadaBridge.InboundAPI.InboundScriptExecutor>();
var methods = await apiRepo.GetAllApiMethodsAsync();
// Parallel: CompileAndRegister is safe for concurrent callers (ConcurrentDictionary
// caches, no shared mutable compile state); serial Roslyn compiles stretch the
// failover-recovery window at hundreds of methods.
Parallel.ForEach(methods, method => executor.CompileAndRegister(method));
}
await app.RunAsync();
}
else if (nodeRole.Equals("Site", StringComparison.OrdinalIgnoreCase))
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddConfiguration(configuration);
// Captive-dependency validation (deferred register row 50) — the SITE half of the
// pair. This branch is the one that actually gains coverage: the docker rig sets
// ASPNETCORE_ENVIRONMENT=Development on the central containers but NOT on the site
// containers, so a site node has always built its provider unvalidated.
builder.Host.UseDefaultServiceProvider(ServiceProviderValidation.Configure);
// Serilog
builder.Host.UseSerilog();
// Windows Service support (no-op when not running as a Windows Service)
builder.Host.UseWindowsService();
// Read GrpcPort from config (NodeOptions already has default 8083)
var grpcPort = configuration.GetValue<int>("ScadaBridge:Node:GrpcPort", 8083);
// Read MetricsPort from config (NodeOptions already has default 8084).
// Separate HTTP/1.1 listener so a standard HTTP/1.1 Prometheus scraper can
// reach /metrics; the gRPC port stays HTTP/2-only below. The default is
// 8084 — distinct from RemotingPort (8082, Akka) and GrpcPort (8083) so the
// metrics listener never collides with the Akka remoting port on site nodes.
var metricsPort = configuration.GetValue<int>("ScadaBridge:Node:MetricsPort", 8084);
// Configure Kestrel for HTTP/2 only on the gRPC port
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(grpcPort, listenOptions =>
{
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
});
// Dedicated HTTP/1.1 (and HTTP/2) listener for the Prometheus /metrics
// scrape endpoint, reachable by an HTTP/1.1 scraper.
options.ListenAnyIP(metricsPort, listenOptions =>
{
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2;
});
});
// gRPC server registration. Two interceptors, two disjoint service prefixes, two
// separate keys — neither one's absence weakens the other:
//
// LocalDbSyncAuthInterceptor gates /localdb_sync.v1.LocalDbSync/ (LocalDb:Replication:ApiKey)
// ControlPlaneAuthInterceptor gates /sitestream.SiteStreamService/ (ScadaBridge:Communication:GrpcPsk)
//
// Both are fail-closed: an unset key closes that surface rather than opening it. For
// LocalDb that means replication simply does not start; for the control plane it means
// this node serves no streams and no audit pulls until a key is configured, which is
// why every environment must carry one before running this build.
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<LocalDbSyncAuthInterceptor>();
options.Interceptors.Add<ControlPlaneAuthInterceptor>();
});
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
// Site command plane (central→site) — the gRPC peer of SiteStreamGrpcServer. Both share
// the h2c listener and the ControlPlaneAuthInterceptor PSK gate; both are readiness-gated
// (SetReady, below).
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCommandGrpcService>();
// Existing site service registrations (this is also where LocalDb and its
// replication engine are registered — see SiteServiceRegistration)
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
var app = builder.Build();
// Endpoint routing middleware. The gRPC service mapping below and the
// /metrics scrape endpoint both run on endpoint routing, so UseRouting()
// must be present before the Map* calls on the site role.
app.UseRouting();
// Map the canonical three-tier health endpoints on the site node:
// /health/ready — akka-cluster (site-pair membership, and the cluster-view data the
// family overview dashboard reads) + localdb (the consolidated site
// store — the only database a site node has).
// /health/active — active-node = this site pair's primary; 200 on the primary, 503 on
// the standby, the same contract central publishes.
// /healthz — bare process liveness.
// Checks are registered in SiteServiceRegistration.Configure. All three endpoints are
// anonymous and use the canonical ZbHealthWriter JSON; the site pipeline runs no
// authentication middleware and has no FallbackPolicy, so nothing extra is needed to keep
// them reachable. They are served on the HTTP/1.1 listener (metricsPort, default :8084)
// alongside /metrics — the gRPC listener is HTTP/2-only and unusable by a plain HTTP client.
app.MapZbHealth();
// Observability — mount the always-on Prometheus /metrics scrape endpoint.
// AddZbTelemetry (in SiteServiceRegistration.Configure → BindSharedOptions)
// wires the OTel Resource + standard instrumentation + Prometheus exporter;
// this exposes them on the site node too.
app.MapZbMetrics();
// Map gRPC service — resolves the singleton SiteStreamGrpcServer from DI
app.MapGrpcService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
// Map the site command plane (central→site) alongside it, on the same gated listener.
app.MapGrpcService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteCommandGrpcService>();
// The passive half of LocalDb replication: the peer node dials THIS endpoint.
// It shares the Kestrel h2c listener the site gRPC server already uses, so no
// listener or port changes are needed. Mapping it is harmless with no peer
// configured — nothing dials it, and LocalDbSyncAuthInterceptor (registered on
// AddGrpc above) rejects anything that tries until an ApiKey is configured.
app.MapZbLocalDbSync();
// Site-shutdown ordering. ApplicationStopping
// fires BEFORE IHostedService.StopAsync runs, so the gRPC server
// refuses new streams (Unavailable) and cancels every active stream
// here — clients observe a clean Cancelled and reconnect — and only
// THEN does AkkaHostedService run CoordinatedShutdown and tear down
// actors. Without this hand-off, in-flight streams go silent and only
// time out via gRPC keepalive (~25 s), violating the documented
// four-step sequence.
var siteLifetime = app.Services.GetRequiredService<Microsoft.Extensions.Hosting.IHostApplicationLifetime>();
var siteGrpcServer = app.Services.GetRequiredService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcServer>();
siteLifetime.ApplicationStopping.Register(() => siteGrpcServer.CancelAllStreams());
await app.RunAsync();
}
else
{
throw new InvalidOperationException($"Unknown role: {nodeRole}. Must be 'Central' or 'Site'.");
}
}
catch (Exception ex)
{
Log.Fatal(ex, "ScadaBridge host terminated unexpectedly");
throw;
}
finally
{
await Log.CloseAndFlushAsync();
}
/// <summary>
/// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory).
/// </summary>
public partial class Program
{
/// <summary>
/// Extracts the distinct TCP ports from an ASP.NET Core server-URLs string (the
/// <c>ASPNETCORE_URLS</c> / <c>--urls</c> value, e.g. <c>"http://+:5000"</c> or a
/// semicolon-separated list). Used to re-declare central's HTTP surface after the gRPC
/// listener switches Kestrel into explicit-endpoints mode — see the call site's warning.
/// </summary>
/// <remarks>
/// Bind hosts (<c>+</c>, <c>*</c>, <c>0.0.0.0</c>, <c>[::]</c>, a hostname) are irrelevant
/// here because the caller re-binds via <c>ListenAnyIP</c>; only the port matters. A URL
/// with no explicit port falls back to the scheme default (80/443). Unparseable entries
/// are skipped rather than throwing — a bad URL should not take the node down at boot.
/// </remarks>
/// <param name="serverUrls">The server-URLs string; may be null/empty.</param>
/// <returns>The distinct ports, in first-seen order.</returns>
internal static IReadOnlyList<int> ParseHttpBindPorts(string? serverUrls)
{
var ports = new List<int>();
if (string.IsNullOrWhiteSpace(serverUrls))
{
return ports;
}
foreach (var raw in serverUrls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
int port;
// Uri can't parse the wildcard hosts Kestrel accepts (+, *), so normalize them
// to a placeholder host before parsing; the host is discarded anyway.
var normalized = raw.Replace("://+", "://placeholder").Replace("://*", "://placeholder");
if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri))
{
port = uri.Port; // Uri fills the scheme default (80/443) when none is given.
}
else
{
// Last-ditch: pull the port after the final ':' (handles odd inputs Uri rejects).
var colon = raw.LastIndexOf(':');
if (colon < 0 || !int.TryParse(raw.AsSpan(colon + 1), out port))
{
continue;
}
}
if (port is > 0 and <= 65535 && !ports.Contains(port))
{
ports.Add(port);
}
}
return ports;
}
}