Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,34 @@ modbusctl client record --url tcp+tls://192.168.1.10:802 --blocks-file plan.json

The device certificate must name the host of the URL (an IP address SAN for `tcp+tls://192.168.1.10:802`, a DNS name for `tcp+tls://plc.example:802`). A missing or unreadable certificate, key or CA file is invalid input (exit 3) and nothing is sent; a device the CA does not vouch for, or one that rejects the client certificate, is a transport error (exit 4). A capture made over TLS records the target's transport as `tls`; the certificate files are not stored in it. `server static` and `server replay` serve over TLS too: see [Serving over Modbus/TCP Security](#serving-over-modbustcp-security).

#### Proxy and source address (`--proxy`, `--source-addr`)

Every command that connects to a device (every `client` command, `client sunspec` included, and `discover`) takes two flags for how the network connection is opened:

| Flag | Environment | Content |
|---|---|---|
| `--proxy` | `MODBUSCTL_PROXY` | `socks5://host:port`: connect through a SOCKS5 proxy (RFC 1928 CONNECT, no authentication), such as an SSH jump host (`ssh -D 1080 jumphost`); `socks5h://host:port` sends host names to the proxy to resolve |
| `--source-addr` | `MODBUSCTL_SOURCE_ADDR` | Local IP address to connect from, on a host with several networks (an OT and an office interface, say) |

```console
# A device in the plant network, through an SSH jump host
ssh -N -D 1080 engineer@jumphost &
modbusctl client identify --url tcp://10.20.0.15:502 --proxy socks5://127.0.0.1:1080
modbusctl client read --url tcp+tls://plc.plant.example:802 --proxy socks5h://127.0.0.1:1080 --tls-cert client.pem --tls-ca plant-ca.pem --start 100
modbusctl discover --subnets 10.20.0.0/24 --proxy socks5://127.0.0.1:1080

# From the OT interface of a multi-homed host
modbusctl client poll --url udp://192.168.0.10:502 --source-addr 192.168.0.2 --start 0 --count 4
```

| Transport | `--proxy` | `--source-addr` |
|---|---|---|
| `tcp://`, `tcp+tls://`, `rtuovertcp://`, `asciiovertcp://` | yes | yes |
| `udp://`, `rtuoverudp://` | no: SOCKS5 UDP ASSOCIATE is not implemented | yes |
| `rtu://`, `ascii://` | no | no |

With both flags, the connection to the proxy is made from the source address. `tcp+tls://` runs its TLS handshake end to end through the proxy and checks the device certificate against the host of the URL, not the proxy. Proxy credentials, a scheme other than `socks5`/`socks5h`, a missing port, an address that is not an IP literal for `--source-addr`, a flag on a transport it does not apply to, and `discover --resolve-mac` with `--proxy` (ARP cannot see hosts behind the proxy) are invalid input (exit 3), and nothing is sent. A proxy that cannot be reached or refuses the connection is a transport error (exit 4) whose message names the proxy and its reply, for example `socks5: connection not allowed by ruleset`.

Good to know:

