fix(dcl): rewrite advertised OPC UA endpoint host to the reachable one

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).
This commit is contained in:
Joseph Doherty
2026-07-23 16:43:45 -04:00
parent 266f001a2e
commit a1abbff760
2 changed files with 122 additions and 0 deletions
@@ -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);
}
/// <summary>
/// Rewrites a discovered endpoint's host/port to match the reachable URL the operator
/// configured, when the server advertised a different (unroutable) one.
/// </summary>
/// <remarks>
/// An OPC UA server advertises its base address from its OWN configuration, not the route the
/// client took to reach it — so <c>GetEndpoints</c> routinely returns a wildcard bind
/// (<c>opc.tcp://0.0.0.0:4840/…</c>) or an internal container/NAT hostname the client cannot
/// dial. The OPC Foundation session opens its channel to <see cref="EndpointDescription.EndpointUrl"/>
/// 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. <c>/OtOpcUa</c>). Mirrors <c>CoreClientUtils.SelectEndpoint</c>.
/// 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).
/// </remarks>
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}";
}
/// <summary>
/// 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);
@@ -0,0 +1,77 @@
using Opc.Ua;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
/// <summary>
/// Covers <see cref="RealOpcUaClient.RewriteEndpointHostForReachability"/> — 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.
/// </summary>
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");
}
}