A pure Go implementation of DNP3 (IEEE 1815-2012), the SCADA protocol between masters and outstations in electric, water and oil and gas utilities. No cgo, no dependencies outside the standard library.
The library provides:
- A client (master): integrity and event polls, reads of any group and range, unsolicited responses with confirmation and duplicate detection, controls (select-before-operate, direct operate, no-ack; relay output blocks, pattern control, set-points), time synchronization by the LAN and the non-LAN procedure, restarts, freezes (also at a time), assign class, device attributes of any set, file transfer, broadcasts, secure authentication
- A server (outstation): a point database for every point type with deadbands and event classes, event buffers per master that survive reconnects, multi-fragment responses with confirmation, unsolicited responses with hold time and retries, select-before-operate with timeout and sequence checks, device attributes of any set, scheduled freezes, any number of masters and connections
- The complete link layer (FT3 frames, CRCs, reset of link states, confirmed user data with the frame count bit, link status keep-alive) and the transport function, also on multi-drop channels
- An application layer codec and object library for every group of IEEE 1815-2012 Annex A in use today: file control (g70), device attributes (g0) and the secure authentication objects (g120..g122) included
- Secure Authentication v5 (IEEE 1815-2012 clause 7): the session key change with AES key wrap under a pre-shared update key, on every connection, at an interval and after an authentication failure; the outstation challenges critical requests, the master answers with an HMAC or sends them in aggressive mode; the outstation keeps the security statistics (g121, g122)
- What production use needs:
context.Contexton every call, automatic reconnect with backoff, retries for the requests that are safe to repeat, automatic handling of the internal indications (restart, time, events, overflow), periodic polls, structured logging, metrics hooks, TCP, TLS, UDP and serial (any byte stream) - Verified against third-party stacks: as master and as outstation against opendnp3, DSC go-dnp3, dnp3py and cB-Sigma dnp3, and as outstation against nfm-dnp3, in the containers of otfabric/dnp3-interop v0.11.2, over TCP, TLS, UDP and serial, with file transfer and Secure Authentication
Docs: API.md (public API) · ARCHITECTURE.md (packages, goroutines, protocol machines) · INTEROPERABILITY.md (what is implemented, clause by clause) · ERRORS.md (error semantics) · OBSERVABILITY.md (logging and metrics) · TESTING.md (test tiers) · docs/FEATURE-STATUS.md (status and evidence per feature) · LIMITATIONS.md · SECURITY.md · RELEASE.md (changelog)
- go-dnp3 vs opendnp3 and DSC go-dnp3
- Install
- Project structure
- Client quickstart
- Server quickstart
- Production features
- Transports
- The object model
- Error semantics
- Examples
- Development
- Interop tests
- Limitations
- License
go-dnp3 is tested against three other implementations, two of which this table compares (see Interop tests). Each suits different needs; the table shows where they differ, from their public sources, so you can pick the right one for your project.
| Capability | otfabric/go-dnp3 | opendnp3 | DSC go-dnp3 |
|---|---|---|---|
| Language | Go | C++ (Java, .NET bindings) | Go |
| Licence | MIT | Apache-2.0 | GPL-3.0-or-later (copyleft: a program linking it is GPL) |
| Status | maintained, v1 (semantic versioning) | end of life: archived 2022, 3.1.2 final | maintained, pre-1.0 ("API not yet stable") |
| Master and outstation | ✅ | ✅ | ✅ |
| Unsolicited responses, both roles | ✅ | ✅ | ✅ |
| Select-before-operate, direct operate | ✅ | ✅ | ✅ |
| Direct operate no-ack, as master | ✅ | — | ✅ |
| LAN and non-LAN time synchronization | ✅ | ✅ | ✅ |
| Assign class, as outstation | ✅ | ✅ | ✅ |
| File transfer (g70) | ✅ both roles | — (codec only) | ✅ both roles |
| Secure Authentication v5 | ✅ session key change, challenges, aggressive mode, statistics | — | symmetric subset with statistics, no aggressive mode |
| Outstation that dials the master | ✅ dual end point (DialAndServe) |
✅ on a TCP or TLS client channel | ✅ over a TCP client channel |
| Several outstations behind one port or line | ✅ by link address (server.Group) |
✅ several stacks per channel | ✅ multidrop package |
| Device attributes (g0) | ✅ both roles, any set | — | ✅ |
| Data sets (g85..g88) | — (generic requests) | — | ✅ outstation backend, master handler |
| TLS, UDP, serial | ✅ (crypto/tls; serial as any byte stream) |
✅ | ✅ |
| Request methods that return the response | ✅ | — callbacks | ✅ controls, attributes, files; polls to a handler |
| Cancellation and deadlines per call | ✅ context.Context |
— | ✅ context.Context |
| Structured logging | ✅ log/slog |
own logger | ✅ log/slog |
| Metrics | ✅ callbacks (dnp3.Metrics) |
— | Stats() counters |
| Dependencies | none (standard library) | ASIO, exe4cpp, ser4cpp; OpenSSL for TLS | standard library; go.bug.st/serial for serial |
| Tested against other stacks in CI | ✅ the two, dnp3py, cB-Sigma dnp3 and nfm-dnp3, the features of the coverage matrix | not documented | ✅ opendnp3 |
| DNP Users Group IED conformance procedures (dnp3c) in CI | ✅ 3.1r1 subset levels 2 and 3, 2.7 level 2, against a test outstation (not a certification) | not documented | ✅ 3.1r1 subset level 3, 2.7 level 2 (not a certification) |
✅ = available · — = not available. For opendnp3 3.1.2 and DSC go-dnp3 v0.8.2, the versions go-dnp3 is tested against, from their source, documentation and licences and the dnp3-interop capability records, as of October 2026. If you spot an inaccuracy, please open an issue and we will correct it.
go get github.com/otfabric/go-dnp3Requires Go 1.26 or later. From v1.0.0 the API follows semantic versioning: a breaking change comes only with a new major version.
| Package | What it is |
|---|---|
dnp3 |
Shared types: sentinel and typed errors, Logger, Metrics, State |
client |
The master |
server |
The outstation and its point database |
app |
Application layer codec and object library |
transport |
Transport function: segmentation and reassembly |
link |
Data link layer codec: FT3 frames, CRC, stream reader |
secauth |
Secure Authentication v5: MACs and the data they protect |
app, transport and link are pure codecs, useful on their own to
analysers and test tools. See ARCHITECTURE.md.
ctx := context.Background()
c, err := client.Dial(ctx, "10.0.0.5:20000",
client.WithAddresses(1, 10), // master 1, outstation 10
client.WithReconnect(client.Reconnect{}),
client.WithRetry(client.Retry{Attempts: 3}),
)
if err != nil {
log.Fatal(err)
}
defer c.Close()
// An integrity poll: events of classes 1..3, then all static data.
res, err := c.IntegrityPoll(ctx)
if err != nil {
log.Fatal(err)
}
for _, m := range res.Measurements() {
switch v := m.Value.(type) {
case app.Analog:
fmt.Printf("%s %d = %.2f %s\n", m.GV, m.Index, v.Value, v.Flags)
case app.Binary:
fmt.Printf("%s %d = %v\n", m.GV, m.Index, v.Value)
}
}
// Select-before-operate of a breaker.
if _, err := c.Operate(ctx, client.SelectBeforeOperate, client.CROB(0, app.OpLatchOn, 0, 0)); err != nil {
var ce *dnp3.CommandError
if errors.As(err, &ce) {
log.Printf("refused: %s", ce.Status)
}
}Unsolicited responses, and every response to an automatic or periodic poll,
go to a client.Handler:
c, err := client.Dial(ctx, addr,
client.WithHandler(client.HandlerFunc(func(info client.ResponseInfo, ms []client.Measurement) {
for _, m := range ms {
fmt.Println(info.Unsolicited, m.GV, m.Index, m.Value)
}
})),
client.WithStartup(client.Startup{DisableUnsolicited: true, Integrity: true, EnableUnsolicited: []int{1, 2, 3}}),
client.WithTimeSync(client.TimeSyncLAN), // when the outstation sets NEED_TIME
)breaker := server.HandlerFunc(func(ctx context.Context, c server.Command, op server.OperateType) app.CommandStatus {
crob, ok := c.Value.(app.CROB)
if !ok || c.Index != 0 || (crob.Op != app.OpLatchOn && crob.Op != app.OpLatchOff) {
return app.StatusNotSupported
}
// ... operate the device ...
return app.StatusSuccess
})
srv, err := server.New(
server.WithAddress(10),
server.WithMasters(1), // events are buffered for master 1 while it is away
server.WithHandler(breaker),
server.WithUnsolicited(server.Unsolicited{Enabled: true}),
)
if err != nil {
log.Fatal(err)
}
db := srv.Database()
_ = db.AddBinaryInput(0, server.PointConfig{Class: 1}, app.Binary{Value: true, Flags: app.FlagOnline})
_ = db.AddAnalogInput(0, server.PointConfig{Class: 2, StaticVariation: 5, Deadband: 0.5}, app.Analog{Value: 230.1, Flags: app.FlagOnline})
go func() {
// Updates produce events by change and deadband, in the order they occur.
_ = db.UpdateAnalogInput(0, app.Analog{Value: 231.0, Flags: app.FlagOnline})
}()
log.Fatal(srv.ListenAndServe(":20000"))| Feature | Client | Server |
|---|---|---|
| Cancellation and deadlines | every request takes a context.Context |
handler calls get a context canceled on Close |
| Reconnect | WithReconnect: exponential backoff, startup sequence repeated |
masters reconnect; their association and events are kept |
| Retries | WithRetry: READ, time, unsolicited and class functions only; controls, freezes, restarts and writes never |
the master's repeated request is answered with the same response, not executed twice |
| Timeouts | WithResponseTimeout per fragment, link layer timeout and retries |
WithConfirmTimeout, WithSelectTimeout |
| Internal indications | clears DEVICE_RESTART, integrity poll on restart and overflow, time sync on NEED_TIME, event poll on class bits | DEVICE_RESTART, NEED_TIME, class events, overflow, broadcast and the request errors maintained; SetIndications for local control, device trouble |
| Flow control | one request at a time, as DNP3 requires; WithQueueLimit bounds the waiting ones |
per-type event limits with oldest-first discard; bounded receive queues |
| Observability | Logger, FieldLogger, ContextLogger, log/slog; Metrics, RequestMetrics; state handler |
Logger; Metrics, ServerMetrics |
| Security | TLS, Secure Authentication v5 with aggressive mode and re-keying after a failure | TLS, Secure Authentication v5 with a reply timeout and security statistics, master address filter |
| Transport | Client | Server |
|---|---|---|
| TCP | default | ListenAndServe, Serve |
| TLS | WithTLS |
WithTLS with ListenAndServe, or a TLS listener with Serve |
| UDP | WithStream(client.UDP(addr)) |
ServeUDP, or ServeUDPTo with the master's address |
| Serial, or any byte stream | WithStream with an opener that returns the port |
ServeConn |
| Dual end point (the outstation connects) | WithStream(client.Accept(l)) |
DialAndServe(ctx, masterAddr, server.Redial{}) |
| Several outstations behind one port or line (multi-drop) | one client per outstation address | server.NewGroup(servers...) with ListenAndServe, Serve, ServeConn, ServeUDP |
Serial ports are not opened by this module, so that it has no dependencies: open the port with a serial library, for example otfabric/go-serial, and hand it over:
c, err := client.Dial(ctx, "/dev/ttyUSB0", client.WithStream(func(ctx context.Context) (io.ReadWriteCloser, net.Addr, error) {
p, err := serial.Open(&serial.Config{Address: "/dev/ttyUSB0", BaudRate: 19200})
return p, nil, err
}))Leave the port's read timeout at zero (blocking reads): a read that times
out is a failing stream to the channel, which then closes. Inactivity is
detected by the link layer's keep-alive instead (client.Link.KeepAlive).
Responses are decoded into app.Fragment values: headers (app.Header)
with their objects (app.Object), each an index and a value. The values
are a closed set of structs: app.Binary, app.DoubleBitBinary,
app.Counter, app.Analog, app.CROB, app.AnalogOutput,
app.OctetString, the file and authentication objects and a few more. One
struct serves every variation of a kind of point; the header's
app.GroupVariation (constants such as app.G30V5) says how it is
encoded. Time stamps are app.Timestamp, DNP3's milliseconds since 1970,
with conversions to and from time.Time.
Encoding an analog value into an integer variation rounds it; a value the
variation cannot hold is sent as the nearest one with OVER_RANGE set,
while a set-point that does not fit is refused. See the app package
documentation.
Every group and variation the codec knows is listed in
OBJECTS.md, generated like the codec's table from one
catalog, app/catalog.json.
Errors are sentinels tested with errors.Is (dnp3.ErrTimeout,
dnp3.ErrConnectionLost, dnp3.ErrNotConnected, dnp3.ErrClosed, ...)
and typed errors for answers of the outstation, matched with errors.As:
*dnp3.IINError (the request was refused), *dnp3.CommandError (a control
was not executed), *dnp3.FileError and *dnp3.AuthError. Request methods
return the response together with an IINError, so that what the
outstation did report is not lost. See ERRORS.md.
Runnable programs under examples/: seven self-contained ones
(quickstart, commands, events, files, secure, observability,
dialout)
that start both sides in one process, and a standalone server and
client for trying the library against other implementations. See
examples/README.md.
go run ./examples/quickstartmake check # format, lint, vet, vulnerability scan, race tests, coverage, examples
make test # the tests with the race detector
make e2e # the end-to-end suite only
make coverage-check # fail below the 90 % coverage floor
make cross # cross-compile for the supported platforms
make fuzz # the codec and outstation fuzz targets (FUZZTIME=30s each by default)
make bench # the codec benchmarks
make generate # regenerate the code and OBJECTS.md from app/catalog.json
make upstream-watch # compare the catalog with DSC go-dnp3 and dnp3-interopTests need nothing but Go: master and outstation run against each other and against scripted peers over loopback, including a chaos suite that corrupts, duplicates and drops octets on the line, and stress tests with many masters and concurrent requests. See CONTRIBUTING.md.
make conformance runs the DNP Users Group's IED certification procedures
against a test outstation built on the library: 3.1 rev 1 at subset levels
2 and 3, and 2.7 at level 2. It uses Step Function I/O's
dnp3c 0.2.0, through
otfabric/dnp3-interop's dnp3c
runner. dnp3c and the runner are GPL-3.0 and not part of this repository;
make conformance-image builds the runner's image from dnp3-interop's
source, and it is not published. CI runs the same on every push. The
results are not a certification. internal/cmd/conformance describes the setup and the
Device Profile.
make interop runs go-dnp3 as master against the reference outstations,
and the reference masters against go-dnp3's outstation, using the images
of otfabric/dnp3-interop v0.11.2,
pinned by digest.
| Reference | Version | Licence | Roles tested |
|---|---|---|---|
| opendnp3 | 3.1.2 | Apache-2.0 | master and outstation |
| DSC go-dnp3 | v0.8.2 | GPL-3.0-or-later | master and outstation |
| dnp3py | 0.6.0 | MIT | master and outstation (its outstation serves the dnp3py fixture only) |
| cB-Sigma dnp3 | v0.3.0 | Apache-2.0 | master and outstation |
| nfm-dnp3 | 1.0.1 | MIT | master (TCP; go-dnp3's outstation serves it without unsolicited responses, and three of its upstream defects skip tests: INTEROPERABILITY.md) |
The Self-interop workflow, run by hand, runs a commit against the latest
published go-dnp3 (dnp3-interop-otfabric:latest). It finds a change on the
wire that breaks an earlier release. A pass is not independent verification: it
is go-dnp3 on both sides.
Both directions cover the integrity poll against the fixture's values and
flags, reads of ranges and variations, every control case of the fixture
(select-before-operate, select-required outputs, set-point ranges,
unsupported codes and points), events and their confirmation, unsolicited
responses, both time synchronization procedures, cold and warm restarts,
freeze and clear, and assign class. The transports and procedures of
dnp3-interop's contracts 1.1 to 1.3 are tested in both directions where a
reference has the feature (see the matrix): TLS with mutual authentication (a test PKI is
generated per run), UDP, serial lines (a pseudo-terminal in the container
bridged to TCP), file transfer against the files fixture, and Secure
Authentication with session key changes and the security statistics; also
the requests without a response (DIRECT OPERATE NO ACK, the _NR
freezes), reads of all objects of a group, and contract 1.2: the dual end
point over TCP and TLS, frozen analog inputs, command events, time and
interval, security statistic events, and the dnp3py fixture; and contract
1.3: device attribute sets, several controls in one request, scheduled
freezes and analog freeze, deadbands, file append and authentication, event
buffer overflow, link confirms and keep-alive, broadcast, the management
functions and several outstations behind one port. The
coverage matrix shows each feature
per reference and direction. Deviations a reference has from the
fixture are those dnp3-interop records as known bugs. Docker is needed; the
images are consumed as published.
The third-party code runs in its own container only: nothing of it is linked into go-dnp3.
The full list is in LIMITATIONS.md.
- Secure Authentication: update keys are configured out of band. The remote update key change, user management (certificates, user status change) and AES-GMAC are not implemented; aggressive mode is verified between go-dnp3's ends only, as no reference stack in the interop suite has it. See SECURITY.md and the gaps.
- Data sets, private registration objects and the virtual terminal
(groups 83, 85..88, 90 carried raw, except the one-octet data set
characteristics g86v2; 112, 113 decoded) have no procedures: exchange
them with the generic request methods. Group 91 is decoded for
ACTIVATE_CONFIG (
Client.ActivateConfig,server.ActivateConfigHandler). - Conformance: CI runs the DNP Users Group's IED certification
procedures (dnp3c: 3.1 rev 1 at subset levels 2 and 3, 2.7 at level 2)
against a test outstation built on the library, with the
Device Profile in
internal/cmd/conformance/device.ini. That is not a certification. go-dnp3 declares no subset level of its own, and the run does not cover what that profile leaves out: double-bit inputs in 3.1r1 (level 4 objects in dnp3c; the 2.7 run has them), serial and UDP, multi-drop, and the network tests of section 9. - UDP outstation: one socket serves one master at a time; a master behind another address takes it over when it sends.
- Multi-fragment requests are not reassembled by the outstation: a request is one fragment.
MIT. The reference implementations the interop tests run are under their own licences and are not part of this module.