#!/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: # # > server EIP frame, uppercase hex, no spaces> # < 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= scripts/ab-capture/capture.sh run -- # scripts/ab-capture/capture.sh tohex # scripts/ab-capture/capture.sh client # # `client` builds (once) and runs scripts/ab-capture/client — the console program # over libplctag 1.5.2 that took the committed fixtures: one tag, one Initialize + # Read/Write, then Dispose + plc_tag_shutdown so the Forward Close reaches the wire. # No UnregisterSession is ever sent; the session ends with a TCP RST. Example: # # CAPDIR=$PWD/caps scripts/ab-capture/capture.sh run otopcua-ab-server-controllogix logix-read-dint -- \ # scripts/ab-capture/capture.sh client read 10.100.0.35:44818 1,0 ControlLogix TestDINT dint # # See Captures/README.md for every invocation used. # # 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 client) here="$(cd "$(dirname "$0")" && pwd)" dll="$here/client/bin/Release/net10.0/ab-capture-client.dll" [[ -f "$dll" ]] || dotnet build "$here/client/ab-capture-client.csproj" -c Release -v q >&2 exec dotnet "$dll" "$@" ;; 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,37p' "$0"; exit 2 ;; esac