ac1b33555a
Docker/nat/docker-compose.yml (stack /opt/otopcua-opcuaclient-nat on 10.100.0.35, project: lmxopcua label): opc-plc 2.14.10 advertising --ph=otopcua-opc-plc-nat-private.invalid on internal 50000, published on 50010, so every advertised URL is unreachable on host AND port. Deployed; NatPublishedEndpointBrowseTests 3/3 against opc.tcp://10.100.0.35:50010 (and the secure case positive once, with the fixture cert temporarily trusted). CI: integration-fixtures.sh starts a throwaway otopcua-ci-opcplc-nat (50110->50000) and exports OPCUA_NAT_ENDPOINT; v2-e2e.yml points it at the shared stack. infra/README.md, scripts/ci/README.md and the fixture README document it.
151 lines
7.8 KiB
C#
151 lines
7.8 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Gitea #806 — real NAT / port-publish browse. The fixture is an opc-plc container whose hostname is private and
|
|
/// whose internal port differs from the published one, so its GetEndpoints advertise an address this machine
|
|
/// cannot reach:
|
|
/// <code>
|
|
/// # shared docker host (#813): compose in Driver.OpcUaClient.IntegrationTests/Docker/nat/
|
|
/// ssh dohertj2@10.100.0.35 'cd /opt/otopcua-opcuaclient-nat && docker compose up -d'
|
|
/// OPCUA_NAT_ENDPOINT=opc.tcp://10.100.0.35:50010 dotnet test --filter Fixture=opc-plc-nat
|
|
/// # or locally / in CI (scripts/ci/integration-fixtures.sh starts the same shape as otopcua-ci-opcplc-nat):
|
|
/// docker run -d --rm --name otopcua-806-nat --hostname otopcua-806-nat-parity -p 32781:50000 \
|
|
/// mcr.microsoft.com/iotedge/opc-plc:2.14.10 --pn=50000 --ph=otopcua-806-nat-parity.invalid --autoaccept --ut
|
|
/// OPCUA_NAT_ENDPOINT=opc.tcp://127.0.0.1:32781 dotnet test --filter Fixture=opc-plc-nat
|
|
/// </code>
|
|
/// Skipped unless <c>OPCUA_NAT_ENDPOINT</c> is set and accepts TCP. Every test first proves the premise (the
|
|
/// advertised URL is NOT the configured host:port, and its host does not resolve here), so a fixture that
|
|
/// advertises a reachable address cannot make these pass vacuously. Before the fix the positive cases failed with
|
|
/// <c>SocketException: nodename nor servname provided, or not known</c>.
|
|
/// </summary>
|
|
[Trait("Category", "Integration"), Trait("Fixture", "opc-plc-nat")]
|
|
public sealed class NatPublishedEndpointBrowseTests
|
|
{
|
|
private const string EnvVar = "OPCUA_NAT_ENDPOINT";
|
|
|
|
private static string? Endpoint => Environment.GetEnvironmentVariable(EnvVar) is { Length: > 0 } e ? e : null;
|
|
|
|
private static string Json(string endpoint, string policy = "None", string mode = "None") => $$"""
|
|
{
|
|
"EndpointUrl":"{{endpoint}}",
|
|
"SecurityPolicy":"{{policy}}","SecurityMode":"{{mode}}","AuthType":"Anonymous",
|
|
"SessionTimeout":"00:01:00","Timeout":"00:00:10","PerEndpointConnectTimeout":"00:00:10"
|
|
}
|
|
""";
|
|
|
|
private static string RequireFixture()
|
|
{
|
|
var endpoint = Endpoint;
|
|
if (endpoint is null) Assert.Skip($"{EnvVar} not set — no NAT/port-published opc-plc fixture.");
|
|
var uri = new Uri(endpoint);
|
|
try
|
|
{
|
|
using var tcp = new TcpClient();
|
|
if (!tcp.ConnectAsync(uri.Host, uri.Port).Wait(TimeSpan.FromSeconds(2)) || !tcp.Connected)
|
|
Assert.Skip($"{EnvVar}={endpoint} did not accept TCP within 2s.");
|
|
}
|
|
catch (Exception ex) when (ex is not Xunit.Sdk.SkipException)
|
|
{
|
|
Assert.Skip($"{EnvVar}={endpoint} unreachable: {ex.GetBaseException().Message}");
|
|
}
|
|
return endpoint;
|
|
}
|
|
|
|
/// <summary>The premise: the server advertises a host:port other than the configured one, and that host is unresolvable.</summary>
|
|
private static async Task AssertAdvertisesUnreachableAddressAsync(string endpoint, CancellationToken ct)
|
|
{
|
|
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
|
|
{
|
|
ApplicationName = "OtOpcUa #806 premise",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = new SecurityConfiguration(),
|
|
TransportQuotas = new TransportQuotas(),
|
|
ClientConfiguration = new ClientConfiguration(),
|
|
DisableHiResClock = true,
|
|
};
|
|
using var discovery = await DiscoveryClient.CreateAsync(appConfig, new Uri(endpoint), DiagnosticsMasks.None, ct);
|
|
var endpoints = (await discovery.GetEndpointsAsync(default, ct)).ToList();
|
|
endpoints.ShouldNotBeEmpty();
|
|
|
|
var configured = new Uri(endpoint);
|
|
foreach (var advertised in endpoints.Select(e => new Uri(e.EndpointUrl!)))
|
|
{
|
|
(advertised.Host, advertised.Port).ShouldNotBe((configured.Host, configured.Port),
|
|
"fixture must advertise a different host:port, or this test proves nothing");
|
|
await Should.ThrowAsync<SocketException>(() => Dns.GetHostAddressesAsync(advertised.Host, ct),
|
|
$"advertised host '{advertised.Host}' must be unresolvable from here, or this test proves nothing");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Browse_through_a_published_port_succeeds_and_namespaced_ids_round_trip()
|
|
{
|
|
var endpoint = RequireFixture();
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await AssertAdvertisesUnreachableAddressAsync(endpoint, ct);
|
|
|
|
await using var session = await new OpcUaClientDriverBrowser().OpenAsync(Json(endpoint), ct);
|
|
var roots = await session.RootAsync(ct);
|
|
roots.Count.ShouldBeGreaterThan(0);
|
|
|
|
// opc-plc's own nodes live in a non-zero namespace; the browse encodes them namespace-URI-stable (nsu=).
|
|
var folder = roots.FirstOrDefault(n => n.Kind == BrowseNodeKind.Folder && n.NodeId.StartsWith("nsu="));
|
|
folder.ShouldNotBeNull("expected an opc-plc folder in its own (nsu=) namespace under Objects");
|
|
var children = await session.ExpandAsync(folder.NodeId, ct);
|
|
children.Count.ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unreachable_configured_endpoint_still_fails_cleanly()
|
|
{
|
|
RequireFixture();
|
|
var ct = TestContext.Current.CancellationToken;
|
|
// A closed loopback port: the rewrite must not invent a reachable address; the open fails promptly.
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
await Should.ThrowAsync<Exception>(() => new OpcUaClientDriverBrowser().OpenAsync(Json("opc.tcp://127.0.0.1:1"), ct));
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(30));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Secure_browse_reaches_the_server_and_keeps_strict_certificate_validation()
|
|
{
|
|
var endpoint = RequireFixture();
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await AssertAdvertisesUnreachableAddressAsync(endpoint, ct);
|
|
|
|
// Basic256Sha256/SignAndEncrypt through the published port. The browse PKI never auto-accepts, so unless the
|
|
// operator trusted this fixture's certificate in the browse PKI (then set OPCUA_NAT_SERVER_CERT_TRUSTED=1) the
|
|
// open must be refused on the certificate — which proves the session reached the SERVER (no DNS failure on the
|
|
// advertised host) and that the rewrite did not relax trust.
|
|
var trusted = Environment.GetEnvironmentVariable("OPCUA_NAT_SERVER_CERT_TRUSTED") == "1";
|
|
var json = Json(endpoint, "Basic256Sha256", "SignAndEncrypt");
|
|
if (trusted)
|
|
{
|
|
await using var session = await new OpcUaClientDriverBrowser().OpenAsync(json, ct);
|
|
(await session.RootAsync(ct)).Count.ShouldBeGreaterThan(0);
|
|
return;
|
|
}
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(async () =>
|
|
{
|
|
await using var session = await new OpcUaClientDriverBrowser().OpenAsync(json, ct);
|
|
});
|
|
TestContext.Current.TestOutputHelper?.WriteLine($"secure open refused: {ex.GetType().Name}: {ex.Message}");
|
|
ex.ShouldNotBeOfType<SocketException>($"must not fail on DNS for the advertised host: {ex}");
|
|
var sre = ex as ServiceResultException ?? ex.InnerException as ServiceResultException;
|
|
sre.ShouldNotBeNull($"expected a certificate refusal, got {ex.GetType().Name}: {ex.Message}");
|
|
sre.StatusCode.ShouldBeOneOf(
|
|
StatusCodes.BadCertificateUntrusted, StatusCodes.BadSecurityChecksFailed,
|
|
StatusCodes.BadCertificateChainIncomplete);
|
|
}
|
|
}
|