- **Defaults differ between tools.** modbusctl defaults to 19200 8N2 (no parity, 2 stop bits, as go-modbus does); the Modbus specification default is 19200 8E1, and many devices ship as 9600 8N1. If a serial device does not answer, check `--baud`, `--parity` and `--stop-bits` against its manual first.
Expand Down Expand Up @@ -923,6 +951,8 @@ modbusctl discover --subnets 192.168.1.0/24 --output discovered.json
sudo modbusctl discover --subnets 192.168.1.0/24,192.168.2.0/24 --resolve-mac --interface eth1 --output discovered.json
```

`--proxy` and `--source-addr` apply to discovery too: each host is probed through the proxy, or from the source address (see [Proxy and source address](#proxy-and-source-address---proxy---source-addr)).

#### SunSpec (client sunspec)

Transport-level SunSpec discovery: detect the SunSpec "SunS" marker, enumerate model headers, print the address map, or run a combined probe. Use `--url` (e.g. `tcp://192.168.1.10:502`) or `--ip`/`--port`; `--unit` (0–255, full Modbus range); `--regtype` **holding** or **input** (tab-completed). No semantic decoding of points—only marker and model ID/length.
Expand Down Expand Up @@ -991,6 +1021,7 @@ Modbusctl supports environment variables for easy automation. The global flag `-
| **MODBUSCTL_PARITY** | string | Serial parity: none, even or odd | `--parity` | none |
| **MODBUSCTL_POLLS** | uint32 | **`client poll`**: number of polls before exiting (0 = until interrupted) | `--polls` | 0 |
| **MODBUSCTL_PORT** | uint16 | Modbus TCP port or server port | `--port` | 502 |
| **MODBUSCTL_PROXY** | string | Connect through a SOCKS5 proxy: `socks5://host:port` (`socks5h://` resolves names at the proxy); `tcp://`, `tcp+tls://`, `rtuovertcp://` and `asciiovertcp://` only; every client command and **`discover`** | `--proxy` | |
| **MODBUSCTL_PROFILE** | string | Device profile to decode | `--profile` | |
| **MODBUSCTL_RECORD_EXCEPTIONS** | bool | **`client record`/`scan`**: also record exception responses in the capture | `--record-exceptions` | false |
| **MODBUSCTL_RESOLVE_MAC** | bool | Resolve MAC addresses of discovered devices | `--resolve-mac` | false |
Expand All @@ -1003,6 +1034,7 @@ Modbusctl supports environment variables for easy automation. The global flag `-
| **MODBUSCTL_RETRY_TIMEOUT** | uint8 | Retry once on timeout/transport (0=no, 1=yes) | `--retry-timeout` | 0 |
| **MODBUSCTL_SEED_START** | uint16 | Boundary algo: seed start address (known-good read) | `--seed-start` | 0 |
| **MODBUSCTL_SEED_COUNT** | uint16 | Boundary algo: seed register count (1–125) | `--seed-count` | 0 |
| **MODBUSCTL_SOURCE_ADDR** | string | Local IP address to connect from (multi-homed hosts); network URLs only; every client command and **`discover`** | `--source-addr` | |
| **MODBUSCTL_SPEED** | string | **`server replay`**: speed factor (1 = original, 10 = ten times faster, 0.1 = ten times slower) | `--speed` | 1 |
| **MODBUSCTL_SUBNETS** | string | Subnets to scan for Modbus devices (comma-separated) | `--subnets` | |
| **MODBUSCTL_TARGET** | uint32 | Capture target to use: **`server static`/`replay`** (0 = every target), **`capture decode`/`extract`/`strings`/`frequencies`** (0 = the only one) | `--target` | 0 |
Expand Down
28 changes: 28 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# modbusctl Releases

## v3.7.0

**Date:** 2026-11-09
**Previous release:** v3.6.2

### Summary

Two flags on every command that connects to a device set how the connection is opened: `--proxy` reaches a plant network through a SOCKS5 proxy such as an SSH jump host, and `--source-addr` picks the local interface. Built on go-modbus v1.14.2, whose `Config.Dialer` every network transport uses.

### Added

- `--proxy socks5://host:port` (`MODBUSCTL_PROXY`): connect through a SOCKS5 proxy (RFC 1928 CONNECT, no authentication); `socks5h://` sends host names to the proxy. For `tcp://`, `tcp+tls://`, `rtuovertcp://` and `asciiovertcp://`; `tcp+tls://` handshakes end to end through the proxy and checks the certificate against the URL host.
- `--source-addr <ip>` (`MODBUSCTL_SOURCE_ADDR`): connect from that local IP address, over TCP and UDP transports; with `--proxy`, the connection to the proxy.
- Both on every `client` command (`read`, `decode`, `write`, `poll`, `record`, `scan`, `identify`, `reportserverid`, `fingerprint`, `diagnostic`, `sunspec detect`/`models`/`map`/`probe`) and on `discover`, which probes every host through the dialer.
- Invalid input (exit 3), nothing sent: proxy credentials, another scheme, a missing port or a path; a `--source-addr` that is not an IP address; `--proxy` with `udp://` or `rtuoverudp://` (SOCKS5 UDP ASSOCIATE is not implemented); either flag with `rtu://` or `ascii://`; `discover --resolve-mac` with `--proxy`. A proxy that is down or refuses is a transport error (exit 4) naming the proxy and its reply.

