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 opendnp3, DSC Systems go-dnp3, dnp3py, cB-Sigma dnp3 and nfm-dnp3. 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 |
|---|---|---|---|---|---|
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 |
cbsigma |
cB-Sigma dnp3 v0.3.0 | Go | yes | yes | ghcr.io/otfabric/dnp3-interop-cbsigma |
nfmdnp3 |
nfm-dnp3 1.0.1 ² | Python | no | yes | ghcr.io/otfabric/dnp3-interop-nfmdnp3 |
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. nfm-dnp3 (²) is a master over TCP only; version 1.0.1 has data link and
application layer defects that break most exchanges with an outstation that
asks for confirmations. They are listed in
tests/known-bugs.tsv and reported upstream. 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:
| opendnp3 | dscsystems | dnp3py | cbsigma | nfmdnp3 | |
|---|---|---|---|---|---|
| TLS (mutual), UDP, serial | yes | yes | no | yes | no |
| File transfer (g70) | no | both roles | no | both roles | no |
| Secure authentication v5 (g120) | no | both roles | 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 adds counters that change on a running server
(counter-increment), and the frozen event policy. Contract 1.5, the
current one, adds ACTIVATE CONFIG, a delay between SELECT and OPERATE
(--operate-delay-ms) and a master that does not confirm (--no-confirm), so
that select expiry and event retransmission can be provoked. 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.
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, apart from one image kept out of its releases. 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
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-opendnp3 and make run-dscsystems run an outstation
on 127.0.0.1:20001 and 20002 (baseline fixture, Ctrl-C to stop). 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
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.
ghcr.io/otfabric/dnp3-interop-otfabric is go-dnp3 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-dnp3 versions and manifest.json does not list it: see own/go-dnp3/README.md.
A consumer needs Docker, the image references and its own test code. It does not clone this repository, except to build the dnp3c runner (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 dnp3c image has
no published digest: pin it by building it from the release tag, whose
upstream pins manifest.json records under localOnly (from v0.5.1).
dnp3-interop/
├── adapters/
│ ├── opendnp3/ C++ adapter: Dockerfile, CMakeLists.txt, src/
│ ├── dscsystems/ Go adapter: Dockerfile, go.mod, *.go
│ ├── dnp3py/ Python adapter: Dockerfile, requirements.txt, dnp3_interop.py
│ ├── cbsigma/ Go adapter: Dockerfile, go.mod, *.go
│ ├── nfmdnp3/ Python adapter (master only): 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.5 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.5 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 (opendnp3's tags, DSC go-dnp3 and cB-Sigma dnp3 on the Go module proxy, dnp3py and nfm-dnp3 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 and cB-Sigma dnp3 are Apache-2.0, DSC
go-dnp3 is GPL-3.0-or-later, dnp3py is MIT and nfm-dnp3 is MIT as its package
metadata declares. The dnp3c runner and its image are
GPL-3.0-only. See LICENSE and
THIRD_PARTY_NOTICES.md.