A Docker-based interoperability testing framework for DNP3 (IEEE 1815), providing independent master and outstation implementations, deterministic fixtures, and automated protocol compatibility testing. Test DNP3 libraries against established implementations including Step Function I/O dnp3 and opendnp3. Reference implementations are independently containerized, enabling reproducible testing across platforms without integrating third-party protocol stacks into production applications.
| Adapter | Implementation | Language | Server (outstation) | Client (master) | Image |
|---|---|---|---|---|---|
stepfunc |
Step Function I/O dnp3 1.8.0 | Rust | yes | yes | not published: build it locally, make image-stepfunc ² |
opendnp3 |
opendnp3 3.1.2 | C++ | yes | yes | ghcr.io/otfabric/dnp3-interop-opendnp3 |
dscsystems |
DSC Systems go-dnp3 v0.8.2 (experimental) | Go | yes | yes | ghcr.io/otfabric/dnp3-interop-dscsystems |
dnp3py |
dnp3py 0.6.0 | Python | yes ¹ | yes | ghcr.io/otfabric/dnp3-interop-dnp3py |
² The stepfunc image is not published, at Step Function I/O's request: the
licence of the Step Function I/O dnp3 crate forbids distributing it, and the
image has the crate compiled in. Build it yourself from this repository
(make image-stepfunc, or
docker build -f adapters/stepfunc/Dockerfile -t ghcr.io/otfabric/dnp3-interop-stepfunc:dev .);
the build fetches the crate from crates.io, under its licence, for you. See
Building the stepfunc image.
The stacks do not all do the same: the opendnp3 master sends no _NR
function, opendnp3's master synchronizes time only when the outstation asks for
it, DSC go-dnp3's master reads only ranges and classes, and dnp3py's
outstation (¹) serves only the dnp3py fixture, the baseline within its
limits. Each image says what it supports (print-capabilities); see
docs/CAPABILITIES.md.
Container contract 1.0 covers DNP3 over TCP. Contract 1.1 adds transports, file transfer and secure authentication:
| stepfunc | opendnp3 | dscsystems | dnp3py | |
|---|---|---|---|---|
| TLS (mutual), UDP, serial | yes | yes | yes | no |
| File transfer (g70) | master (no delete) | no | both roles | no |
| Secure authentication v5 (g120) | no | no | both roles | no |
¹ dnp3py's outstation serves the dnp3py fixture only.
Contract 1.2 adds frozen analog inputs, command events, time and interval
objects, dual end point (the outstation dials) and security statistics.
Contract 1.4, the current one, adds counters that change on a running server
(counter-increment), and the frozen event policy. Contract 1.3 adds device attributes, several controls in
one request, freeze at time and analog freeze, analog deadbands, file append
and authentication, event buffer overflow, link-layer confirms and
keep-alives, broadcast, management functions and several outstations on one
channel. Which adapter has which is in
docs/CAPABILITIES.md.
The Step Function I/O dnp3 crate is distributed under a non-commercial, non-production licence that does not permit redistributing it. This repository holds only the MIT-licensed stepfunc adapter and its Dockerfile; no stepfunc image is published, and each user builds one locally, getting the crate from crates.io under that licence. See THIRD_PARTY_NOTICES.md.
On hold: the Triangle MicroWorks DNP3 Source Code Library (C), whose licensed source has no agreed way into the builds yet. See PLAN.md.
Docs: docs/CONTAINER_CONTRACT.md (commands, JSON documents, exit codes) · docs/FIXTURES.md (the simulated outstation and its behaviour) · docs/CAPABILITIES.md · docs/COMPATIBILITY.md (pins, cross-stack matrix, findings) · COVERAGE.md · REQUIREMENTS.md · PLAN.md · RELEASE.md
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. otfabric/go-dnp3 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 an outstation is; the adapters make each implementation behave that way; the consumer decides what to assert.
Needs Docker and jq.
make images # build the adapter images for this machine (stepfunc included)
make smoke # container contract of each image
make interop # every master against every outstationA reference outstation, and a reference master in its network namespace
(both images built by make images, tagged dev; with a release tag
instead of dev the opendnp3 and dscsystems images are pulled from GHCR):
docker run -d --rm --name ref ghcr.io/otfabric/dnp3-interop-opendnp3:dev server
until docker exec ref test -f /run/dnp3-interop/ready; do sleep 0.1; done
docker run --rm --network container:ref \
ghcr.io/otfabric/dnp3-interop-dscsystems:dev \
client integrity-poll --host 127.0.0.1 --port 20000 | jq .
docker stop ref{
"schemaVersion": "1.0",
"adapter": "dscsystems",
"operation": "integrity-poll",
"ok": true,
"error": null,
"connected": true,
"iin": ["NEED_TIME"],
"objects": [
{ "group": 1, "variation": 2, "index": 0, "value": true, "flags": ["ONLINE"], "unsolicited": false },
{ "group": 30, "variation": 5, "index": 0, "value": 230.5, "flags": ["ONLINE"], "unsolicited": false }
]
}(abridged). make run-stepfunc, make run-opendnp3 and
make run-dscsystems run an outstation on 127.0.0.1:20000, 20001 and 20002
(baseline fixture, Ctrl-C to stop); make run-stepfunc needs the locally
built stepfunc image (below). dnp3py has no such target and serves its
own fixture:
docker run --rm -p 127.0.0.1:20003:20000 ghcr.io/otfabric/dnp3-interop-dnp3py:dev \
server --fixture /fixtures/dnp3py/fixture.jsonEvery image has the same commands:
server an outstation serving a fixture
client <operation> a master running one of: integrity-poll, event-poll, read,
operate, operate-multi, time-sync, restart,
enable-unsolicited, disable-unsolicited, assign-class,
freeze, monitor, file-get, file-put, file-ls, file-info,
file-delete, file-authenticate, attribute-read,
attribute-write, deadband-write, manage
(see docs/CONTAINER_CONTRACT.md; not every image has all)
print-capabilities what this adapter supports, as JSON
print-fixture the fixture baked into the image
version adapter and upstream versions
The stepfunc image is not published, at Step Function I/O's request: the
licence of their dnp3 crate does not permit distributing it, and the image
has it compiled in. Interoperability testing with it is allowed, so build it
yourself; the build fetches the crate from crates.io, under its licence, for
you. At the release tag you test against (for example v0.5.1):
git clone --branch v0.5.1 https://github.com/otfabric/dnp3-interop.git
cd dnp3-interop
make image-stepfunc VERSION=v0.5.1 # ghcr.io/otfabric/dnp3-interop-stepfunc:v0.5.1, local onlyor without make:
docker build --build-arg ADAPTER_VERSION=v0.5.1 \
-f adapters/stepfunc/Dockerfile -t ghcr.io/otfabric/dnp3-interop-stepfunc:v0.5.1 .Without VERSION, make image-stepfunc builds the dev tag that
make smoke, make interop and make run-stepfunc use. The tag names the
registry, but the image stays on your machine: nothing in this repository
pushes or pulls it. Do not push it to a registry yourself either; that would
distribute the crate.
make image-dnp3c builds dnp3-interop-dnp3c, which runs the DNP Users
Group's DNP3 IED Certification Procedure against an outstation. It uses Step
Function I/O's dnp3c 0.2.0 (3.1 rev 1 at
subset levels 1 to 3, and 2.7). The outstation under test implements a small
control protocol for the steps dnp3c cannot do over DNP3, such as restarts,
input changes and checks of outputs; no adapter here implements it. dnp3c and
the runner are GPL-3.0-only. The build fetches dnp3c from Maven Central, and
the image is not published. See docs/CONFORMANCE.md.
The results are not a certification.
A consumer needs Docker, the image references and its own test code. It does not clone this repository, except to build the stepfunc image (above).
Library as master, reference as outstation. Start the server, wait for the readiness file (never for a delay), connect to link address 1024 as master 1:
docker run -d --name ref -p 127.0.0.1:20000:20000 ghcr.io/otfabric/dnp3-interop-opendnp3:<tag> server
until docker exec ref test -f /run/dnp3-interop/ready; do sleep 0.1; doneThe server's stdout is a JSON Lines stream of what it received (command,
time-sync, restart, ...), for asserting what the library actually put on
the wire.
Library as outstation, reference as master. Serve the fixture with the library, run a client operation, assert on the result document and exit code:
docker run --rm --add-host=host.docker.internal:host-gateway \
ghcr.io/otfabric/dnp3-interop-opendnp3:<tag> \
client operate --type g12v1 --index 0 --code LATCH_ON --mode sbo \
--host host.docker.internal --port <port of the library's outstation>This is for Docker Desktop. On Linux use --network host and
--host 127.0.0.1 instead of --add-host and host.docker.internal.
Expected values come from the image: print-fixture baseline prints the
fixture it was built with, so expectations cannot drift from the image.
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/dnp3-interop-opendnp3@sha256:...); the digests of the
published images are in each release's manifest.json. The stepfunc and
dnp3c images have no published digest: pin them by building them from the
release tag, whose upstream pins manifest.json records under localOnly
(dnp3c from v0.5.1).
dnp3-interop/
├── adapters/
│ ├── opendnp3/ C++ adapter: Dockerfile, CMakeLists.txt, src/
│ ├── stepfunc/ Rust adapter: Dockerfile, Cargo.toml, Cargo.lock, src/ (image built locally, never published)
│ ├── dscsystems/ Go adapter: Dockerfile, go.mod, *.go
│ ├── dnp3py/ Python adapter: Dockerfile, requirements.txt, dnp3_interop.py
│ └── LICENSE MIT, except adapters/dscsystems/ (GPL-3.0-or-later)
├── conformance/
│ ├── dnp3c/ the dnp3c runner: Dockerfile, pom.xml, src/ (GPL-3.0-only; image built locally, never published)
│ └── LICENSE why conformance/dnp3c/ is GPL-3.0-only
├── fixtures/
│ ├── schema/ JSON Schema of the fixture format
│ ├── baseline/ the baseline outstation
│ ├── dnp3py/ what dnp3py's outstation can serve
│ └── extended/, files/, attributes/, controls/, events/, counters/ fixtures of contract 1.1..1.4 features
├── schemas/ JSON Schemas of the capability and client result documents, interop-result-v1
├── tests/cases.tsv self-test cases: operation, expected outcome, features needed
├── tests/cases-dnp3py.tsv the cases against the dnp3py fixture
├── tests/known-bugs.tsv known bugs of upstream versions
├── scripts/ validate, smoke, cross-stack self-test
├── 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 outstations on loopback
└── Makefile
make help # all targets
make ci # validate, lint, build, smoke, cross-stack and feature self-tests, document schemas
make features # cross-stack self-test of the contract 1.1..1.4 features (TLS, UDP, serial, files, SAv5, ...)
make validate # fixtures against the schema and the cross-field rules
make buildx # check the other architecture locally, under emulation (CI builds both natively)
scripts/latest-upstream.py dnp3py # the pin and the latest upstream release
make candidate-dnp3py CANDIDATE_VERSION=0.7.0 # self-test an adapter on another upstream release
make documents report # self-tests with every document kept, then the compatibility report
VERSION=v0.5.1 make qualify # Trivy scan and SBOMs of a release's published images, into qualify/The Upstream workflow resolves the latest release of every stack daily (the dnp3 crate on crates.io, opendnp3's tags, DSC go-dnp3 on the Go module proxy, dnp3py on PyPI) and the digest of every pinned base image, builds and self-tests the adapters whose pin is behind, reports pin versus latest in one table in its job summary and keeps a tracking issue open while a pin is behind.
make documents also writes reports/results.json, the organisation's
interop-result-v1, of make interop and
make features. A run with no pass, or with a known bug that no longer
reproduces, fails.
make smoke ADAPTERS=opendnp3 and VERBOSE=1 make interop narrow and
expand what is run and shown; DNP3_INTEROP_DEBUG=1 in a container's
environment logs the frames on stderr. See CONTRIBUTING.md
for adding an adapter, a fixture or a client operation.
The servers are unauthenticated DNP3 outstations that accept controls,
restarts and time writes from anyone who can reach them. They are test
fixtures: compose.yaml and the run-* targets publish them on loopback
only, and they must never be attached to a plant network. See
SECURITY.md.
The repository's own code and documentation, and the adapter sources, are
MIT-licensed, except adapters/dscsystems/, which links a GPL-3.0-or-later
library and is GPL-3.0-or-later. The images contain the upstream libraries
under their own licences: opendnp3 is Apache-2.0, DSC go-dnp3 is
GPL-3.0-or-later and dnp3py is MIT. The Step Function I/O dnp3 crate is under
a non-commercial, non-production licence agreement that does not permit
redistribution: its image is not published, and whoever builds it locally
gets the crate from crates.io under that agreement. See LICENSE and
THIRD_PARTY_NOTICES.md.