### Changed

- go-modbus v1.14.2 (was v1.13.1): `Config.Dialer`. `version` lists `go-modbus 1.14.2`.

### Tests

- `internal/netdial`: flag validation, TCP and UDP source binding, SOCKS5 CONNECT for IPv4, IPv6 and host names (`socks5` and `socks5h`), every reply and handshake failure, a silent proxy against the deadline, cancel. `internal/socks5test` is the scripted test proxy. Both at 100 % coverage.
- `cmd/client`: every connecting command through the test proxy to the fake device at an address only the proxy reaches; `rtuovertcp://`, `asciiovertcp://` and `tcp+tls://` (certificate checked against the URL host) through `socks5h://`; a refusing proxy; `--source-addr` on TCP, UDP and with the proxy, and an address the host does not have; every invalid value and transport rejection on every command, with nothing sent.
- `cmd/discover`: a TEST-NET range discovered through the proxy, `--source-addr`, invalid values and `--resolve-mac` with `--proxy`.

---

## v3.6.1

**Date:** 2026-11-08
Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ Modbus TCP, UDP, RTU and ASCII carry no authentication and no encryption: anyone
- **Keep Modbus traffic on isolated, trusted networks**; never expose port 502 to the Internet
- For access across untrusted networks, use a VPN or a secured gateway in front of the device

### Proxies

`--proxy` sends the connection through the SOCKS5 proxy you name, and with it the Modbus traffic, in clear text unless the URL is `tcp+tls://`. The proxy can read and change that traffic and connect anywhere it is allowed to: use only a proxy you control, such as an SSH jump host (`ssh -D`) listening on loopback. modbusctl sends no proxy credentials (it supports none). With `tcp+tls://` the TLS session runs end to end through the proxy and the device certificate is checked against the URL host, so the proxy cannot impersonate the device. `--source-addr` only picks the local address; it does not bypass routing or firewall rules.

### Commands That Change Device State

`client write` and the state-changing sub-functions of `client diagnostic` refuse to run without `--yes` (exit 2, nothing sent).
Expand Down
207 changes: 207 additions & 0 deletions cmd/client/dial_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// SPDX-License-Identifier: MIT

package client

import (
"net"
"os"
"path/filepath"
"strings"
"testing"

"github.com/otfabric/modbusctl/internal/errs"
"github.com/otfabric/modbusctl/internal/mbtest"
"github.com/otfabric/modbusctl/internal/socks5test"
"github.com/spf13/cobra"
)

// unroutable is a TEST-NET-1 address (RFC 5737): a URL naming it reaches the device only
// through a proxy that forwards it there.
const unroutable = "192.0.2.10:502"

// forwardingProxy is a test proxy that sends every CONNECT to the device at addr.
func forwardingProxy(t *testing.T, addr string) *socks5test.Server {
t.Helper()
px := socks5test.Start(t)
px.Forward = func(string) string { return addr }
return px
}

// dialingCommands are the client commands that connect to a device, each with the
// arguments of a successful run against newDevice; url is the --url value.
func dialingCommands(t *testing.T, url string) map[string][]string {
t.Helper()
tmp := t.TempDir()
blocks := filepath.Join(tmp, "blocks.json")
if err := os.WriteFile(blocks, []byte(`[{"start_address":100,"register_count":3}]`), 0o600); err != nil {
t.Fatal(err)
}
return map[string][]string{
"read": {"client", "read", "--url", url, "--start", "100", "--count", "3", "--output", filepath.Join(tmp, "read.mbcap")},
"decode": {"client", "decode", "--url", url, "--start", "103", "--codec", "float32/layout:4321"},
"write": {"client", "write", "--yes", "--url", url, "--start", "200", "--values", "7"},
"poll": {"client", "poll", "--url", url, "--start", "100", "--count", "3", "--polls", "2", "--interval", "1"},
"record": {"client", "record", "--url", url, "--blocks-file", blocks, "--interval", "10", "--duration", "25", "--output", filepath.Join(tmp, "rec.mbcap")},
"scan": {"client", "scan", "--url", url, "--start", "100", "--end", "104", "--output", filepath.Join(tmp, "scan.mbcap")},
"identify": {"client", "identify", "--url", url},
"reportserverid": {"client", "reportserverid", "--url", url},
"fingerprint": {"client", "fingerprint", "--url", url},
"diagnostic": {"client", "diagnostic", "--url", url},
"sunspec": {"client", "sunspec", "probe", "--url", url},
}
}

