From ce9fa07ff8097db6b3bfecc05534bd24412344ba Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Jul 2026 22:25:31 -0400 Subject: [PATCH] fix(localdb): re-bind ASPNETCORE_HTTP_PORTS surface, not just ASPNETCORE_URLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kestrel re-bind read only urls/ASPNETCORE_URLS. The aspnet:8.0+ base images set ASPNETCORE_HTTP_PORTS=8080 (all interfaces) as the CONTAINER default in place of ASPNETCORE_URLS, so a driver node that never sets URLS fell straight through to Kestrel's localhost:5000 default — silently moving its health/metrics surface to loopback when the sync listener took over Kestrel. Now falls through URLS → HTTP_PORTS/HTTPS_PORTS (wildcard, all-interfaces) → localhost:5000, mirroring Kestrel's own source precedence. Found on the docker-dev live gate (central nodes were fine — they set ASPNETCORE_URLS=9000 explicitly). --- .../Configuration/KestrelHttpBinding.cs | 27 ++++++++++++++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 23 +++++++++++- .../LocalDbSyncListenerTests.cs | 37 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/KestrelHttpBinding.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/KestrelHttpBinding.cs index 43b52de1..dcf343d2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/KestrelHttpBinding.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/KestrelHttpBinding.cs @@ -49,6 +49,33 @@ public sealed record KestrelHttpBinding(string Host, int Port, bool IsSecure) return result; } + /// + /// Parses the bare-port list format of ASPNETCORE_HTTP_PORTS / HTTP_PORTS (and + /// the HTTPS variants) into wildcard (+, all-interfaces) bindings — the shape + /// Kestrel itself gives those variables. Modern .NET base images (aspnet:8.0+) set + /// ASPNETCORE_HTTP_PORTS=8080 as the container default instead of + /// ASPNETCORE_URLS, so a node that never sets URLS explicitly still has a real + /// configured surface here that must be re-bound, not replaced with Kestrel's localhost:5000. + /// + /// A ;- or ,-separated list of bare ports, or null/empty. + /// True to mark the parsed bindings https (the HTTPS_PORTS vars). + /// One all-interfaces binding per parseable port; empty when nothing is configured. + public static IReadOnlyList ParsePorts(string? ports, bool isSecure) + { + if (string.IsNullOrWhiteSpace(ports)) + return []; + + var result = new List(); + foreach (var token in ports.Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + // Skip a malformed port rather than throwing — a bad env var must not down startup. + if (int.TryParse(token, out var port) && port is > 0 and <= 65535) + result.Add(new KestrelHttpBinding("+", port, isSecure)); + } + + return result; + } + /// /// Applies this binding to Kestrel, choosing the narrowest listen call the host component /// allows. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 1c772c56..b9de794b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -399,11 +399,32 @@ builder.Services.AddOtOpcUaObservability(builder.Configuration); var syncListenPort = LocalDbRegistration.SyncListenPort(builder.Configuration); if (hasDriver && syncListenPort > 0) { + // Mirror Kestrel's own source precedence: URLS wins; else the HTTP_PORTS/HTTPS_PORTS bare-port + // vars; else Kestrel's localhost:5000 default. Missing the HTTP_PORTS leg is not academic — the + // aspnet:8.0+ base images set ASPNETCORE_HTTP_PORTS=8080 as the CONTAINER default in place of + // ASPNETCORE_URLS, so a driver node that never sets URLS is really serving :8080 on all + // interfaces. Falling straight through to localhost:5000 would silently move its health/metrics + // surface to loopback (found on the docker-dev live gate). var configuredUrls = builder.Configuration["urls"] ?? builder.Configuration["ASPNETCORE_URLS"]; var existingBindings = KestrelHttpBinding.Parse(configuredUrls); - // A driver-only Windows-service node gets NO ASPNETCORE_URLS (Install-Services.ps1 sets it + if (existingBindings.Count == 0) + { + var httpPorts = builder.Configuration["ASPNETCORE_HTTP_PORTS"] ?? builder.Configuration["HTTP_PORTS"]; + var httpsPorts = builder.Configuration["ASPNETCORE_HTTPS_PORTS"] ?? builder.Configuration["HTTPS_PORTS"]; + existingBindings = + [ + .. KestrelHttpBinding.ParsePorts(httpPorts, isSecure: false), + .. KestrelHttpBinding.ParsePorts(httpsPorts, isSecure: true), + ]; + if (existingBindings.Count > 0) + Log.Information( + "LocalDb sync listener enabled; re-binding the HTTP_PORTS surface ({HttpPorts}/{HttpsPorts}) " + + "alongside the sync port.", httpPorts, httpsPorts); + } + + // A driver-only Windows-service node gets NO URLS and NO *_PORTS (Install-Services.ps1 sets URLS // only under $hasAdmin), so "nothing configured" is a real, supported state — not an error. // Kestrel's own default is localhost:5000; re-state it explicitly, because taking over // configuration means taking over the default too. Health probes hit this port. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSyncListenerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSyncListenerTests.cs index 0a1c69cc..58e25082 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSyncListenerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSyncListenerTests.cs @@ -88,6 +88,43 @@ public sealed class LocalDbSyncListenerTests KestrelHttpBinding.Parse("not a url;http://+:9000").ShouldHaveSingleItem().Port.ShouldBe(9000); } + // ---- ParsePorts (ASPNETCORE_HTTP_PORTS / HTTPS_PORTS) ---------------------------------- + + [Fact] + public void ParsePorts_NullOrEmpty_YieldsNoBindings() + { + KestrelHttpBinding.ParsePorts(null, isSecure: false).ShouldBeEmpty(); + KestrelHttpBinding.ParsePorts("", isSecure: false).ShouldBeEmpty(); + KestrelHttpBinding.ParsePorts(" ", isSecure: false).ShouldBeEmpty(); + } + + [Fact] + public void ParsePorts_BarePort_BindsAllInterfaces() + { + // The aspnet:8.0+ container default is ASPNETCORE_HTTP_PORTS=8080, on all interfaces — not + // loopback. Re-binding it as a wildcard is what keeps a driver node's health/metrics surface + // reachable from other containers once the sync listener takes over Kestrel. + var binding = KestrelHttpBinding.ParsePorts("8080", isSecure: false).ShouldHaveSingleItem(); + binding.Host.ShouldBe("+"); + binding.Port.ShouldBe(8080); + binding.IsSecure.ShouldBeFalse(); + } + + [Fact] + public void ParsePorts_SeparatedList_AndSecureFlag() + { + KestrelHttpBinding.ParsePorts("8080;8081", isSecure: false).Select(b => b.Port).ShouldBe([8080, 8081]); + KestrelHttpBinding.ParsePorts("8080,8081", isSecure: false).Select(b => b.Port).ShouldBe([8080, 8081]); + KestrelHttpBinding.ParsePorts("443", isSecure: true).ShouldHaveSingleItem().IsSecure.ShouldBeTrue(); + } + + [Fact] + public void ParsePorts_MalformedPort_IsSkipped_NotThrown() + { + KestrelHttpBinding.ParsePorts("notaport;8080;0;70000", isSecure: false) + .ShouldHaveSingleItem().Port.ShouldBe(8080); + } + // --------------------------------------------------------------------------------------- // The real Kestrel contract // ---------------------------------------------------------------------------------------