Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientEndpointRewriteTests.cs
T
Joseph Doherty 378962eeb1 fix(opcuaclient-browser): keep configured host/port behind NAT (#806)
The AdminUI OPC UA browse picker connected to the EndpointUrl the server
advertised in GetEndpoints verbatim, so behind NAT / a container port
publish it dialled the server's private hostname + internal port and
failed DNS where the runtime driver (which rewrites per #676) connected.

- Move the rewrite to the shared OpcUaEndpointUrlRewriter (.Contracts,
  pure Uri logic, no SDK dependency) and the policy+mode selection to
  OpcUaEndpointSelection (.Session leaf). Driver and browser both select
  through it, so the browse connects exactly where the driver does.
- Policy/mode filter, server certificate and strict browse PKI
  (no auto-accept) are unchanged.
- Unit tests: rewrite (host/port differ, same, IPv6 both sides, path
  kept, port-less, scheme mismatch untouched), browser selection seam.
- Live NAT tests (Fixture=opc-plc-nat, OPCUA_NAT_ENDPOINT): browse via a
  published port with a premise check that the advertised host:port
  differs and does not resolve, nsu= round-trip, unreachable endpoint
  fails cleanly, SignAndEncrypt reaches the server and is refused
  BadCertificateUntrusted. Verified red without the rewrite.

Refs #806
2026-11-10 21:00:04 -05:00

136 lines
5.6 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Gitea #676 — the driver connects to the endpoint URL the server ADVERTISES in GetEndpoints. A server behind NAT,
/// a port map or a proxy advertises an address the client cannot reach (or one that bypasses the proxy), so the
/// driver must rewrite host AND port to the configured URL when the scheme matches — what the SDK's own
/// <c>CoreClientUtils.SelectEndpoint</c> does. Gitea #806 moved the rule to the shared
/// <see cref="OpcUaEndpointUrlRewriter"/> so the AdminUI browse picker applies the identical rule; these tests pin
/// the shared implementation and that the driver delegates to it.
/// </summary>
public sealed class OpcUaClientEndpointRewriteTests
{
[Fact]
public void Host_and_port_of_the_advertised_url_are_replaced_by_the_configured_ones()
{
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://10.1.2.3:54840/OtOpcUa")
.ShouldBe("opc.tcp://10.1.2.3:54840/OtOpcUa");
}
[Fact]
public void The_806_docker_port_publish_shape_is_rewritten()
{
// The exact #806 fixture: opc-plc in a container advertises its container hostname + internal port; the
// operator reaches it through a published host port.
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://otopcua-sdk1-browser-nat-parity:50000/", "opc.tcp://127.0.0.1:32772")
.ShouldBe("opc.tcp://127.0.0.1:32772/");
}
[Fact]
public void The_advertised_path_is_kept()
{
// The path is the server's to choose (several endpoints may share a host:port); only the address is ours.
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://plc-a:4840/Server/Ua", "opc.tcp://gateway:6000/")
.ShouldBe("opc.tcp://gateway:6000/Server/Ua");
}
[Fact]
public void Only_the_port_differs_still_rewrites()
{
// The in-process stall proxy shape: same host, the configured port is the proxy's.
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://127.0.0.1:4840/OtOpcUa", "opc.tcp://127.0.0.1:61000/OtOpcUa")
.ShouldBe("opc.tcp://127.0.0.1:61000/OtOpcUa");
}
[Fact]
public void Same_host_and_port_is_unchanged()
{
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://plc-a:4840/Server", "opc.tcp://plc-a:4840/Server")
.ShouldBe("opc.tcp://plc-a:4840/Server");
}
[Fact]
public void Host_matching_is_case_insensitive_and_keeps_the_configured_spelling()
{
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://PLC-A:4840/Server", "OPC.TCP://plc-a:4840/")
.ShouldBe("opc.tcp://plc-a:4840/Server");
}
[Fact]
public void A_configured_url_without_a_port_keeps_the_advertised_port()
{
// Review of 3b88056f: Uri.Port is -1 for a port-less opc.tcp URL; copying it would drop the advertised port and
// the SDK would fall back to 4840.
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://internal-host:62541/Server", "opc.tcp://gateway/Server")
.ShouldBe("opc.tcp://gateway:62541/Server");
}
[Fact]
public void An_IPv6_configured_host_is_bracketed()
{
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://[fd00::1]:54840/OtOpcUa")
.ShouldBe("opc.tcp://[fd00::1]:54840/OtOpcUa");
}
[Fact]
public void An_IPv6_advertised_host_is_replaced_by_a_configured_name()
{
OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://[fd00::7]:4840/Ua", "opc.tcp://gateway:6000")
.ShouldBe("opc.tcp://gateway:6000/Ua");
}
[Fact]
public void A_different_scheme_is_left_alone()
{
// An https endpoint advertised next to an opc.tcp one is a different transport, not a NAT artefact.
OpcUaEndpointUrlRewriter.Rewrite("https://srv:443/ua", "opc.tcp://srv:4840/ua")
.ShouldBe("https://srv:443/ua");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("not a url")]
public void An_unusable_advertised_url_falls_back_to_the_configured_one(string? advertised)
{
OpcUaEndpointUrlRewriter.Rewrite(advertised, "opc.tcp://srv:4840/ua").ShouldBe("opc.tcp://srv:4840/ua");
}
[Fact]
public void The_driver_delegates_to_the_shared_rewriter()
{
OpcUaClientDriver.RewriteEndpointUrl("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://10.1.2.3:54840")
.ShouldBe(OpcUaEndpointUrlRewriter.Rewrite("opc.tcp://internal-host:4840/OtOpcUa", "opc.tcp://10.1.2.3:54840"));
}
[Fact]
public void Shared_selection_filters_policy_and_mode_then_rewrites_the_match()
{
var advertised = new[]
{
new EndpointDescription
{
EndpointUrl = "opc.tcp://internal:50000/", SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
},
new EndpointDescription
{
EndpointUrl = "opc.tcp://internal:50000/", SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
},
};
var picked = OpcUaEndpointSelection.SelectMatching(
advertised, "opc.tcp://127.0.0.1:32772", OpcUaSecurityPolicy.Basic256Sha256, OpcUaSecurityMode.SignAndEncrypt);
picked.ShouldBeSameAs(advertised[1]);
picked.SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
picked.SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
picked.EndpointUrl.ShouldBe("opc.tcp://127.0.0.1:32772/");
}
}