test(ab-transport): golden libplctag<->ab_server frame captures for Logix, Micro800 and PCCC

17 .hex fixtures captured from libplctag 1.5.2 (native 2.6.0) against
otopcua-ab-server:libplctag-release, plus the CaptureFile loader, its
length/alternation tests, and the re-runnable scripts/ab-capture/capture.sh.

Observed and recorded in Captures/README.md "Routing observed":
- Micro800 is NOT UCMM under libplctag 2.6.0: it opens a Large Forward Open
  (path 20 02 24 01) and reads connected; use_connected_msg=0 is ignored.
- SLC500/MicroLogix/PLC-5 send Execute PCCC 0x4B unconnected and unwrapped;
  a configured path=1,0 never reaches the wire.
- Only Large FO 0x5B was observed; no 0x54 fallback occurred.
- LogixPccc is unreachable against ab_server (CIP 0x08); no fixture committed.
This commit is contained in:
Joseph Doherty
2026-10-07 15:50:00 -04:00
parent a0897fda96
commit 1201d5fcfb
22 changed files with 507 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Golden-frame capture recipe for the native AB transport (Task 4 of
# docs/plans/2026-10-07-native-ab-transport.md).
#
# Captures the EtherNet/IP conversation between libplctag (the libplctag 1.5.2
# NuGet wrapper, native core 2.6.0) and the ab_server simulator fixtures, and
# converts each pcap into the `.hex` line format consumed by
# tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures:
#
# > <client->server EIP frame, uppercase hex, no spaces>
# < <server->client EIP frame>
# # comment
#
# Packets are captured INSIDE the simulator container's network namespace
# (netshoot sidecar), where ab_server always listens on 44818 — so the same
# filter works for the AbLegacy containers whose host ports are 44819-44821.
# tshark also runs in the netshoot image on the docker host, so nothing needs
# installing on the workstation beyond python3.
#
# Usage:
# CAPDIR=<fresh empty dir> scripts/ab-capture/capture.sh run <container> <name> -- <client command...>
# scripts/ab-capture/capture.sh tohex <pcap> <out.hex>
#
# The client command is whatever drives libplctag; the session used for the
# committed fixtures is a ~60-line console program over libplctag 1.5.2 (one tag,
# one Initialize + Read/Write, then Dispose + plc_tag_shutdown so the Forward Close
# and Unregister Session reach the wire). See Captures/README.md for every
# invocation.
#
# Fixtures (one AbCip profile at a time — they all bind host :44818):
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d'
# ssh dohertj2@10.100.0.35 'cd ~/otopcua-ablegacy && docker compose --profile slc500 up -d'
set -euo pipefail
DOCKER_HOST_SSH="${DOCKER_HOST_SSH:-dohertj2@10.100.0.35}"
NETSHOOT="${NETSHOOT:-nicolaka/netshoot:v0.13}"
REMOTE_DIR="${REMOTE_DIR:-/tmp/abcap}"
cmd="${1:-}"; shift || true
case "$cmd" in
run)
container="$1"; name="$2"; shift 2
[[ "${1:-}" == "--" ]] && shift
: "${CAPDIR:?set CAPDIR to a fresh empty directory}"
sidecar="abcap-$name"
ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR && rm -f $REMOTE_DIR/$name.pcap && \
docker run -d --rm --name $sidecar --net=container:$container -v $REMOTE_DIR:/cap $NETSHOOT \
tcpdump -i eth0 -U -w /cap/$name.pcap tcp port 44818 >/dev/null"
sleep 2 # let tcpdump open the interface before the client connects
"$@" || echo "client exited non-zero: $?" >&2
sleep 1
ssh "$DOCKER_HOST_SSH" "docker stop $sidecar >/dev/null"
scp -q "$DOCKER_HOST_SSH:$REMOTE_DIR/$name.pcap" "$CAPDIR/$name.pcap"
echo "$CAPDIR/$name.pcap"
;;
tohex)
pcap="$1"; out="$2"
base="$(basename "$pcap")"
ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR"
scp -q "$pcap" "$DOCKER_HOST_SSH:$REMOTE_DIR/tohex-$base"
# srcport + payload of every data-bearing, non-retransmitted segment, in capture order.
ssh "$DOCKER_HOST_SSH" "docker run --rm -v $REMOTE_DIR:/cap $NETSHOOT \
tshark -r /cap/tohex-$base -T fields -e tcp.srcport -e tcp.payload \
-Y 'tcp.len>0 && !tcp.analysis.retransmission'" \
| python3 -I -c '
import sys
# Re-join TCP segments into EIP encapsulation frames per direction, using the
# 24-byte header length field (offset 2, UInt16 LE) to find frame boundaries.
bufs = {True: b"", False: b""}
for line in sys.stdin:
line = line.strip()
if not line:
continue
port, payload = line.split("\t")
from_client = port != "44818" # srcport 44818 => server->client
bufs[from_client] += bytes.fromhex(payload.replace(":", ""))
b = bufs[from_client]
while len(b) >= 24:
total = 24 + int.from_bytes(b[2:4], "little")
if len(b) < total:
break
print(("> " if from_client else "< ") + b[:total].hex().upper())
b = b[total:]
bufs[from_client] = b
for k, rest in bufs.items():
if rest:
sys.exit(f"trailing partial frame ({len(rest)} bytes, client={k})")
' > "$out"
echo "$out: $(grep -c '^[<>]' "$out") frames"
;;
*)
sed -n '2,32p' "$0"; exit 2 ;;
esac