From a1abbff760fe63e0d6c050a125c345d435dff49e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 23 Jul 2026 16:43:45 -0400 Subject: [PATCH] fix(dcl): rewrite advertised OPC UA endpoint host to the reachable one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An OPC UA server advertises its base address from its own config, not the route the client took — commonly a 0.0.0.0 wildcard bind or an internal container/NAT hostname. The OPC Foundation session dials EndpointDescription.EndpointUrl verbatim, so a 0.0.0.0 advertisement resolved to the client's own loopback and the connect failed. RealOpcUaClient now swaps the advertised authority for the reachable host/port (ConnectAsync + VerifyEndpointAsync), preserving scheme+path; no-op when already reachable. Surfaced live-gating OtOpcUa v3 (#14). --- .../Adapters/RealOpcUaClient.cs | 45 +++++++++++ .../RealOpcUaClientEndpointRewriteTests.cs | 77 +++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientEndpointRewriteTests.cs 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"); + } +}