fix(grpc): central Kestrel gRPC listener was dropping the :5000 HTTP surface (T1A.2 regression)
Caught on the Phase 1A rig proof: central-a logged only "Now listening on: http://[::]:8083" and nothing else. Central UI, the Management + Inbound API, and the /health/* endpoints Traefik + IActiveNodeGate depend on were all gone, with no startup error — the node booted, joined the cluster and served gRPC fine. Cause: calling options.ListenAnyIP in ConfigureKestrel puts Kestrel into explicit-endpoints mode, which SUPPRESSES the URLs from ASPNETCORE_URLS/--urls entirely — it is not additive, contrary to the comment T1A.2 shipped. Central's whole HTTP/1 surface lives on that URL (http://+:5000 on the rig, a different port in prod), so binding only the gRPC port silently deleted it. The site branch has the same shape but no ASPNETCORE_URLS surface to lose — it binds every port it needs explicitly. Fix: parse the port(s) from the configured URLs and re-declare them (Http1AndHttp2) alongside the gRPC port (Http2) in the one ConfigureKestrel call. New Program.ParseHttpBindPorts + CentralHttpBindPortsTests (14 cases: wildcard/ipv6/ hostname hosts, multi-URL, de-dupe, scheme-default, null/blank, unparseable-skipped). Why no test caught it originally: unit/E2E tests use TestServer, which never binds real Kestrel. Only a live node exposes the missing listener — which is exactly what the rig proof is for.
This commit is contained in:
@@ -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<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;
|
||||
@@ -645,4 +670,56 @@ finally
|
||||
/// <summary>
|
||||
/// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory).
|
||||
/// </summary>
|
||||
public partial class Program { }
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="Program.ParseHttpBindPorts"/>, which re-declares central's HTTP surface
|
||||
/// after the gRPC listener switches Kestrel into explicit-endpoints mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>/health/*</c> 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user