Skip to content

Repository files navigation

ethernetip-interop — EtherNet/IP Interoperability Testing with Docker

A Docker-based interoperability testing framework for EtherNet/IP (CIP over TCP and UDP), providing independent target (adapter) and originator (scanner) implementations, deterministic fixtures, and automated cross-stack self-tests. Test EtherNet/IP libraries against established implementations: OpENer, EIPScanner, cpppo, pycomm3, libplctag, gologix, pylogix, etherip, eip-rs, node-ethernet-ip, EEIP.NET, loki-os's go-ethernet-ip and cB-Sigma's enip. Each reference is containerized behind the same command-line contract, so a library can be tested against real implementation diversity without integrating third-party protocol stacks into its own code.

Adapter Implementation Language Server (target) Client (originator) Image
opener OpENer master 0e1ab7e (v2.3-591) C yes ¹ no ghcr.io/otfabric/ethernetip-interop-opener
eipscanner EIPScanner master 12c89a5 (1.3.0-33) C++ no yes ghcr.io/otfabric/ethernetip-interop-eipscanner
cpppo cpppo 5.2.5 Python yes ² yes ghcr.io/otfabric/ethernetip-interop-cpppo
pycomm3 pycomm3 1.2.16 Python no yes ghcr.io/otfabric/ethernetip-interop-pycomm3
libplctag libplctag 2.7.2 C yes ³ yes ghcr.io/otfabric/ethernetip-interop-libplctag
gologix gologix 0.44.0-beta Go yes ⁴ yes ghcr.io/otfabric/ethernetip-interop-gologix
pylogix pylogix 1.1.6 Python no yes ghcr.io/otfabric/ethernetip-interop-pylogix
etherip etherip main ce0688a (V1.0-67) Java no yes ghcr.io/otfabric/ethernetip-interop-etherip
eiprs eip-rs (rseip) main 16e2236 (0.3.1-2) Rust no yes ghcr.io/otfabric/ethernetip-interop-eiprs
nodeeip node-ethernet-ip 2.0.0 TypeScript no yes ghcr.io/otfabric/ethernetip-interop-nodeeip
eeip EEIP.NET master 58b91a0 (1.6.0) C# no yes ⁵ ghcr.io/otfabric/ethernetip-interop-eeip
lokieip go-ethernet-ip (loki-os) master 535f797 (2022-12-29) Go no yes ghcr.io/otfabric/ethernetip-interop-lokieip
cbsigma enip (cB-Sigma) 0.2.0 Go yes ⁶ yes ⁷ ghcr.io/otfabric/ethernetip-interop-cbsigma

