diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs index 5f6fba17..1482203c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs @@ -2,18 +2,19 @@ using System.Diagnostics; using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Serialization; +using Opc.Ua; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// -/// Cheap TCP-connect probe for the -shaped driver config. -/// Parses the first endpoint URL (from or -/// the convenience fallback), opens a -/// socket to the OPC UA server host + port and closes immediately. Surfaces a green tick + -/// latency on success; red chip + SocketError on failure; "timed out" on the caller's -/// cancellation. Does NOT open an OPC UA session — a richer session-open probe is a -/// documented follow-up. +/// Probe for the -shaped driver config. Parses the +/// first endpoint URL (from or the +/// convenience fallback), performs a +/// TCP-connect preflight to detect closed ports quickly, then executes a real OPC UA +/// GetEndpoints discovery handshake (mirroring the driver's +/// SelectMatchingEndpointAsync) to confirm the remote process is actually an OPC UA +/// server. No session is opened and no authentication is required. /// public sealed class OpcUaClientDriverProbe : IDriverProbe { @@ -38,17 +39,19 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); } if (opts is null) return new(false, "Config JSON deserialized to null.", null); - var (host, port) = ExtractTarget(opts); + var (host, port, endpointUrl) = ExtractTarget(opts); if (string.IsNullOrWhiteSpace(host) || port <= 0) return new(false, "Config has no host/port to probe.", null); + // --- TCP preflight: fast-fail for closed ports / unreachable hosts --- var sw = Stopwatch.StartNew(); try { - using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + using var socket = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp) + { + DualMode = true, + }; await socket.ConnectAsync(host, port, ct); - sw.Stop(); - return new(true, null, sw.Elapsed); } catch (SocketException ex) { @@ -62,21 +65,70 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe { return new(false, ex.Message, null); } + + // --- OPC UA discovery handshake: confirm the remote is an OPC UA server --- + // Mirrors OpcUaClientDriver.SelectMatchingEndpointAsync exactly: + // DiscoveryClient.CreateAsync(appConfig, new Uri(endpointUrl), DiagnosticsMasks.None, ct) + // GetEndpoints needs no session, no application certificate, and no authentication. + try + { + var appConfig = BuildMinimalAppConfig(); + using var client = await DiscoveryClient.CreateAsync( + appConfig, new Uri(endpointUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false); + var endpoints = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false); + sw.Stop(); + + if (endpoints is { Count: > 0 }) + return new(true, $"OPC UA: {endpoints.Count} endpoint(s)", sw.Elapsed); + + return new(false, + $"Reachable at {host}:{port} but OPC UA handshake failed: server published 0 endpoints", + null); + } + catch (OperationCanceledException) + { + return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null); + } + catch (Exception ex) + { + return new(false, + $"Reachable at {host}:{port} but OPC UA handshake failed: {ex.Message}", + null); + } } - private static (string host, int port) ExtractTarget(OpcUaClientDriverOptions opts) + /// + /// Builds a minimal suitable for unauthenticated + /// discovery (GetEndpoints). No PKI stores, no session certificate — discovery traffic + /// is plain-text and does not require a client certificate. + /// + private static ApplicationConfiguration BuildMinimalAppConfig() => + new() + { + ApplicationName = "OtOpcUa.Probe", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + AutoAcceptUntrustedCertificates = true, + }, + TransportQuotas = new TransportQuotas(), + ClientConfiguration = new ClientConfiguration(), + DisableHiResClock = true, + }; + + private static (string host, int port, string endpointUrl) ExtractTarget(OpcUaClientDriverOptions opts) { // EndpointUrls wins over the convenience EndpointUrl when both are set. var endpointUrl = opts.EndpointUrls.FirstOrDefault() ?? (string.IsNullOrWhiteSpace(opts.EndpointUrl) ? null : opts.EndpointUrl); - if (endpointUrl is null) return (string.Empty, 0); + if (endpointUrl is null) return (string.Empty, 0, string.Empty); // Parse as a URI — opc.tcp://host:port is a valid URI. if (!Uri.TryCreate(endpointUrl, UriKind.Absolute, out var uri)) - return (string.Empty, 0); + return (string.Empty, 0, string.Empty); var host = uri.Host; var port = uri.IsDefaultPort ? 4840 : uri.Port; - return (host, port); + return (host, port, endpointUrl); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverProbeTests.cs new file mode 100644 index 00000000..5ebb4e0e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverProbeTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Unit tests for . Uses an in-process +/// on 127.0.0.1 to exercise the accept-then-close negative path +/// (non-OPC-UA TCP server). The happy path (real OPC UA endpoints) is covered live. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientDriverProbeTests +{ + private readonly OpcUaClientDriverProbe _probe = new(); + + // ── 1. Invalid JSON ────────────────────────────────────────────────────────── + + /// + /// Invalid JSON returns Ok=false with a message containing "invalid". + /// + [Fact] + public async Task InvalidJson_returns_false_with_invalid_message() + { + var result = await _probe.ProbeAsync( + "not-json", + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldContain("invalid", Case.Insensitive); + } + + // ── 2. Config with no endpoint URL ─────────────────────────────────────────── + + /// + /// Config JSON that resolves to no endpoint URL returns Ok=false with a message + /// indicating no host/port was found. + /// + [Fact] + public async Task NoEndpointUrl_returns_false_with_no_host_port_message() + { + // Empty EndpointUrl + empty EndpointUrls → ExtractTarget returns ("", 0, "") + var result = await _probe.ProbeAsync( + """{"EndpointUrl":"","EndpointUrls":[]}""", + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldContain("no host", Case.Insensitive); + } + + // ── 3. Unreachable closed port ──────────────────────────────────────────────── + + /// + /// Pointing at a TCP port that is not open returns Ok=false with a "Connect failed" + /// message from the socket layer. + /// + [Fact] + public async Task ClosedPort_returns_false_with_connect_failed_message() + { + // Find a free port by letting the OS assign one, then immediately close the listener + // so nothing is bound at that port when we probe it. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + var configJson = $$"""{"EndpointUrl":"opc.tcp://127.0.0.1:{{port}}"}"""; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var result = await _probe.ProbeAsync(configJson, TimeSpan.FromSeconds(5), cts.Token); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + // SocketException on a closed port emits "Connect failed: ConnectionRefused" (or similar). + result.Message!.ShouldContain("Connect failed", Case.Insensitive); + } + + // ── 4. TCP accept-then-close (non-OPC-UA server) ──────────────────────────── + + /// + /// A TCP server that accepts but immediately closes the connection (simulating a + /// non-OPC-UA process on the target port) causes the GetEndpoints handshake to fail. + /// The result must be Ok=false and the message must contain "handshake failed". + /// + [Fact] + public async Task NonOpcUaTcpServer_returns_false_with_handshake_failed_message() + { + // Start a listener that accepts the connection and immediately closes it, simulating + // a non-OPC UA TCP service (e.g. an HTTP server or an SSH daemon) on the target port. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var testCt = TestContext.Current.CancellationToken; + + // Accept in background — immediately close the socket so the OPC UA client gets EOF. + var acceptTask = Task.Run(async () => + { + try + { + using var client = await listener.AcceptTcpClientAsync(testCt); + // Close immediately — the OPC UA handshake will get an EOF / broken pipe. + } + catch + { + // Listener may be stopped before a second accept; ignore. + } + finally + { + listener.Stop(); + } + }, testCt); + + try + { + var configJson = $$"""{"EndpointUrl":"opc.tcp://127.0.0.1:{{port}}"}"""; + + // Use a generous timeout so the test is not fragile on a slow CI box, but short + // enough to not block the suite for long if something goes wrong. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var result = await _probe.ProbeAsync(configJson, TimeSpan.FromSeconds(10), cts.Token); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldNotBeNull(); + result.Message!.ShouldContain("handshake failed", Case.Insensitive); + } + finally + { + await acceptTask.WaitAsync(TimeSpan.FromSeconds(5), testCt); + } + } +}