b085360454
Review round 1 on the golden frames: - Re-captured logix-read-dint-array16 (element i = ((i+1)<<24)|(i+1)) and logix-read-bool (= 1) so neither decode oracle is all zeros. ab_server rejects libplctag's own BOOL write with CIP 0x03 (two data bytes for a one-byte BOOL), so the BOOL is seeded by a raw UCMM Write Tag (client seed-bool). - Committed the capture client as scripts/ab-capture/client (outside the slnx, CPM off, libplctag 1.5.2 pinned); capture.sh gains a `client` subcommand. - README documents simulator-only behaviour: zeroed reply sender context, STRING typed D0 00 instead of A0 02 + handle, and the BOOL write refusal. - capture.sh no longer claims UnregisterSession reaches the wire (session ends in RST). - CaptureFileTests also check the CPF walk ends at the frame end and that every reply service is its request service | 0x80.
109 lines
4.5 KiB
Bash
Executable File
109 lines
4.5 KiB
Bash
Executable File
#!/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>
|
|
# scripts/ab-capture/capture.sh client <client args...>
|
|
#
|
|
# `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
|