¹ OpENer's sample application, a target with Class 1 I/O; it serves the opener-sample fixture (its compiled-in identity and assemblies). ² A Logix-style simulator serving the logix fixture; no Class 1 I/O. ³ libplctag's ab_server PLC simulator serving the logix fixture's tags; no ListIdentity, no attributes, no tag list. ⁴ A Logix tag server with Class 1 I/O, serving the logix and adapter fixtures. ⁵ A scanner with Class 1 I/O, T->O multicast and listen-only connections. ⁶ enip's server package (the controller of cB-Sigma's enip-sim since its v0.2.0), serving the logix and logix-structures fixtures: tags, structures, the tag list and templates, Class 3 and Class 1 I/O; the identity revision is fixed at 33.11. ⁷ Explicit messaging, Logix tags over Class 3 with fragmentation, the tag list with templates, and Class 1 I/O as scanner (no Forward_Close of I/O connections).

The stacks do not all do the same: OpENer has no Multiple Service Packet and no Unconnected_Send, EIPScanner no Class 3 and no multicast, cpppo no Class 1 and no tag list, pycomm3's Logix driver needs a tag list from its target, and the Logix clients differ in bit writes, BOOL arrays, structures and fragmentation. Each image says what it supports (print-capabilities); see docs/CAPABILITIES.md.

Docs: docs/CONTAINER_CONTRACT.md (commands, JSON documents, exit codes, networking) · docs/FIXTURES.md (the simulated targets) · docs/CAPABILITIES.md · docs/COMPATIBILITY.md (pins, cross-stack matrix) · REQUIREMENTS.md · PLAN.md · RELEASE.md

Table of contents

What this repository is, and is not

It owns the reference implementations, their adapters, the fixtures, the container contract, the images and the self-tests that show the references agree with each other.

It does not contain, import, build or know about any library under test, apart from one image kept out of its releases. otfabric/go-ethernetip is a consumer: it pulls the images and owns its own scenarios, assertions and compatibility claims. A passing self-test here says the references interoperate; it says nothing about a consumer.

The fixtures describe what a target is; the adapters make each implementation behave that way; the consumer decides what to assert.

Quick start

Needs Docker and jq.

make images      # build the four adapter images for this machine
make smoke       # container contract of each image
make interop     # every client against every server

A reference target and a reference originator on one Docker network (images built by make images, tagged dev; with a release tag instead of dev they are pulled from GHCR):

docker network create eip
docker run -d --rm --name ref --network eip ghcr.io/otfabric/ethernetip-interop-opener:dev server
until docker exec ref test -f /run/ethernetip-interop/ready; do sleep 0.1; done
ip=$(docker inspect -f '{{(index .NetworkSettings.Networks "eip").IPAddress}}' ref)
docker run --rm --network eip ghcr.io/otfabric/ethernetip-interop-eipscanner:dev \
  client io --host "$ip" --config 151 --output 150 --input 100 \
  --output-size 32 --input-size 32 --rpi-ms 10 --duration-ms 1000 \
  --output-data "$(printf 'c3%.0s' $(seq 32))" | jq 'del(.log)'
docker stop ref; docker network rm eip
{
  "schemaVersion": "1.0",
  "adapter": "eipscanner",
  "operation": "io",
  "ok": true,
  "error": null,
  "forwardOpen": { "status": 0 },
  "sent": 76,
  "received": 84,
  "firstInput": "0000…",
  "lastInput": "c3c3…",
  "timedOut": false,
  "forwardClose": { "status": 0 }
}

(abridged: OpENer copies output 150 to input 100, so the output comes back.) docker compose up -d --wait starts OpENer and cpppo on the network ethernetip-interop, with 44818/tcp on 127.0.0.1:44818 and :44819 for explicit messaging from the host; make run-opener and make run-cpppo run one of them the same way. Class 1 I/O does not work from the host through Docker's NAT; see Networking.

Every image has the same commands:

server               a target serving a fixture (opener, cpppo)
client <operation>   an originator running one of: list-identity, discover,
                     list-services, get-attribute, set-attribute,
                     get-attributes-all, multiple-get, identity, io,
                     read-tag, read-tags, write-tag, tag-list
                     (see docs/CONTAINER_CONTRACT.md; not every image has all)
print-capabilities   what this adapter supports, as JSON
print-fixture        a fixture baked into the image
version              adapter and upstream versions, as JSON

OT Fabric's own stack

ghcr.io/otfabric/ethernetip-interop-otfabric is go-ethernetip behind the same contract, so you can test your stack against ours the way we test ours against yours. Despite the name it shares with the reference images, it is not an independent implementation and not part of a release of this repository, so its tags are go-ethernetip versions and manifest.json does not list it: see own/go-ethernetip/README.md.

Using the images from a library's tests

A consumer needs Docker, the image references and its own test code. It does not clone this repository. go-ethernetip's interop package is the example: it runs the four images against its own adapter and originator, each way.

Everything on one Docker network. Create a network per test run and run the references and the library on it, the library in a container too (a statically linked test node mounted into a slim base image is enough). Class 1 I/O needs this: the target sends T->O datagrams to the originator's own address at UDP 2222, which Docker's port publishing hides. Address the containers by their address on the network.

Library as originator, reference as target. Start the server, wait for its ready event or readiness file (never for a delay), then run the library's originator against it:

docker run -d --rm --name ref --network "$net" ghcr.io/otfabric/ethernetip-interop-opener:<tag> server
until docker exec ref test -f /run/ethernetip-interop/ready; do sleep 0.1; done

Library as target, reference as originator. Serve a fixture with the library, run a client operation, assert on the result document and exit code:

docker run --rm --network "$net" ghcr.io/otfabric/ethernetip-interop-pycomm3:<tag> \
  client get-attribute --host <library's address> --class 1 --instance 1 --attribute 7

Expected values come from the image: print-fixture adapter (or logix, logix-structures, opener-sample) prints the fixture it was built with, so expectations cannot drift from the image. A library serving the adapter fixture gives the reference originators an identity and assemblies to check.

What a stack supports comes from the image too: print-capabilities. Skip, do not fail, a scenario that needs a feature an image declares false.

Pin by digest in release qualification (ghcr.io/otfabric/ethernetip-interop-opener@sha256:...); the digests of a release are in its notes and its manifest.json. go-ethernetip reads ETHERNETIP_INTEROP_<ADAPTER>_IMAGE (or ETHERNETIP_INTEROP_VERSION=dev for the local builds of make images) to override its pins.

Repository layout

ethernetip-interop/
├── adapters/
│   ├── opener/          OpENer: Dockerfile, bash entrypoint, static capability document
│   ├── eipscanner/      C++ adapter: Dockerfile, CMakeLists.txt, src/main.cpp
│   ├── cpppo/           Python adapter: Dockerfile, requirements.txt, cpppo_interop.py (GPL-3.0-or-later)
│   ├── pycomm3/         Python adapter: Dockerfile, requirements.txt, pycomm3_interop.py
│   └── LICENSE          MIT, except adapters/cpppo/ (GPL-3.0-or-later)
├── fixtures/
│   ├── schema/          JSON Schema of the fixture format
│   ├── adapter/         the adapter a library under test serves
│   ├── logix/           a Logix-style controller (cpppo)
│   └── opener-sample/   what OpENer's sample application serves
├── own/go-ethernetip/   OT Fabric's own stack behind the contract (not a reference, not in a release)
├── schemas/             JSON Schemas of the capability and client result documents
├── scripts/             validate, smoke, cross-stack self-test (interop.sh), version check, latest upstream
├── tests/               cases.tsv, known-bugs.tsv (candidate upstreams), known-differences.tsv (between references)
├── docs/                contract, fixtures, capabilities, compatibility
├── licenses/            licence texts the adapters refer to
├── versions.yaml        every upstream pin and base image digest
├── compose.yaml         the reference targets on a private network
└── Makefile

Development

make help        # all targets
make ci          # validate, lint, build, smoke, cross-stack self-test
make validate    # versions.yaml against the build files; fixtures against the schema and the cross-field rules
make lint        # shellcheck
make buildx      # check the other architecture locally, under emulation (CI builds both natively)
scripts/latest-upstream.py opener   # an adapter's pin against the latest upstream
make candidate-opener CANDIDATE_VERSION=<version> CANDIDATE_SHA=<commit>   # self-test an adapter on another upstream than its pin

make smoke ADAPTERS=opener and make interop ADAPTERS="opener eipscanner" narrow what is run; VERBOSE=1 shows every check. The self-test keeps every result document under reports/interop/ with summary.md and summary.tsv. See CONTRIBUTING.md for adding an adapter, a fixture or a client operation.

A daily workflow, Upstream, compares every pin with the latest upstream (the newest release, or the head of the default branch for the six stacks pinned to a commit) and every base-image digest with its tag, builds and self-tests a candidate image where a pin is behind, and keeps an issue open with the pin-vs-latest table while any pin is behind. Pins are never moved automatically; see docs/COMPATIBILITY.md.

Security

The servers are unauthenticated EtherNet/IP targets that accept attribute writes, Class 1 connections and tag writes from anyone who can reach them. They are test fixtures: compose.yaml and the run-* targets publish them on loopback only, the self-tests use private Docker networks, and they must never be attached to a plant network. See SECURITY.md.

License

The repository's own code and documentation, and the adapter sources, are MIT-licensed, except adapters/cpppo/, which imports the GPL-3.0 cpppo and is GPL-3.0-or-later. The images contain the upstream stacks under their own licences: OpENer under its adapted BSD licence (products implementing EtherNet/IP also need ODVA's Terms of Usage), EIPScanner and pycomm3 MIT, and the cpppo image is GPL-3.0 as a whole. All four images are published. See LICENSE and THIRD_PARTY_NOTICES.md.

Releases

Packages

Contributors

Languages