Skip to content

Repository files navigation

dnp3-interop — DNP3 (IEEE 1815) Interoperability Testing with Docker

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

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-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.

Quick start

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 outstation

A 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.json

Every 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 dnp3c runner

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.

OT Fabric's own stack

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.

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, 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; done

The 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).

Repository layout

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

Development

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.

Security

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.

License

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.

Releases

Packages

Contributors

Languages