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();
|
builder.Host.UseWindowsService();
|
||||||
|
|
||||||
// Explicit Kestrel h2c listener for the central-hosted gRPC control plane
|
// Explicit Kestrel h2c listener for the central-hosted gRPC control plane
|
||||||
// (CentralControlService). Mirrors the site branch's gRPC listener: HTTP/2-only,
|
// (CentralControlService): HTTP/2-only, on its own port (default 8083, symmetric
|
||||||
// on its own port (default 8083, symmetric with the site GrpcPort — a node is
|
// with the site GrpcPort — a node is either central or a site, never both). gRPC
|
||||||
// either central or a site, never both). This is ADDITIVE to central's :5000
|
// does NOT go through Traefik (HTTP/1 only); sites reach this port by container name.
|
||||||
// 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
|
// WARNING — the trap the rig caught (T1A.2 shipped it, fixed here): calling
|
||||||
// this port by container name. T1A.2 hosts the server here; sites keep dialing over
|
// options.Listen*/ListenAnyIP puts Kestrel into EXPLICIT-ENDPOINTS mode, which
|
||||||
// Akka ClusterClient until T1A.3 flips CentralTransport — this listener simply also
|
// SUPPRESSES the URLs from ASPNETCORE_URLS/--urls entirely — it is NOT additive.
|
||||||
// accepts.
|
// 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);
|
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 =>
|
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 =>
|
options.ListenAnyIP(centralGrpcPort, listenOptions =>
|
||||||
{
|
{
|
||||||
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
|
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
|
||||||
@@ -645,4 +670,56 @@ finally
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory).
|
/// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory).
|
||||||
/// </summary>
|
/// </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