// Every client command that takes --url takes --proxy and --source-addr too.
func TestDial_flagsOnEveryDialingCommand(t *testing.T) {
n := 0
walkCommands(ClientCmd, func(c *cobra.Command) {
if c.Flags().Lookup("url") == nil {
return
}
n++
for _, f := range []string{"proxy", "source-addr"} {
if c.Flags().Lookup(f) == nil {
t.Errorf("%s has --url but no --%s", c.CommandPath(), f)
}
}
})
if n < 10 {
t.Fatalf("only %d commands take --url", n)
}
r := run(t, "client", "read", "--help")
wantExit(t, r, errs.ExitOK)
wantContains(t, r.stdout, "--proxy", "MODBUSCTL_PROXY", "--source-addr", "MODBUSCTL_SOURCE_ADDR")
}

// Each command reaches the device only through the proxy: the URL names an address the
// proxy alone forwards to the device.
func TestDial_proxyEveryCommand(t *testing.T) {
d, url := newDevice(t)
px := forwardingProxy(t, strings.TrimPrefix(url, "tcp://"))
for name, args := range dialingCommands(t, "tcp://"+unroutable) {
t.Run(name, func(t *testing.T) {
before := len(px.Requests())
r := run(t, append(args, "--proxy", px.URL())...)
if r.exit() != errs.ExitOK && r.exit() != errs.ExitPartial {
t.Fatalf("exit %d: %v\n%s", r.exit(), r.err, r.stderr)
}
reqs := px.Requests()
if len(reqs) == before {
t.Fatal("the command did not connect through the proxy")
}
if got := reqs[len(reqs)-1]; got.AddrType != 1 || got.Host != "192.0.2.10" || got.Port != 502 {
t.Fatalf("proxy saw %+v, want 192.0.2.10:502", got)
}
})
}
if len(d.Requests()) == 0 {
t.Fatal("the device saw no request")
}
}

// The proxy carries every TCP transport; with socks5h the host name goes to the proxy.
func TestDial_proxyTransports(t *testing.T) {
d, _ := newDevice(t)
for scheme, addr := range map[string]string{
"tcp": d.ServeTCP(t),
"rtuovertcp": d.ServeRTUOverTCP(t),
"asciiovertcp": d.ServeASCIIOverTCP(t),
} {
t.Run(scheme, func(t *testing.T) {
px := forwardingProxy(t, addr)
r := run(t, "client", "read", "--url", scheme+"://plc.invalid:502", "--start", "100", "--count", "3",
"--output", filepath.Join(t.TempDir(), "x.mbcap"), "--proxy", "socks5h://"+px.Addr)
wantExit(t, r, errs.ExitOK)
if reqs := px.Requests(); len(reqs) != 1 || reqs[0].AddrType != 3 || reqs[0].Host != "plc.invalid" {
t.Fatalf("proxy saw %+v, want the name plc.invalid", reqs)
}
})
}
}

// tcp+tls through the proxy: the handshake runs end to end and checks the certificate
// against the URL host, not the proxy.
func TestDial_proxyTLS(t *testing.T) {
d, _ := newDevice(t)
pki := mbtest.NewPKI(t)
_, port, _ := net.SplitHostPort(d.ServeTLS(t, pki))
px := forwardingProxy(t, net.JoinHostPort("127.0.0.1", port))
read := func(host string) cmdResult {
return run(t, append([]string{"client", "read", "--url", "tcp+tls://" + host + ":" + port, "--start", "100",
"--output", filepath.Join(t.TempDir(), "x.mbcap"), "--proxy", "socks5h://" + px.Addr}, tlsFlags(pki)...)...)
}
wantExit(t, read("localhost"), errs.ExitOK)
// The certificate names localhost and 127.0.0.1, not this host: refused, although the
// proxy delivered the connection to the same device.
r := read("plc.invalid")
wantKind(t, r, errs.KindTransport)
wantContains(t, r.err.Error(), "certificate")
}

