diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
index 55d8c1fe..0c76acb5 100644
--- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs
@@ -123,6 +123,11 @@ public class RealOpcUaClient : IOpcUaClient
endpoint = new EndpointDescription(endpointUrl);
}
+ // The server advertises its own base address, which is frequently unroutable from here
+ // (a 0.0.0.0 wildcard bind, or an internal container/NAT hostname). Swap it back to the
+ // host the operator actually reached, or the session channel dials an unreachable address.
+ RewriteEndpointHostForReachability(endpoint, endpointUrl);
+
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
@@ -157,6 +162,42 @@ public class RealOpcUaClient : IOpcUaClient
await _subscription.CreateAsync(cancellationToken);
}
+ ///
+ /// Rewrites a discovered endpoint's host/port to match the reachable URL the operator
+ /// configured, when the server advertised a different (unroutable) one.
+ ///
+ ///
+ /// An OPC UA server advertises its base address from its OWN configuration, not the route the
+ /// client took to reach it — so GetEndpoints routinely returns a wildcard bind
+ /// (opc.tcp://0.0.0.0:4840/…) or an internal container/NAT hostname the client cannot
+ /// dial. The OPC Foundation session opens its channel to
+ /// verbatim, so a 0.0.0.0 advertisement resolves to the client's OWN loopback and the connect
+ /// fails. This swaps only the authority (host + port) to the reachable one, preserving the
+ /// advertised scheme and path (e.g. /OtOpcUa). Mirrors CoreClientUtils.SelectEndpoint.
+ /// A no-op when the advertised host/port is already reachable (so an opc-plc server that
+ /// advertises its real container name is left untouched).
+ ///
+ internal static void RewriteEndpointHostForReachability(EndpointDescription? endpoint, string discoveryUrl)
+ {
+ if (endpoint is null || string.IsNullOrWhiteSpace(endpoint.EndpointUrl))
+ return;
+ if (!Uri.TryCreate(discoveryUrl, UriKind.Absolute, out var reachable))
+ return;
+ if (!Uri.TryCreate(endpoint.EndpointUrl, UriKind.Absolute, out var advertised))
+ return;
+
+ // Already reachable — leave it exactly as advertised.
+ if (string.Equals(advertised.Host, reachable.Host, StringComparison.OrdinalIgnoreCase)
+ && advertised.Port == reachable.Port)
+ return;
+
+ var port = reachable.Port >= 0 ? reachable.Port : advertised.Port;
+ // Authority-only advertisements parse to AbsolutePath "/" — drop it so we don't append a
+ // spurious trailing slash the server never advertised.
+ var path = advertised.AbsolutePath == "/" ? string.Empty : advertised.AbsolutePath;
+ endpoint.EndpointUrl = $"{advertised.Scheme}://{reachable.Host}:{port}{path}";
+ }
+
///
/// Probes an OPC UA endpoint configuration WITHOUT persisting it or creating a
/// long-lived connection — connect, capture the server certificate if it is untrusted,
@@ -258,6 +299,10 @@ public class RealOpcUaClient : IOpcUaClient
endpoint = new EndpointDescription(endpointUrl);
}
+ // Same reachability rewrite as ConnectAsync — a probe against a server advertising a
+ // 0.0.0.0 / NAT base address must dial the reachable host, not the advertised one.
+ RewriteEndpointHostForReachability(endpoint, endpointUrl);
+
var endpointConfig = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientEndpointRewriteTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientEndpointRewriteTests.cs
new file mode 100644
index 00000000..d9ed9182
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientEndpointRewriteTests.cs
@@ -0,0 +1,77 @@
+using Opc.Ua;
+using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
+
+namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
+
+///
+/// Covers — the fix for OPC UA
+/// servers (e.g. OtOpcUa v3) that advertise an unroutable base address (a 0.0.0.0 wildcard bind or
+/// an internal container/NAT hostname). The session must dial the host the operator reached, not
+/// the advertised one, while preserving the advertised scheme + path.
+///
+public class RealOpcUaClientEndpointRewriteTests
+{
+ private static string? Rewrite(string advertised, string discovery)
+ {
+ var ep = new EndpointDescription(advertised);
+ RealOpcUaClient.RewriteEndpointHostForReachability(ep, discovery);
+ return ep.EndpointUrl;
+ }
+
+ [Fact]
+ public void Rewrites_0_0_0_0_advertisement_to_the_reachable_host_preserving_path()
+ {
+ Assert.Equal(
+ "opc.tcp://otopcua-dev-site-a-1-1:4840/OtOpcUa",
+ Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://otopcua-dev-site-a-1-1:4840/OtOpcUa"));
+ }
+
+ [Fact]
+ public void Rewrites_internal_nat_hostname_to_the_reachable_host()
+ {
+ Assert.Equal(
+ "opc.tcp://plc.example.com:4840/OtOpcUa",
+ Rewrite("opc.tcp://be5667fab9da:4840/OtOpcUa", "opc.tcp://plc.example.com:4840/OtOpcUa"));
+ }
+
+ [Fact]
+ public void Rewrites_port_too_when_the_advertised_port_differs()
+ {
+ Assert.Equal(
+ "opc.tcp://gateway:51210/OtOpcUa",
+ Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", "opc.tcp://gateway:51210/OtOpcUa"));
+ }
+
+ [Fact]
+ public void Leaves_an_already_reachable_advertisement_untouched()
+ {
+ // opc-plc advertises its real container name — must not be rewritten.
+ Assert.Equal(
+ "opc.tcp://scadabridge-opcua:50000",
+ Rewrite("opc.tcp://scadabridge-opcua:50000", "opc.tcp://scadabridge-opcua:50000"));
+ }
+
+ [Fact]
+ public void Handles_authority_only_advertisement_without_appending_a_trailing_slash()
+ {
+ Assert.Equal(
+ "opc.tcp://host-b:4840",
+ Rewrite("opc.tcp://0.0.0.0:4840", "opc.tcp://host-b:4840"));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not-a-uri")]
+ public void Leaves_endpoint_unchanged_when_discovery_url_is_unusable(string discovery)
+ {
+ Assert.Equal("opc.tcp://0.0.0.0:4840/OtOpcUa", Rewrite("opc.tcp://0.0.0.0:4840/OtOpcUa", discovery));
+ }
+
+ [Fact]
+ public void Null_endpoint_is_a_no_op()
+ {
+ // Must not throw.
+ RealOpcUaClient.RewriteEndpointHostForReachability(null, "opc.tcp://host:4840");
+ }
+}