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);
+ }
+ }
+}