func TestDial_refusingProxy(t *testing.T) {
d, url := newDevice(t)
px := socks5test.Start(t)
px.Reply = 2
out := filepath.Join(t.TempDir(), "x.mbcap")
r := run(t, "client", "read", "--url", url, "--start", "100", "--output", out, "--proxy", px.URL())
wantKind(t, r, errs.KindTransport)
wantExit(t, r, errs.ExitTransport)
wantContains(t, r.err.Error(), "socks5 proxy "+px.Addr, "socks5: connection not allowed by ruleset")
if len(d.Requests()) != 0 {
t.Fatal("a refused connection reached the device")
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Fatalf("a refused connection left %s behind: %v", out, err)
}
}

func TestDial_sourceAddr(t *testing.T) {
d, url := newDevice(t)
udp := "udp://" + d.ServeUDP(t)
for _, u := range []string{url, udp, "rtuovertcp://" + d.ServeRTUOverTCP(t)} {
r := run(t, "client", "read", "--url", u, "--start", "100", "--output", filepath.Join(t.TempDir(), "x.mbcap"), "--source-addr", "127.0.0.1")
wantExit(t, r, errs.ExitOK)
}
// With the proxy, the connection to the proxy is made from the source address.
px := forwardingProxy(t, strings.TrimPrefix(url, "tcp://"))
r := run(t, "client", "read", "--url", "tcp://"+unroutable, "--start", "100", "--output", filepath.Join(t.TempDir(), "x.mbcap"),
"--source-addr", "127.0.0.1", "--proxy", px.URL())
wantExit(t, r, errs.ExitOK)
// A source address this host does not have: the bind fails, a transport error.
for _, u := range []string{url, udp} {
r = run(t, "client", "read", "--url", u, "--start", "100", "--output", filepath.Join(t.TempDir(), "x.mbcap"), "--source-addr", "192.0.2.77")
wantKind(t, r, errs.KindTransport)
}
}

// Invalid values and transports the flags do not apply to are refused before anything is
// sent, on every command.
func TestDial_invalid(t *testing.T) {
d, url := newDevice(t)
udp := "udp://" + d.ServeUDP(t)
cases := []struct {
url, flag, value, want string
}{
{url, "--proxy", "http://127.0.0.1:8080", "the scheme must be socks5 or socks5h"},
{url, "--proxy", "socks5://user:pw@127.0.0.1:1080", "proxy credentials are not supported"},
{url, "--proxy", "socks5://127.0.0.1", "want socks5://host:port"},
{url, "--source-addr", "eth0", "invalid --source-addr"},
{udp, "--proxy", "socks5://127.0.0.1:1080", "--proxy does not apply to udp://"},
{"rtuoverudp://" + d.ServeUDP(t), "--proxy", "socks5://127.0.0.1:1080", "--proxy does not apply to rtuoverudp://"},
{"rtu:///dev/null", "--proxy", "socks5://127.0.0.1:1080", "do not apply to rtu://"},
{"rtu:///dev/null", "--source-addr", "127.0.0.1", "do not apply to rtu://"},
{"ascii:///dev/null", "--source-addr", "127.0.0.1", "do not apply to ascii://"},
}
for _, tc := range cases {
for name, args := range dialingCommands(t, tc.url) {
r := run(t, append(args, tc.flag, tc.value)...)
e, ok := errs.As(r.err)
if !ok || e.Kind != errs.KindInvalidInput || !strings.Contains(r.err.Error(), tc.want) {
t.Errorf("%s %s %s %s: exit %d, %v; want invalid input mentioning %q", name, tc.url, tc.flag, tc.value, r.exit(), r.err, tc.want)
}
}
}
if n := len(d.Requests()); n != 0 {
t.Fatalf("validation failures must not reach the device, saw %d requests", n)
}
}
Loading
Loading