diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 2422f353..bd1a99b1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -98,17 +98,42 @@ try builder.Host.UseWindowsService(); // Explicit Kestrel h2c listener for the central-hosted gRPC control plane - // (CentralControlService). Mirrors the site branch's gRPC listener: HTTP/2-only, - // on its own port (default 8083, symmetric with the site GrpcPort — a node is - // either central or a site, never both). This is ADDITIVE to central's :5000 - // HTTP/1 surface (Central UI + Management/Inbound API from ASPNETCORE_URLS), - // which is untouched: gRPC does NOT go through Traefik (HTTP/1 only), sites reach - // this port by container name. T1A.2 hosts the server here; sites keep dialing over - // Akka ClusterClient until T1A.3 flips CentralTransport — this listener simply also - // accepts. + // (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("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; @@ -645,4 +670,56 @@ finally /// /// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory). /// -public partial class Program { } +public partial class Program +{ + /// + /// Extracts the distinct TCP ports from an ASP.NET Core server-URLs string (the + /// ASPNETCORE_URLS / --urls value, e.g. "http://+:5000" 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. + /// + /// + /// Bind hosts (+, *, 0.0.0.0, [::], a hostname) are irrelevant + /// here because the caller re-binds via ListenAnyIP; 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. + /// + /// The server-URLs string; may be null/empty. + /// The distinct ports, in first-seen order. + internal static IReadOnlyList ParseHttpBindPorts(string? serverUrls) + { + var ports = new List(); + 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; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs new file mode 100644 index 00000000..dd2d4336 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs @@ -0,0 +1,56 @@ +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// Pins , which re-declares central's HTTP surface +/// after the gRPC listener switches Kestrel into explicit-endpoints mode. +/// +/// +/// This exists because the regression it guards is invisible to every other test: a central +/// node that binds only the gRPC port boots clean, joins the cluster and serves gRPC, while +/// the Central UI, Management/Inbound API and /health/* are silently dead. TestServer +/// never binds real Kestrel, so the parser is the one seam that can be asserted in-process. +/// The rig caught the original defect; this keeps it caught. +/// +public class CentralHttpBindPortsTests +{ + [Theory] + [InlineData("http://+:5000", new[] { 5000 })] + [InlineData("http://0.0.0.0:5000", new[] { 5000 })] + [InlineData("http://[::]:5000", new[] { 5000 })] + [InlineData("http://localhost:5000", new[] { 5000 })] + [InlineData("http://*:8085", new[] { 8085 })] + [InlineData("http://+:5000;http://+:5001", new[] { 5000, 5001 })] + [InlineData("http://+:5000 ; http://+:5001", new[] { 5000, 5001 })] + [InlineData("http://+:5000;http://+:5000", new[] { 5000 })] // de-duped + public void ParsesPortsFromServerUrls(string urls, int[] expected) + { + Assert.Equal(expected, Program.ParseHttpBindPorts(urls)); + } + + [Theory] + [InlineData("https://+:443", 443)] // scheme default when no explicit port + [InlineData("http://+:80", 80)] + public void HandlesSchemeDefaultAndExplicitPort(string url, int expected) + { + Assert.Equal(new[] { expected }, Program.ParseHttpBindPorts(url)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void EmptyOrNull_YieldsNoPorts(string? urls) + { + Assert.Empty(Program.ParseHttpBindPorts(urls)); + } + + [Fact] + public void UnparseableEntry_IsSkipped_NotThrown() + { + // A malformed URL must not take the node down at boot; the good one still binds. + var ports = Program.ParseHttpBindPorts("not-a-url;http://+:5000"); + Assert.Equal(new[] { 5000 }, ports); + } +}