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:
@@ -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);
|
||||
|
||||
|
||||
+77
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user