#!/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 # # 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