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
// ---------------------------------------------------------------------------------------