Files
lmxopcua/docs/drivers/TestConnectProbes.md
T
Joseph Doherty fdc9473819 fix(twincat): Test Connect probe no longer dials the AmsNetId as an IP (#863)
TwinCATDriverProbe ran a first-phase TCP check against the AmsNetId's
first four octets on the AMS port (801/851). An AmsNetId is not an IP and
the AMS port is not a TCP port, so Test Connect timed out against every
real runtime (Z35474: dialled 5.118.56.93, an unrelated public host).

The probe now opens no socket of its own: it takes the driver's path,
AdsClient.Connect + ReadStateAsync through the local AMS router, bounded
by the probe budget (Task.Run + WaitAsync), and classifies failures as
no local AMS router / no route or target unreachable / timeout / other.
The degrade-to-green 'TCP reachability only' outcome is gone - with no
TCP check nothing was contacted, so a missing router is reported red.

Tests: the regression test listens on the AmsNetId-derived endpoint and
asserts no connection arrives (fails on the old probe); classification
and budget tests drive the handshake through an internal seam.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 12:13:55 -05:00

161 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Test-Connect Probes — Protocol Handshakes
Each driver's **Test-Connect** button in the AdminUI runs a probe against the
form's current config (never the persisted row, never the live driver actor).
Before Phase 5 (shipped 2026-06-16) every probe was a bare TCP `ConnectAsync`
— a live-but-rejecting device showed a healthy green tick, and the operator
only discovered the truth when the driver faulted at deploy. Phase 5 replaced
each TCP-only probe with a **real protocol handshake** so a reachable-but-wrong
or actively-rejecting endpoint now reads RED.
The `IDriverProbe` / `DriverProbeResult` contract and DI registration are
unchanged. Probes run in a transient actor with a timeout clamp of 1–60 s
and must not mutate any state.
For the AdminUI probe flow (button → `AdminOperationsActor` → transient probe
actor), see
[`docs/plans/2026-05-28-adminui-driver-pages-design.md`](../plans/2026-05-28-adminui-driver-pages-design.md)
§4.
---
## Result contract
All probes return a consistent `DriverProbeResult(bool Ok, string? Message, TimeSpan? Latency)`.
The message templates below are uniform across all 8 drivers:
| Outcome | `Ok` | Message template |
|---------|------|-----------------|
| TCP connect fails | `false` | `"Connect failed: {SocketErrorCode}"` |
| TCP ok + handshake ok | `true` | driver-specific descriptive string (see table below) |
| TCP ok but handshake rejected | `false` | `"Reachable at {host}:{port} but {proto} handshake failed: {detail}"` |
| Timeout | `false` | `"Probe timed out after {n}s."` |
The third row is the key new behavior: a reachable device that answers on the
port but rejects the protocol-level handshake now surfaces a `false` result
with a human-readable explanation rather than a false-green TCP-open tick.
---
## Per-driver handshake
| Driver | Handshake | Ok message | Dev-rig target |
|--------|-----------|------------|----------------|
| **Modbus** | FC03 (Read Holding Registers, qty 1 @ addr 0) via `ModbusTcpTransport`. A Modbus exception PDU still proves a real Modbus device → `Ok`. A non-MBAP reply → handshake fail. | `"Modbus FC03 OK"` | `10.100.0.35:5020` (Modbus sim) |
| **OpcUaClient** | `DiscoveryClient.GetEndpointsAsync` — no session, no app-cert, no auth. ≥ 1 endpoint → `Ok`. A non-OPC-UA TCP server throws or times out → handshake fail. | `"OPC UA: N endpoint(s)"` | `opc.tcp://10.100.0.35:50000` (opc-plc) |
| **S7** | `Plc.OpenAsync` (COTP CR/CC + S7 setup-communication), check `IsConnected`, then `Close`. Wrong rack/slot or a non-S7 server causes `OpenAsync` to throw → handshake fail. | `"S7 connected (CPU …)"` | `10.100.0.35:1102` (python-snap7 sim) |
| **AbCip** | `libplctag` Tag `InitializeAsync` (EIP session + CIP Forward Open). A CIP-level error such as tag-not-found still proves the controller answered CIP → `Ok`. A session/ForwardOpen/connect error → handshake fail. | `"CIP session OK"` | `10.100.0.35:44818` (CIP sim) |
| **AbLegacy** | Same `libplctag` `InitializeAsync` handshake as AbCip, PCCC protocol family. | `"CIP session OK"` (PCCC family) | Deferred — no PLC5/SLC sim |
| **TwinCAT** | `AdsClient.Connect` + `ReadStateAsync` through the **local AMS router** — no TCP pre-check. See [TwinCAT: no TCP pre-check](#twincat-no-tcp-pre-check) below. | `"ADS state: {state}"` | Z35474 (production TC3 press, read-only, via an in-process TcpRouter) |
| **FOCAS** | `cnc_allclibhndl3` via a direct `DllImport("fwlib32")` in the probe. See [degrade semantics](#focas-degrade) below. | `"FOCAS handle OK"` | Deferred — no CNC + FWLIB |
| **Galaxy** | gRPC unary call to `GalaxyRepository.TestConnection` on the configured mxaccessgw endpoint. See [auth-rejection rule](#galaxy-auth-rejection) below. | `"gateway gRPC OK"` | `http://10.100.0.48:5120` (mxaccessgw) |
| **Heidenhain** | LSV2 `A_LG INSPECT` (password resolved from its `secret:`/`env:` reference) + `R_VR`, through the same read-only allowlist as the driver; a refusal or malformed version record → fail. No logout — the socket close ends the session. | `"LSV2 INSPECT session OK at host:port: <model> NC <version>"` | Deferred — no LSV2 control; simulator-proven |
| **MQTT** | One MQTT `CONNECT` (no SUBSCRIBE, no publish) under a `-probe-`-suffixed client id, then disconnect. **A rejected CONNACK is a returned result code, not an exception** — MQTTnet 5 does not throw — so the probe inspects `ResultCode` and reuses the driver's own `DescribeConnackRejection`, making the button and the running driver word a rejection identically. | `"MQTT CONNECT OK"` | `10.100.0.35:8883` (Mosquitto TLS+auth fixture) |
**Historian.Wonderware** had a TCP `Hello`→`HelloAck` handshake probe before Phase 5, but the
Wonderware historian backend (and its driver-type / probe) has since been **retired** — the historian
backend is now the external HistorianGateway (a gRPC client package, not a probed `IDriver`). See
[`Historian.Wonderware.md`](Historian.Wonderware.md) (retired stub) and [`../Historian.md`](../Historian.md).
---
## Degrade semantics
Some drivers have environmental constraints that can prevent the handshake
from running on certain hosts. The **degradation principle** is: the probe
must never produce a result *worse* than today's TCP-only probe. A genuine
protocol rejection from a reachable device is a correct RED; an inability to
*run* the handshake at all (no FWLIB) degrades to the
existing TCP-reachability message — still a green tick but annotated. TwinCAT
no longer degrades (see below).
### TwinCAT: no TCP pre-check
TwinCAT is the exception to the "TCP connect fails" row above: it opens **no socket of its own**
(#863). The driver has no IP field — an AMS Net ID is a Beckhoff identifier the AMS router maps to a
host through its route table, and the AMS port (801/851) is not a TCP port. The probe therefore takes
exactly the driver's path (`AdsTwinCATClient.ConnectAsync`): `AdsClient.Connect(netId, port)` through
the local AMS router (TwinCAT XAR, or `Beckhoff.TwinCAT.Ads.TcpRouter` hosted in-process), then one
read-only `ReadStateAsync`, all inside the probe budget. Until #863 a first-phase check dialled the Net
ID's first four octets as an IPv4 address on the AMS port — for Z35474 (`5.118.56.93.1.1`) an
unrelated public host — so Test Connect timed out against every real runtime.
| Outcome | `Ok` | Message |
|---------|------|---------|
| ReadState succeeds | `true` | `"ADS state: {state}"` (Run / Config / Stop) |
| No local AMS router (Beckhoff "Check for a running TwinCAT router instance!", `ClientPortNotOpen`, router not active, ADS client cannot load) | `false` | `"No local AMS router on this host — start TwinCAT XAR or host the Beckhoff TcpRouter; the target was not contacted (…)"` |
| Router answered but cannot reach the target (`TargetMachineNotFound`, `TargetPortNotFound`, port/access refused, host unreachable) | `false` | `"No route to {netId}:{port} or target unreachable: {code} — check the local AMS router has a route …"` |
| Sync/device timeout code, or the budget expired | `false` | `"Probe timed out after {n}s waiting for ADS state from {netId}:{port} …"` |
| Any other ADS error | `false` | `"ADS handshake with {netId}:{port} failed: {code}"` |
There is **no degrade-to-green** any more: with no TCP check, a host that cannot run the handshake
has contacted nothing, so it reports the missing router rather than a green tick. A stock admin node
has no AMS router, so TwinCAT Test Connect is red there by design — the driver needs the same router.
### FOCAS degrade
On a Windows host with the FANUC FWLIB shared library present:
- `cnc_allclibhndl3` is called via a direct `DllImport("fwlib32")` declared in
the probe (the production `Wire.WireFocasClient` is a pure-managed FOCAS/2 TCP
client, not an FWLIB P/Invoke, so the probe carries its own native binding).
A successful handle allocation → `Ok=true`, `"FOCAS handle OK"`.
- A CNC-level rejection → handshake fail.
On dev, Linux, or macOS (no native FWLIB — `UnimplementedFocasClientFactory`
gates the driver):
- `DllNotFoundException` / `NotSupportedException` is caught and the probe
degrades to TCP-reachability: `Ok=true`,
`"(FOCAS handshake unavailable on this host — FWLIB absent, TCP reachability only)"`.
### Galaxy auth-rejection rule
The probe builds the gRPC channel from the form's config and issues one
lightweight unary call. It does **not** resolve `secretref:` secrets — the
key string in the transient config (possibly empty or unresolved) is used as-is.
- `Unavailable` / transport failure → `Ok=false` (gateway is down or unreachable).
- `Unauthenticated` / `PermissionDenied` → **`Ok=true`**,
`"gateway reachable & speaking gRPC (auth not checked)"` — an auth rejection
proves a live mxaccessgw gRPC server. This is the correct result: the driver's
own session-layer will handle auth; the probe is testing reachability only.
The mxaccessgw client surfaces a rejected key as a typed
`MxGatewayAuthenticationException` / `MxGatewayAuthorizationException`, **not** a
raw `RpcException` — the probe catches both and maps them to the reachable result
above. (Live verification on `10.100.0.48:5120` with no key returns
`MxGatewayAuthenticationException("Missing or invalid API key.")` → `Ok=true`.)
> **Config note:** `UseTls` must match the endpoint scheme — `UseTls:false` for an
> `http://` (h2c) gateway, `UseTls:true` for `https://`. A mismatch fails the
> client's own validation (the same constraint the Galaxy driver enforces).
---
## Live-verify scope
| Driver | Live-verify status | Notes |
|--------|-------------------|-------|
| Modbus | Verified | Dev-rig sim `10.100.0.35:5020`; green vs sim, RED vs wrong port / non-Modbus server, timeout vs black-hole IP |
| OpcUaClient | Verified | opc-plc `10.100.0.35:50000`; same three-scenario matrix |
| S7 | Verified | python-snap7 `10.100.0.35:1102` |
| AbCip | Verified | CIP sim `10.100.0.35:44818` |
| Galaxy | Verified | mxaccessgw `10.100.0.48:5120`; `Unauthenticated` reply counts as Ok |
| MQTT | Verified | Mosquitto `10.100.0.35:8883`; live suite covers TLS+auth Ok, foreign-CA rejection, and wrong-password rejection surfacing rather than reporting healthy |
| AbLegacy | Deferred | No PLC5/SLC sim; unit-proven + code path identical to AbCip |
| TwinCAT | Deferred | No ADS target; unit-proven + degrade guard tested |
| FOCAS | Deferred | No CNC + FWLIB on dev host; degrade guard is the CI-observable path |
| Heidenhain | Deferred | No LSV2 control; probe path shared with the simulator-tested driver (#803) |
---
## Implementation references
- Phase 5 design: `docs/plans/2026-06-16-stillpending-phase-5-probes-design.md`
- Parent roadmap: `docs/plans/2026-06-15-stillpending-backlog-design.md` §Phase 5
- AdminUI probe flow: `docs/plans/2026-05-28-adminui-driver-pages-design.md` §4
- Per-driver probe implementations: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.<Type>/<Type>DriverProbe.cs`
- `IDriverProbe` contract: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverProbe.cs`
- Probe dispatch + timeout clamp: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Actors/AdminOperationsActor.cs` (around line 284)