feat(probe): OpcUaClient Test-Connect does a GetEndpoints discovery handshake
Replace the bare TCP-connect return in OpcUaClientDriverProbe with a real OPC UA GetEndpoints discovery handshake (mirroring SelectMatchingEndpointAsync in the driver). TCP preflight still fast-fails closed ports; the handshake confirms the remote is actually an OPC UA server, so a live-but-rejecting non-OPC-UA process now reads RED instead of a false-healthy green.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="OpcUaClientDriverOptions"/>-shaped driver config.
|
||||
/// Parses the first endpoint URL (from <see cref="OpcUaClientDriverOptions.EndpointUrls"/> or
|
||||
/// the convenience <see cref="OpcUaClientDriverOptions.EndpointUrl"/> 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 <see cref="OpcUaClientDriverOptions"/>-shaped driver config. Parses the
|
||||
/// first endpoint URL (from <see cref="OpcUaClientDriverOptions.EndpointUrls"/> or the
|
||||
/// convenience <see cref="OpcUaClientDriverOptions.EndpointUrl"/> fallback), performs a
|
||||
/// TCP-connect preflight to detect closed ports quickly, then executes a real OPC UA
|
||||
/// <c>GetEndpoints</c> discovery handshake (mirroring the driver's
|
||||
/// <c>SelectMatchingEndpointAsync</c>) to confirm the remote process is actually an OPC UA
|
||||
/// server. No session is opened and no authentication is required.
|
||||
/// </summary>
|
||||
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)
|
||||
/// <summary>
|
||||
/// Builds a minimal <see cref="ApplicationConfiguration"/> suitable for unauthenticated
|
||||
/// discovery (GetEndpoints). No PKI stores, no session certificate — discovery traffic
|
||||
/// is plain-text and does not require a client certificate.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="OpcUaClientDriverProbe"/>. Uses an in-process
|
||||
/// <see cref="TcpListener"/> 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.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDriverProbeTests
|
||||
{
|
||||
private readonly OpcUaClientDriverProbe _probe = new();
|
||||
|
||||
// ── 1. Invalid JSON ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Invalid JSON returns Ok=false with a message containing "invalid".
|
||||
/// </summary>
|
||||
[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 ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Config JSON that resolves to no endpoint URL returns Ok=false with a message
|
||||
/// indicating no host/port was found.
|
||||
/// </summary>
|
||||
[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 ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Pointing at a TCP port that is not open returns Ok=false with a "Connect failed"
|
||||
/// message from the socket layer.
|
||||
/// </summary>
|
||||
[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) ────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user