feat(probe): AbCip Test-Connect opens a real CIP session (libplctag init)
Replace the bare-TCP-only AbCipDriverProbe with a two-phase check: Phase 1 keeps the existing TCP preflight; Phase 2 initialises a LibplctagTagRuntime against the first device to open a real EIP session and CIP Forward Open, so a live-but-rejecting CIP endpoint reads red instead of a false-positive green. Status mapping: ErrorNotFound / ErrorNoMatch / ErrorBadDevice → reachable (controller answered CIP, probe tag absent); ErrorTimeout / ErrorBadConnection / ErrorBadGateway / ErrorWinsock / ErrorOpen / ErrorClose / ErrorRead / ErrorWrite / ErrorBadReply / ErrorRemoteErr / ErrorPartial / ErrorAbort → handshake failed. LibPlcTagException message text is used as a secondary signal for the reachable-exception path. All other statuses default to handshake-failed (conservative). Add AbCipDriverProbeTests: invalid JSON, no devices, malformed host address, closed-port TCP rejection, and black-hole timeout — all offline-determinable. Happy path + CIP-error path covered live against the CIP sim.
This commit is contained in:
@@ -2,16 +2,36 @@ using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap TCP-connect probe for the <see cref="AbCipDriverOptions"/>-shaped driver config.
|
||||
/// Opens a socket to the first device's gateway host + EtherNet/IP 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 exchange any CIP bytes —
|
||||
/// a richer EIP session-open probe is a documented follow-up.
|
||||
/// Two-phase Test-Connect probe for the <see cref="AbCipDriverOptions"/>-shaped driver config.
|
||||
/// Phase 1: bare TCP connect to the first device's gateway host + EtherNet/IP port to
|
||||
/// quickly reject unreachable targets. Phase 2: initialises a libplctag <see cref="Tag"/>
|
||||
/// against the same device to open a real EIP session and perform a CIP Forward Open —
|
||||
/// confirming the remote endpoint actually speaks EtherNet/IP CIP, not just accepts TCP.
|
||||
/// A device that accepts the TCP connection but is not a CIP controller (wrong CIP path,
|
||||
/// non-CIP server) returns <c>Ok = false</c> with a "handshake failed" message instead of
|
||||
/// a false-positive green tick.
|
||||
/// <para>
|
||||
/// Status-code discrimination: libplctag statuses that indicate the controller answered
|
||||
/// the CIP session but could not resolve the probe tag
|
||||
/// (<see cref="Status.ErrorNotFound"/>, <see cref="Status.ErrorNoMatch"/>,
|
||||
/// <see cref="Status.ErrorBadDevice"/>) are treated as "reachable — Ok = true" because
|
||||
/// a non-existent tag name proves CIP connectivity. Statuses that indicate a transport or
|
||||
/// session failure (<see cref="Status.ErrorTimeout"/>, <see cref="Status.ErrorBadConnection"/>,
|
||||
/// <see cref="Status.ErrorBadGateway"/>, <see cref="Status.ErrorWinsock"/>,
|
||||
/// <see cref="Status.ErrorOpen"/>, <see cref="Status.ErrorClose"/>,
|
||||
/// <see cref="Status.ErrorRead"/>, <see cref="Status.ErrorWrite"/>,
|
||||
/// <see cref="Status.ErrorBadReply"/>, <see cref="Status.ErrorRemoteErr"/>,
|
||||
/// <see cref="Status.ErrorPartial"/>, <see cref="Status.ErrorAbort"/>) are treated as
|
||||
/// "handshake failed — Ok = false". All other error statuses default to
|
||||
/// "handshake failed" (conservative).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AbCipDriverProbe : IDriverProbe
|
||||
{
|
||||
@@ -32,17 +52,19 @@ public sealed class AbCipDriverProbe : 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);
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
var (parsed, firstDevice) = ExtractTarget(opts);
|
||||
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
|
||||
var host = parsed.Gateway;
|
||||
var port = parsed.Port;
|
||||
|
||||
// Phase 1: bare TCP preflight — fast rejection for unreachable hosts.
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(host, port, ct);
|
||||
sw.Stop();
|
||||
return new(true, null, sw.Elapsed);
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
@@ -56,17 +78,106 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
{
|
||||
return new(false, ex.Message, null);
|
||||
}
|
||||
|
||||
// Phase 2: CIP session via libplctag Tag.InitializeAsync. Open a real EIP session +
|
||||
// Forward Open to confirm the remote endpoint speaks CIP, not just TCP.
|
||||
var profile = AbCipPlcFamilyProfile.ForFamily(firstDevice?.PlcFamily ?? AbCipPlcFamily.ControlLogix);
|
||||
|
||||
// Prefer the first declared tag path as the probe tag name (it must exist on the PLC).
|
||||
// Fall back to a benign system attribute that is present on all ControlLogix/CompactLogix
|
||||
// controllers (@raw_cpu_type); still returns ErrorNotFound on Micro800 / MicroLogix, which
|
||||
// is sufficient to confirm CIP reachability.
|
||||
var tagName = opts.Tags.FirstOrDefault(
|
||||
t => string.Equals(t.DeviceHostAddress, firstDevice?.HostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
?.TagPath
|
||||
?? opts.Tags.FirstOrDefault()?.TagPath
|
||||
?? "@raw_cpu_type";
|
||||
|
||||
var p = new AbCipTagCreateParams(
|
||||
Gateway: parsed.Gateway,
|
||||
Port: parsed.Port,
|
||||
CipPath: parsed.CipPath,
|
||||
LibplctagPlcAttribute: profile.LibplctagPlcAttribute,
|
||||
TagName: tagName,
|
||||
Timeout: timeout,
|
||||
AllowPacking: firstDevice?.AllowPacking ?? profile.SupportsRequestPacking,
|
||||
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize);
|
||||
|
||||
var rt = new LibplctagTagRuntime(p);
|
||||
try
|
||||
{
|
||||
await rt.InitializeAsync(ct);
|
||||
sw.Stop();
|
||||
|
||||
// InitializeAsync completed without throwing — either the tag was found (Ok) or
|
||||
// libplctag returned a non-exception status. Check via GetStatus().
|
||||
var statusCode = (Status)rt.GetStatus();
|
||||
if (IsReachableStatus(statusCode))
|
||||
{
|
||||
var msg = statusCode == Status.Ok
|
||||
? "CIP session OK"
|
||||
: "CIP session OK (controller reachable; probe tag not found)";
|
||||
return new(true, msg, sw.Elapsed);
|
||||
}
|
||||
|
||||
// Non-Ok but non-exception: a session/transport error surfaced in the status word.
|
||||
return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {statusCode}", null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (LibPlcTagException ex)
|
||||
{
|
||||
// LibPlcTagException carries the Status; classify as reachable vs transport failure.
|
||||
if (IsReachableException(ex))
|
||||
return new(true, "CIP session OK (controller reachable; probe tag not found)", sw.Elapsed);
|
||||
|
||||
return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rt.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static (string host, int port) ExtractTarget(AbCipDriverOptions opts)
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> for libplctag statuses that indicate the CIP controller answered
|
||||
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
|
||||
/// reachability. Returns <c>true</c> for <see cref="Status.Ok"/> (tag was found).
|
||||
/// </summary>
|
||||
private static bool IsReachableStatus(Status s) => s is
|
||||
Status.Ok or
|
||||
Status.ErrorNotFound or
|
||||
Status.ErrorNoMatch or
|
||||
Status.ErrorBadDevice;
|
||||
|
||||
/// <summary>
|
||||
/// Inspects a <see cref="LibPlcTagException"/> message to decide whether the error is a
|
||||
/// "controller answered CIP but tag not found" condition (reachable) versus a session /
|
||||
/// transport failure (unreachable). Falls back to checking the exception message for
|
||||
/// known tag-not-found phrases used by the libplctag native library.
|
||||
/// </summary>
|
||||
private static bool IsReachableException(LibPlcTagException ex)
|
||||
{
|
||||
// libplctag wraps the Status in the message as "STATUS_NOT_FOUND", "STATUS_NO_MATCH", etc.
|
||||
var msg = ex.Message ?? string.Empty;
|
||||
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
|
||||
{
|
||||
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
|
||||
var firstDevice = opts.Devices.FirstOrDefault();
|
||||
if (firstDevice is null) return (string.Empty, 0);
|
||||
if (firstDevice is null) return (null, null);
|
||||
|
||||
var parsed = AbCipHostAddress.TryParse(firstDevice.HostAddress);
|
||||
if (parsed is null) return (string.Empty, 0);
|
||||
|
||||
return (parsed.Gateway, parsed.Port);
|
||||
return (parsed, firstDevice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AbCipDriverProbe"/>. Covers the three offline-determinable
|
||||
/// failure paths: invalid JSON, missing host/port, and an unreachable (TCP-level rejected)
|
||||
/// target. The happy path (real CIP EIP session + <c>Ok=true</c>) and the
|
||||
/// "controller reachable but probe tag not found" path require a live CIP controller or
|
||||
/// simulator and are covered in integration tests against the AB-CIP Docker fixture
|
||||
/// (<c>10.100.0.35:44818</c>).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverProbeTests
|
||||
{
|
||||
private static readonly AbCipDriverProbe Probe = new();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Invalid JSON
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Invalid JSON returns Ok=false with a message containing "invalid".</summary>
|
||||
[Fact]
|
||||
public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage()
|
||||
{
|
||||
var result = await Probe.ProbeAsync(
|
||||
"not-valid-json{{{",
|
||||
TimeSpan.FromSeconds(3),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ToLowerInvariant().ShouldContain("invalid");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Config with no device / no host
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A config with an empty Devices list returns Ok=false with a "no host/port" message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task NoDevices_Returns_OkFalse_WithNoHostPortMessage()
|
||||
{
|
||||
var result = await Probe.ProbeAsync(
|
||||
"{\"devices\":[]}",
|
||||
TimeSpan.FromSeconds(3),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ShouldContain("no host/port");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A config whose first device carries a malformed host address (not ab://) returns
|
||||
/// Ok=false with a "no host/port" message because <see cref="AbCipHostAddress.TryParse"/>
|
||||
/// returns null for the address.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
|
||||
{
|
||||
// "not-an-ab-url" is not an ab:// URL — TryParse returns null.
|
||||
var result = await Probe.ProbeAsync(
|
||||
"{\"devices\":[{\"hostAddress\":\"not-an-ab-url\"}]}",
|
||||
TimeSpan.FromSeconds(3),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
result.Message!.ShouldContain("no host/port");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Unreachable target — TCP connect fails at Phase 1
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A closed port causes <c>ConnectAsync</c> to throw <see cref="SocketException"/>;
|
||||
/// the probe must return Ok=false with a "Connect failed" message.
|
||||
/// The port is reserved then released so the OS refuses the connection immediately
|
||||
/// (no black-hole delay needed).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage()
|
||||
{
|
||||
// Reserve an ephemeral port then release it so the port is definitely closed.
|
||||
var reserved = new TcpListener(IPAddress.Loopback, 0);
|
||||
reserved.Start();
|
||||
var port = ((IPEndPoint)reserved.LocalEndpoint).Port;
|
||||
reserved.Stop();
|
||||
|
||||
// Use a short timeout — we only need Phase 1 to fail fast.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
TestContext.Current.CancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
|
||||
var configJson = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]}}";
|
||||
var result = await Probe.ProbeAsync(
|
||||
configJson,
|
||||
TimeSpan.FromSeconds(3),
|
||||
cts.Token);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
// Phase 1 SocketException → "Connect failed: <SocketErrorCode>"
|
||||
result.Message!.ShouldContain("Connect failed");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A target on a non-routable IP (192.0.2.1 — TEST-NET-1, RFC 5737) causes the
|
||||
/// probe to time out at Phase 1 when given a very short cancellation window.
|
||||
/// The probe must return Ok=false with a "timed out" message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BlackHoleTarget_TimedOut_Returns_OkFalse_WithTimedOutMessage()
|
||||
{
|
||||
// 192.0.2.1 is TEST-NET-1 (RFC 5737) — reserved, non-routable, connection black-holes.
|
||||
// Use a 1-second cancellation so the test stays fast on any network.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
TestContext.Current.CancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(1));
|
||||
|
||||
var configJson = "{\"devices\":[{\"hostAddress\":\"ab://192.0.2.1/1,0\"}]}";
|
||||
var result = await Probe.ProbeAsync(
|
||||
configJson,
|
||||
TimeSpan.FromSeconds(1),
|
||||
cts.Token);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldNotBeNull();
|
||||
// Either "timed out" (OperationCanceledException) or "Connect failed" (EHOSTUNREACH on
|
||||
// some network stacks) — both are acceptable "not reachable" outcomes.
|
||||
var lower = result.Message!.ToLowerInvariant();
|
||||
(lower.Contains("timed out") || lower.Contains("connect failed")).ShouldBeTrue(
|
||||
$"Expected 'timed out' or 'Connect failed' but got: {result.Message}");
|
||||
result.Latency.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user