diff --git a/README.md b/README.md index 78cd1c4..7a3c71f 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 | @@ -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 | diff --git a/RELEASE.md b/RELEASE.md index dba5b7c..3c277a2 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 ` (`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 diff --git a/SECURITY.md b/SECURITY.md index 3939e07..e1e69fd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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). diff --git a/cmd/client/dial_test.go b/cmd/client/dial_test.go new file mode 100644 index 0000000..bfd44be --- /dev/null +++ b/cmd/client/dial_test.go @@ -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) + } +} diff --git a/cmd/discover/dial_test.go b/cmd/discover/dial_test.go new file mode 100644 index 0000000..84d96a1 --- /dev/null +++ b/cmd/discover/dial_test.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT + +package discover + +import ( + "net" + "sort" + "testing" + + "github.com/otfabric/modbusctl/internal/errs" + "github.com/otfabric/modbusctl/internal/mbtest" + "github.com/otfabric/modbusctl/internal/socks5test" + "github.com/otfabric/modbusctl/internal/types" +) + +// Discovery through the proxy: every host of a TEST-NET range is probed through it, and +// the proxy forwards each CONNECT to the one fake device, which so answers for all. +func TestDiscover_proxy(t *testing.T) { + d := mbtest.NewDevice().SetHolding(0, 1) + addr := d.ServeTCP(t) + px := socks5test.Start(t) + px.Forward = func(string) string { return addr } + + r := run(t, "discover", "--subnets", "192.0.2.8/30", "--port", "502", "--proxy", px.URL(), "--source-addr", "127.0.0.1", "--format", "json") + wantExit(t, r, errs.ExitOK) + var out types.DiscoverOutput + decodeJSON(t, r.stdout, &out) + var hosts []string + for _, dev := range out.Devices { + hosts = append(hosts, dev.IP) + } + if len(hosts) != 2 || hosts[0] != "192.0.2.9" || hosts[1] != "192.0.2.10" { + t.Fatalf("devices %v, want 192.0.2.9 and 192.0.2.10", hosts) + } + var seen []string + for _, req := range px.Requests() { + if req.Port != 502 { + t.Errorf("proxy saw port %d, want 502", req.Port) + } + seen = append(seen, req.Host) + } + sort.Strings(seen) + if len(seen) != 2 || seen[0] != "192.0.2.10" || seen[1] != "192.0.2.9" { + t.Fatalf("proxy saw %v", seen) + } +} + +func TestDiscover_sourceAddr(t *testing.T) { + d := mbtest.NewDevice().SetHolding(0, 1) + port := servePort(t, d) + r := run(t, "discover", "--subnets", "127.0.0.1/32", "--port", port, "--source-addr", "127.0.0.1") + wantExit(t, r, errs.ExitOK) + wantContains(t, r.stdout, "Modbus device found at "+net.JoinHostPort("127.0.0.1", port)) +} + +func TestDiscover_dialInvalid(t *testing.T) { + d := mbtest.NewDevice().SetHolding(0, 1) + port := servePort(t, d) + for _, args := range [][]string{ + {"--proxy", "http://127.0.0.1:1"}, + {"--proxy", "socks5://127.0.0.1"}, + {"--source-addr", "eth0"}, + {"--proxy", "socks5://127.0.0.1:1080", "--resolve-mac", "--interface", "lo0"}, + } { + r := run(t, append([]string{"discover", "--subnets", "127.0.0.1/32", "--port", port}, args...)...) + wantKind(t, r, errs.KindInvalidInput) + } + if n := len(d.Requests()); n != 0 { + t.Fatalf("validation failures must not probe, device saw %d requests", n) + } + r := run(t, "discover", "--subnets", "127.0.0.1/32", "--port", port, "--proxy", "socks5://127.0.0.1:1080", "--resolve-mac", "--interface", "lo0") + wantContains(t, r.err.Error(), "--resolve-mac", "--proxy") +} diff --git a/go.mod b/go.mod index ebc15da..a313c63 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.0 require ( github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875 - github.com/otfabric/go-modbus v1.13.1 + github.com/otfabric/go-modbus v1.14.2 github.com/otfabric/go-modbus-capture v0.2.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 2d73b08..5921c24 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/otfabric/go-modbus v1.13.1 h1:tdIsrO/p8b79ZfgejZJu4l30RKH0E9j30CnzR9ueCDs= -github.com/otfabric/go-modbus v1.13.1/go.mod h1:FcVASTIAl1MNwPVW8nCMMubztiMueKHIFeVyHm0BDeY= +github.com/otfabric/go-modbus v1.14.2 h1:wrqZTAdFCtOo6fs2mo/Go3FAjARS2aO8ZpUZ0pbxqOo= +github.com/otfabric/go-modbus v1.14.2/go.mod h1:FcVASTIAl1MNwPVW8nCMMubztiMueKHIFeVyHm0BDeY= github.com/otfabric/go-modbus-capture v0.2.0 h1:tU1qYslLrWehUyk5wBQWRGdF8YNaPPpog9k5k8Rz98c= github.com/otfabric/go-modbus-capture v0.2.0/go.mod h1:z+1rnvMln/8Ra4lSzbekw4pybZroJuBf8GPXqCHY8qs= github.com/otfabric/go-serial v0.2.1 h1:PCOdnCDAHIHFaxTBNmisC/4SlD+NVd74d+EJPnXrCOw= diff --git a/internal/config/dial.go b/internal/config/dial.go new file mode 100644 index 0000000..4e35df5 --- /dev/null +++ b/internal/config/dial.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + "strings" + + "github.com/otfabric/modbusctl/internal/netdial" +) + +// DialConfig selects how the network connection to the device is opened. Every command +// that connects to a device embeds it. Zero values keep go-modbus's own dialer. +type DialConfig struct { + Proxy string `env:"MODBUSCTL_PROXY" desc:"Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy); tcp://, tcp+tls://, rtuovertcp:// and asciiovertcp:// only" flag:"proxy"` + SourceAddr string `env:"MODBUSCTL_SOURCE_ADDR" desc:"Local IP address to connect from (multi-homed hosts); network URLs only" flag:"source-addr"` +} + +// proxySchemes are the network schemes that run over TCP, which a SOCKS5 CONNECT carries. +var proxySchemes = []string{"tcp", "tcp+tls", "rtuovertcp", "asciiovertcp"} + +// IsSet reports whether --proxy or --source-addr is set. +func (d DialConfig) IsSet() bool { + return strings.TrimSpace(d.Proxy) != "" || strings.TrimSpace(d.SourceAddr) != "" +} + +// Dialer returns the dialer of the flags for modbus.Config.Dialer, or nil when neither is +// set. An error means a flag is invalid. +func (d DialConfig) Dialer() (netdial.Dialer, error) { + return netdial.New(strings.TrimSpace(d.SourceAddr), strings.TrimSpace(d.Proxy)) +} + +// ValidateDialConfig checks --proxy and --source-addr against modbusURL (the URL of --url, +// or "" for --ip/--port, which is tcp://). A serial URL takes neither; a UDP URL takes no +// proxy, since SOCKS5 UDP ASSOCIATE is not implemented. Unset flags always pass. +func ValidateDialConfig(modbusURL string, d DialConfig) error { + if !d.IsSet() { + return nil + } + scheme := "tcp" + if strings.TrimSpace(modbusURL) != "" { + scheme, _, _ = splitModbusURL(modbusURL) + } + if hasScheme(serialSchemes, scheme) { + return fmt.Errorf("--proxy and --source-addr do not apply to %s:// (a serial line)", scheme) + } + if strings.TrimSpace(d.Proxy) != "" && !hasScheme(proxySchemes, scheme) { + return fmt.Errorf("--proxy does not apply to %s://: SOCKS5 carries TCP only (use %s)", scheme, strings.Join(proxySchemes, ", ")) + } + _, err := d.Dialer() + return err +} diff --git a/internal/config/dial_test.go b/internal/config/dial_test.go new file mode 100644 index 0000000..426c2ec --- /dev/null +++ b/internal/config/dial_test.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT + +package config + +import ( + "strings" + "testing" +) + +func TestDialConfig_fromEnv(t *testing.T) { + t.Setenv("MODBUSCTL_PROXY", "socks5h://127.0.0.1:1080") + t.Setenv("MODBUSCTL_SOURCE_ADDR", "127.0.0.1") + var cfg ReadConfig + if err := LoadFromEnv(&cfg); err != nil { + t.Fatal(err) + } + if cfg.Proxy != "socks5h://127.0.0.1:1080" || cfg.SourceAddr != "127.0.0.1" { + t.Fatalf("env not loaded: %+v", cfg.DialConfig) + } + var disc DiscoverConfig + if err := LoadFromEnv(&disc); err != nil { + t.Fatal(err) + } + if !disc.IsSet() { + t.Fatal("discover does not take MODBUSCTL_PROXY") + } +} + +func TestDialConfig_Dialer(t *testing.T) { + if d, err := (DialConfig{}).Dialer(); d != nil || err != nil { + t.Fatalf("unset: %v, %v; want nil, nil", d != nil, err) + } + if d, err := (DialConfig{Proxy: " socks5://127.0.0.1:1080 ", SourceAddr: " 127.0.0.1 "}).Dialer(); d == nil || err != nil { + t.Fatalf("set: %v, %v", d != nil, err) + } + if _, err := (DialConfig{SourceAddr: "x"}).Dialer(); err == nil || !strings.Contains(err.Error(), "--source-addr") { + t.Fatalf("invalid: %v", err) + } +} + +func TestValidateDialConfig_ipPortIsTCP(t *testing.T) { + if err := ValidateDialConfig("", DialConfig{Proxy: "socks5://127.0.0.1:1080"}); err != nil { + t.Fatalf("--ip/--port with --proxy: %v", err) + } + if err := ValidateDialConfig("rtu:///dev/x", DialConfig{}); err != nil { + t.Fatalf("unset flags on rtu: %v", err) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 8d336b0..8db0032 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -17,6 +17,7 @@ type SerialConfig struct { type DeviceConfig struct { SerialConfig TLSConfig + DialConfig URL string `env:"MODBUSCTL_URL" desc:"Modbus URL: tcp://, tcp+tls://, udp://, rtu://, ascii://, rtuovertcp://, rtuoverudp:// or asciiovertcp:// (e.g. tcp://192.168.1.10:502, rtu:///dev/ttyUSB0); mutually exclusive with --ip/--port" flag:"url"` IP string `env:"MODBUSCTL_IP" desc:"Modbus TCP device IP (used when --url is not set)" flag:"ip"` Port uint16 `env:"MODBUSCTL_PORT" desc:"Modbus TCP port (used when --url is not set)" flag:"port"` @@ -27,6 +28,7 @@ type DeviceConfig struct { type UnitClientConfig struct { SerialConfig TLSConfig + DialConfig URL string `env:"MODBUSCTL_URL" desc:"Modbus URL: tcp://, tcp+tls://, udp://, rtu://, ascii://, rtuovertcp://, rtuoverudp:// or asciiovertcp:// (e.g. tcp://192.168.1.10:502, rtu:///dev/ttyUSB0); mutually exclusive with --ip/--port" flag:"url"` IP string `env:"MODBUSCTL_IP" desc:"Modbus TCP device IP (used when --url is not set)" flag:"ip"` Port uint16 `env:"MODBUSCTL_PORT" desc:"Modbus TCP port (used when --url is not set)" flag:"port"` @@ -213,6 +215,7 @@ type ReplayServerConfig struct { } type DiscoverConfig struct { + DialConfig Subnets []string `env:"MODBUSCTL_SUBNETS" desc:"List of subnets to scan for Modbus devices (e.g., 192.168.1.0/24,192.168.2.0/24)" flag:"subnets"` Port uint16 `env:"MODBUSCTL_PORT" desc:"Port to use for Modbus TCP discovery" flag:"port"` Parallel uint8 `env:"MODBUSCTL_PARALLEL" desc:"Parallel discovery workers (1-64)" flag:"parallel"` @@ -228,6 +231,7 @@ type DiscoverConfig struct { type FingerprintConfig struct { SerialConfig TLSConfig + DialConfig URL string `env:"MODBUSCTL_URL" desc:"Modbus URL: tcp://, tcp+tls://, udp://, rtu://, ascii://, rtuovertcp://, rtuoverudp:// or asciiovertcp:// (e.g. tcp://192.168.1.10:502, rtu:///dev/ttyUSB0); mutually exclusive with --ip/--port" flag:"url"` IP string `env:"MODBUSCTL_IP" desc:"Modbus TCP device IP (used when --url is not set)" flag:"ip"` Port uint16 `env:"MODBUSCTL_PORT" desc:"Modbus TCP port (used when --url is not set)" flag:"port"` @@ -242,6 +246,7 @@ type FingerprintConfig struct { type DiagnosticConfig struct { SerialConfig TLSConfig + DialConfig URL string `env:"MODBUSCTL_URL" desc:"Modbus URL: tcp://, tcp+tls://, udp://, rtu://, ascii://, rtuovertcp://, rtuoverudp:// or asciiovertcp:// (e.g. tcp://192.168.1.10:502, rtu:///dev/ttyUSB0); mutually exclusive with --ip/--port" flag:"url"` IP string `env:"MODBUSCTL_IP" desc:"Modbus TCP device IP (used when --url is not set)" flag:"ip"` Port uint16 `env:"MODBUSCTL_PORT" desc:"Modbus TCP port (used when --url is not set)" flag:"port"` @@ -272,6 +277,7 @@ type DeviceProfileDecodeConfig struct { type SunSpecBaseConfig struct { SerialConfig TLSConfig + DialConfig URL string `env:"MODBUSCTL_URL" desc:"Modbus URL: tcp://, tcp+tls://, udp://, rtu://, ascii://, rtuovertcp://, rtuoverudp:// or asciiovertcp:// (e.g. tcp://192.168.1.10:502, rtu:///dev/ttyUSB0); mutually exclusive with --ip/--port" flag:"url"` IP string `env:"MODBUSCTL_IP" desc:"Modbus TCP device IP (used when --url is not set)" flag:"ip"` Port uint16 `env:"MODBUSCTL_PORT" desc:"Modbus TCP port (used when --url is not set)" flag:"port"` diff --git a/internal/modbus/client.go b/internal/modbus/client.go index 27f6164..0683ee3 100644 --- a/internal/modbus/client.go +++ b/internal/modbus/client.go @@ -117,7 +117,7 @@ func applyTLSConfig(conf *modbus.Config, tlsCfg config.TLSConfig) error { return nil } -func buildDeviceClientConfig(modbusURL string, timeoutMS uint16, debug bool, serial config.SerialConfig, tlsCfg config.TLSConfig) (modbus.Config, error) { +func buildDeviceClientConfig(modbusURL string, timeoutMS uint16, debug bool, serial config.SerialConfig, tlsCfg config.TLSConfig, dial config.DialConfig) (modbus.Config, error) { req := clientRequestTimeout(timeoutMS) c := buildClientConfig(modbusURL, req, debug) c.DialTimeout = dialTimeoutForRequest(req) @@ -125,9 +125,28 @@ func buildDeviceClientConfig(modbusURL string, timeoutMS uint16, debug bool, ser if err := applyTLSConfig(&c, tlsCfg); err != nil { return modbus.Config{}, err } + if err := applyDialConfig(&c, dial); err != nil { + return modbus.Config{}, err + } return c, nil } +// applyDialConfig sets conf.Dialer from --proxy and --source-addr; with neither, go-modbus +// keeps its own dialer. go-modbus passes the dialer the unresolved host:port of the URL and +// runs the TLS handshake of tcp+tls:// on the returned connection, so the server certificate +// is still checked against the URL host. [config.ValidateDialConfig] reports invalid values +// and transports before a command gets this far. +func applyDialConfig(conf *modbus.Config, dial config.DialConfig) error { + d, err := dial.Dialer() + if err != nil { + return errs.InvalidInput(errs.CodeInvalidInput, err.Error(), err) + } + if d != nil { + conf.Dialer = d + } + return nil +} + // validateAndConnect validates conf, creates a client, and opens the connection. func validateAndConnect(conf modbus.Config) (*modbus.Client, func(), error) { if err := modbus.ValidateConfig(conf); err != nil { @@ -160,8 +179,8 @@ type deviceLink struct { } // openDeviceLink connects to modbusURL. The caller must Close the returned link. -func openDeviceLink(modbusURL string, timeoutMS uint16, debug bool, serial config.SerialConfig, tlsCfg config.TLSConfig) (*deviceLink, error) { - conf, err := buildDeviceClientConfig(modbusURL, timeoutMS, debug, serial, tlsCfg) +func openDeviceLink(modbusURL string, timeoutMS uint16, debug bool, serial config.SerialConfig, tlsCfg config.TLSConfig, dial config.DialConfig) (*deviceLink, error) { + conf, err := buildDeviceClientConfig(modbusURL, timeoutMS, debug, serial, tlsCfg, dial) if err != nil { return nil, err } @@ -179,8 +198,8 @@ func openDeviceLink(modbusURL string, timeoutMS uint16, debug bool, serial confi // openRecordingLink is openDeviceLink for a link whose successful reads are recorded as samples // of stream, once [deviceLink.record] attaches a recorder. Connecting first means that a device // that cannot be reached leaves no capture file behind. -func openRecordingLink(modbusURL string, timeoutMS uint16, debug bool, serial config.SerialConfig, tlsCfg config.TLSConfig, stream uint32) (*deviceLink, error) { - conf, err := buildDeviceClientConfig(modbusURL, timeoutMS, debug, serial, tlsCfg) +func openRecordingLink(modbusURL string, timeoutMS uint16, debug bool, serial config.SerialConfig, tlsCfg config.TLSConfig, dial config.DialConfig, stream uint32) (*deviceLink, error) { + conf, err := buildDeviceClientConfig(modbusURL, timeoutMS, debug, serial, tlsCfg, dial) if err != nil { return nil, err } @@ -244,15 +263,17 @@ func (l *deviceLink) Close() { } } -func discoveryClientConfig(modbusURL string, debug bool) modbus.Config { +func discoveryClientConfig(modbusURL string, debug bool, dial modbus.Dialer) modbus.Config { c := buildClientConfig(modbusURL, discoveryModbusTimeout, debug) c.DialTimeout = discoveryDialTimeout + c.Dialer = dial return c } -// connectDiscovery opens one Modbus TCP client (dial + Open) with short timeouts for subnet discovery. -func connectDiscovery(modbusURL string, debug bool) (*modbus.Client, func(), error) { - return validateAndConnect(discoveryClientConfig(modbusURL, debug)) +// connectDiscovery opens one Modbus TCP client (dial + Open) with short timeouts for subnet +// discovery; dial is the dialer of --proxy and --source-addr, or nil. +func connectDiscovery(modbusURL string, debug bool, dial modbus.Dialer) (*modbus.Client, func(), error) { + return validateAndConnect(discoveryClientConfig(modbusURL, debug, dial)) } // classifyOutcome returns outcome type and Modbus exception code (0 when not an exception). @@ -504,7 +525,7 @@ func CollectRead(ctx context.Context, cfg config.ReadConfig, progress io.Writer) progress = io.Discard } modbusURL := config.ModbusURL(cfg.URL, cfg.IP, cfg.Port) - link, err := openRecordingLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, 1) + link, err := openRecordingLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig, 1) if err != nil { return nil, err } @@ -567,7 +588,7 @@ func ScanAndRecord(ctx context.Context, cfg config.ScanConfig, progress io.Write } out = AutoCapturePath(dir, "scan") } - link, err := openRecordingLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, 1) + link, err := openRecordingLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig, 1) if err != nil { return nil, err } @@ -729,7 +750,7 @@ func RecordToCapture(ctx context.Context, cfg config.RecordConfig, progress io.W if fsutil.SameFile(out, cfg.BlocksFile) { return nil, errs.InvalidInput(errs.CodeInvalidFlagCombination, fsutil.ErrSameFile.Error(), fsutil.ErrSameFile) } - link, err := openRecordingLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, 1) + link, err := openRecordingLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig, 1) if err != nil { return nil, err } diff --git a/internal/modbus/coll_identify_test.go b/internal/modbus/coll_identify_test.go index 91ac389..92b621b 100644 --- a/internal/modbus/coll_identify_test.go +++ b/internal/modbus/coll_identify_test.go @@ -355,7 +355,7 @@ func TestCollIdentify_ipPortTarget(t *testing.T) { func TestCollClientPool(t *testing.T) { dev := mbtest.NewDevice().SetHolding(0, 0xBEEF) - conf, err := buildDeviceClientConfig("tcp://"+dev.ServeTCP(t), 1000, false, config.SerialConfig{}, config.TLSConfig{}) + conf, err := buildDeviceClientConfig("tcp://"+dev.ServeTCP(t), 1000, false, config.SerialConfig{}, config.TLSConfig{}, config.DialConfig{}) require.NoError(t, err) t.Run("opens n independent clients", func(t *testing.T) { diff --git a/internal/modbus/collect_diagnostics.go b/internal/modbus/collect_diagnostics.go index 7a8c2ff..d8e7eb5 100644 --- a/internal/modbus/collect_diagnostics.go +++ b/internal/modbus/collect_diagnostics.go @@ -22,7 +22,7 @@ func CollectDiagnostics(ctx context.Context, cfg config.DiagnosticConfig) (*type } modbusURL := config.ModbusURL(cfg.URL, cfg.IP, cfg.Port) - link, err := openDeviceLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig) + link, err := openDeviceLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig) if err != nil { return nil, err } diff --git a/internal/modbus/collect_fingerprint.go b/internal/modbus/collect_fingerprint.go index 636a18a..cf13204 100644 --- a/internal/modbus/collect_fingerprint.go +++ b/internal/modbus/collect_fingerprint.go @@ -21,7 +21,7 @@ func CollectFingerprint(ctx context.Context, cfg config.FingerprintConfig) (*typ modbusURL := config.ModbusURL(cfg.URL, cfg.IP, cfg.Port) res := &types.FingerprintResult{Target: modbusURL} - conf, err := buildDeviceClientConfig(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig) + conf, err := buildDeviceClientConfig(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig) if err != nil { return nil, err } diff --git a/internal/modbus/collect_identify.go b/internal/modbus/collect_identify.go index c1394d4..46ea61e 100644 --- a/internal/modbus/collect_identify.go +++ b/internal/modbus/collect_identify.go @@ -26,7 +26,7 @@ func CollectDeviceIdentification(ctx context.Context, cfg config.IdentifyConfig) modbusURL := config.ModbusURL(cfg.URL, cfg.IP, cfg.Port) res := &types.IdentifyResult{Target: modbusURL} - conf, err := buildDeviceClientConfig(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig) + conf, err := buildDeviceClientConfig(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig) if err != nil { return nil, err } diff --git a/internal/modbus/collect_report_server_id.go b/internal/modbus/collect_report_server_id.go index 9a4eb15..d4a6125 100644 --- a/internal/modbus/collect_report_server_id.go +++ b/internal/modbus/collect_report_server_id.go @@ -23,7 +23,7 @@ func CollectReportServerID(ctx context.Context, cfg config.ReportServerIDConfig) modbusURL := config.ModbusURL(cfg.URL, cfg.IP, cfg.Port) res := &types.ReportServerIDResult{Target: modbusURL} - conf, err := buildDeviceClientConfig(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig) + conf, err := buildDeviceClientConfig(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig) if err != nil { return nil, err } diff --git a/internal/modbus/core_read_test.go b/internal/modbus/core_read_test.go index a7f3368..75c45cf 100644 --- a/internal/modbus/core_read_test.go +++ b/internal/modbus/core_read_test.go @@ -216,7 +216,7 @@ func TestCoreCollectRead_failures(t *testing.T) { // coreOpenLink opens a device link to dev with the given debug setting. func coreOpenLink(t *testing.T, url string, debug bool) *deviceLink { t.Helper() - link, err := openDeviceLink(url, coreTimeoutMS, debug, config.SerialConfig{}, config.TLSConfig{}) + link, err := openDeviceLink(url, coreTimeoutMS, debug, config.SerialConfig{}, config.TLSConfig{}, config.DialConfig{}) require.NoError(t, err) t.Cleanup(link.Close) return link @@ -467,11 +467,11 @@ func TestCoreClientConfig(t *testing.T) { assert.NotNil(t, debug.Logger) assert.NotEqual(t, fmt.Sprintf("%T", quiet.Logger), fmt.Sprintf("%T", debug.Logger), "debug selects a real logger") - disc := discoveryClientConfig("tcp://h:1", false) + disc := discoveryClientConfig("tcp://h:1", false, nil) assert.Equal(t, discoveryModbusTimeout, disc.Timeout) assert.Equal(t, discoveryDialTimeout, disc.DialTimeout) - dev, err := buildDeviceClientConfig("rtu:///dev/null", 300, false, config.SerialConfig{Baud: 9600, DataBits: 7, StopBits: 2, Parity: "odd"}, config.TLSConfig{TLSCert: "ignored"}) + dev, err := buildDeviceClientConfig("rtu:///dev/null", 300, false, config.SerialConfig{Baud: 9600, DataBits: 7, StopBits: 2, Parity: "odd"}, config.TLSConfig{TLSCert: "ignored"}, config.DialConfig{}) require.NoError(t, err) assert.Equal(t, 300*time.Millisecond, dev.Timeout) assert.Equal(t, 300*time.Millisecond, dev.DialTimeout) @@ -525,7 +525,7 @@ func TestCoreValidateAndConnect(t *testing.T) { }) t.Run("nothing listening", func(t *testing.T) { - conf, err := buildDeviceClientConfig(coreClosedURL(t), coreShortTimeoutMS, false, config.SerialConfig{}, config.TLSConfig{}) + conf, err := buildDeviceClientConfig(coreClosedURL(t), coreShortTimeoutMS, false, config.SerialConfig{}, config.TLSConfig{}, config.DialConfig{}) require.NoError(t, err) client, cleanup, err := validateAndConnect(conf) require.Error(t, err) @@ -534,14 +534,14 @@ func TestCoreValidateAndConnect(t *testing.T) { }) t.Run("serial device missing", func(t *testing.T) { - _, err := openDeviceLink("rtu://"+filepath.Join(t.TempDir(), "no-such-tty"), coreShortTimeoutMS, false, config.SerialConfig{Baud: 9600}, config.TLSConfig{}) + _, err := openDeviceLink("rtu://"+filepath.Join(t.TempDir(), "no-such-tty"), coreShortTimeoutMS, false, config.SerialConfig{Baud: 9600}, config.TLSConfig{}, config.DialConfig{}) require.ErrorIs(t, err, ErrTCPConnection) require.ErrorIs(t, err, os.ErrNotExist) }) t.Run("discovery connect", func(t *testing.T) { dev := mbtest.NewDevice().SetHolding(0, 0x1111) - client, cleanup, err := connectDiscovery("tcp://"+dev.ServeTCP(t), false) + client, cleanup, err := connectDiscovery("tcp://"+dev.ServeTCP(t), false, nil) require.NoError(t, err) defer cleanup() regs, err := client.ReadRegisters(context.Background(), 1, 0, 1, gomodbus.HoldingRegister) diff --git a/internal/modbus/decode.go b/internal/modbus/decode.go index c1f577b..7b98f90 100644 --- a/internal/modbus/decode.go +++ b/internal/modbus/decode.go @@ -43,7 +43,7 @@ func CollectDecode(ctx context.Context, cfg config.DecodeConfig, progress io.Wri } modbusURL := config.ModbusURL(cfg.URL, cfg.IP, cfg.Port) - link, err := openDeviceLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig) + link, err := openDeviceLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig) if err != nil { return nil, err } diff --git a/internal/modbus/discover.go b/internal/modbus/discover.go index e66343c..98fdeda 100644 --- a/internal/modbus/discover.go +++ b/internal/modbus/discover.go @@ -31,6 +31,10 @@ func CollectDiscover(ctx context.Context, cfg config.DiscoverConfig, stderr io.W if cfg.Parallel < 1 { cfg.Parallel = 1 } + dial, err := cfg.Dialer() + if err != nil { + return nil, errs.InvalidInput(errs.CodeInvalidInput, err.Error(), err) + } jobs := make(chan string, int(cfg.Parallel)*4) go func() { @@ -71,7 +75,7 @@ func CollectDiscover(ctx context.Context, cfg config.DiscoverConfig, stderr io.W default: } modbusURL := config.ModbusURL("", ip, cfg.Port) - client, cleanup, err := connectDiscovery(modbusURL, cfg.Debug) + client, cleanup, err := connectDiscovery(modbusURL, cfg.Debug, dial) if err != nil { if cfg.Debug { _, _ = fmt.Fprintf(stderr, "DEBUG [discover] %s: connect/setup: %v\n", ip, err) diff --git a/internal/modbus/e2e_tls_test.go b/internal/modbus/e2e_tls_test.go index 74148c3..10ff86d 100644 --- a/internal/modbus/e2e_tls_test.go +++ b/internal/modbus/e2e_tls_test.go @@ -411,7 +411,7 @@ func TestE2ETLS_ignoredForOtherURLs(t *testing.T) { OutputFile: filepath.Join(t.TempDir(), "r.mbcap")}, nil) require.NoError(t, err) assert.Equal(t, "00 09", res.RawDataHex) - conf, err := buildDeviceClientConfig(d.URL, coreTimeoutMS, false, config.SerialConfig{}, bogus) + conf, err := buildDeviceClientConfig(d.URL, coreTimeoutMS, false, config.SerialConfig{}, bogus, config.DialConfig{}) require.NoError(t, err) assert.Nil(t, conf.TLSClientCert) assert.Nil(t, conf.TLSRootCAs) diff --git a/internal/modbus/poll.go b/internal/modbus/poll.go index 42f9693..f21d66f 100644 --- a/internal/modbus/poll.go +++ b/internal/modbus/poll.go @@ -70,7 +70,7 @@ func Poll(ctx context.Context, cfg config.PollConfig, progress io.Writer, emit f } modbusURL := config.ModbusURL(cfg.URL, cfg.IP, cfg.Port) - link, err := openDeviceLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig) + link, err := openDeviceLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig) if err != nil { return nil, err } diff --git a/internal/modbus/sunspec.go b/internal/modbus/sunspec.go index f4cec9a..1375e59 100644 --- a/internal/modbus/sunspec.go +++ b/internal/modbus/sunspec.go @@ -94,7 +94,7 @@ func sunspecOperationContext(parent context.Context, cfg *config.SunSpecBaseConf } func openSunSpecModbusClient(modbusURL string, cfg *config.SunSpecBaseConfig) (*modbus.Client, func(), error) { - conf, err := buildDeviceClientConfig(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig) + conf, err := buildDeviceClientConfig(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig) if err != nil { return nil, nil, err } diff --git a/internal/modbus/tls_config_errors_test.go b/internal/modbus/tls_config_errors_test.go index 88519e7..d61d29e 100644 --- a/internal/modbus/tls_config_errors_test.go +++ b/internal/modbus/tls_config_errors_test.go @@ -26,9 +26,9 @@ func TestTLSConfigErrorsBeforeDial(t *testing.T) { } } - _, err := openDeviceLink(url, 100, false, config.SerialConfig{}, bad) + _, err := openDeviceLink(url, 100, false, config.SerialConfig{}, bad, config.DialConfig{}) wantInvalid("openDeviceLink", err) - _, err = openRecordingLink(url, 100, false, config.SerialConfig{}, bad, 1) + _, err = openRecordingLink(url, 100, false, config.SerialConfig{}, bad, config.DialConfig{}, 1) wantInvalid("openRecordingLink", err) var fp config.FingerprintConfig diff --git a/internal/modbus/write.go b/internal/modbus/write.go index 82ce050..29d483f 100644 --- a/internal/modbus/write.go +++ b/internal/modbus/write.go @@ -239,7 +239,7 @@ func CollectWrite(ctx context.Context, cfg config.WriteConfig) (*types.WriteResu } modbusURL := config.ModbusURL(cfg.URL, cfg.IP, cfg.Port) - link, err := openDeviceLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig) + link, err := openDeviceLink(modbusURL, cfg.Timeout, cfg.Debug, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig) if err != nil { return nil, err } diff --git a/internal/netdial/netdial.go b/internal/netdial/netdial.go new file mode 100644 index 0000000..11aa678 --- /dev/null +++ b/internal/netdial/netdial.go @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MIT + +// Package netdial builds the modbus.Config.Dialer of the --source-addr and +// --proxy flags: a TCP or UDP dial bound to a local address, or a TCP dial +// through a SOCKS5 proxy (RFC 1928 CONNECT, no authentication). +package netdial + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/url" + "strconv" + "time" +) + +// Dialer opens a connection to addr over network ("tcp" or "udp"), as +// net.Dialer.DialContext does; it is assignable to modbus.Dialer. +type Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) + +// New returns the dialer for sourceAddr (an IP literal to bind, or "") and +// proxy ("socks5://host:port", "socks5h://host:port" or ""). It returns nil +// when both are empty, so the library keeps its default dialer. Errors are +// usage errors: the flags are invalid. +func New(sourceAddr, proxy string) (Dialer, error) { + if sourceAddr == "" && proxy == "" { + return nil, nil + } + var base local + if sourceAddr != "" { + base.ip = net.ParseIP(sourceAddr) + if base.ip == nil { + return nil, fmt.Errorf("invalid --source-addr %q: want an IP address", sourceAddr) + } + } + if proxy == "" { + return base.dial, nil + } + p, err := parseProxy(proxy) + if err != nil { + return nil, err + } + p.base = base + return p.dial, nil +} + +// local dials directly, from ip when it is set: a TCP address for tcp +// networks, a UDP address for udp ones. +type local struct{ ip net.IP } + +func (l local) dial(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + if l.ip != nil { + switch network { + case "udp", "udp4", "udp6": + d.LocalAddr = &net.UDPAddr{IP: l.ip} + default: + d.LocalAddr = &net.TCPAddr{IP: l.ip} + } + } + return d.DialContext(ctx, network, addr) +} + +type socks5 struct { + addr string // proxy host:port + remoteName bool // socks5h: the proxy resolves host names + base local +} + +func parseProxy(s string) (*socks5, error) { + u, err := url.Parse(s) + if err != nil { + return nil, fmt.Errorf("invalid --proxy %q: %w", s, err) + } + if u.Scheme != "socks5" && u.Scheme != "socks5h" { + return nil, fmt.Errorf("invalid --proxy %q: the scheme must be socks5 or socks5h", s) + } + if u.User != nil { + return nil, fmt.Errorf("invalid --proxy %q: proxy credentials are not supported", s) + } + if u.Path != "" && u.Path != "/" || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("invalid --proxy %q: want socks5://host:port", s) + } + host, port := u.Hostname(), u.Port() + if host == "" || port == "" { + return nil, fmt.Errorf("invalid --proxy %q: want socks5://host:port", s) + } + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return nil, fmt.Errorf("invalid --proxy %q: invalid port", s) + } + return &socks5{addr: net.JoinHostPort(host, port), remoteName: u.Scheme == "socks5h"}, nil +} + +// SOCKS5 reply codes (RFC 1928 section 6). +var replyText = map[byte]string{ + 1: "general SOCKS server failure", + 2: "connection not allowed by ruleset", + 3: "network unreachable", + 4: "host unreachable", + 5: "connection refused", + 6: "TTL expired", + 7: "command not supported", + 8: "address type not supported", +} + +// ReplyError is a SOCKS5 proxy refusing the CONNECT. +type ReplyError struct{ Code byte } + +func (e *ReplyError) Error() string { + if t, ok := replyText[e.Code]; ok { + return "socks5: " + t + } + return fmt.Sprintf("socks5: reply code %d", e.Code) +} + +var errProtocol = errors.New("socks5: unexpected reply from the proxy") + +func (p *socks5) dial(ctx context.Context, network, addr string) (net.Conn, error) { + if network != "tcp" && network != "tcp4" && network != "tcp6" { + return nil, fmt.Errorf("socks5: network %q not supported", network) + } + req, err := p.connectRequest(ctx, addr) + if err != nil { + return nil, err + } + conn, err := p.base.dial(ctx, "tcp", p.addr) + if err != nil { + return nil, fmt.Errorf("socks5 proxy %s: %w", p.addr, err) + } + // The handshake honours ctx, its deadline and its cancel, through one + // path: when ctx is done the pending I/O fails, so ctx.Err() is already + // set and the error matches context.DeadlineExceeded / Canceled. + stop := context.AfterFunc(ctx, func() { _ = conn.SetDeadline(time.Unix(1, 0)) }) + err = handshake(conn, req) + stop() + if err == nil { + // A ctx that ended as the handshake finished may have set the past + // deadline already: fail rather than return a poisoned connection. + err = ctx.Err() + } + if err != nil { + _ = conn.Close() + if ctxErr := ctx.Err(); ctxErr != nil && !errors.Is(err, ctxErr) { + err = fmt.Errorf("%w: %w", ctxErr, err) + } + return nil, fmt.Errorf("socks5 proxy %s: %w", p.addr, err) + } + _ = conn.SetDeadline(time.Time{}) + return conn, nil +} + +// connectRequest encodes CONNECT for addr: an IP literal as such; a host +// name sent to the proxy (socks5h) or resolved here first (socks5). +func (p *socks5) connectRequest(ctx context.Context, addr string) ([]byte, error) { + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + port, err := strconv.Atoi(portStr) + if err != nil || port < 0 || port > 65535 { + return nil, fmt.Errorf("socks5: invalid port in %q", addr) + } + req := []byte{5, 1, 0} + ip := net.ParseIP(host) + if ip == nil && !p.remoteName { + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + ip = ips[0].IP + } + switch { + case ip == nil: + if len(host) > 255 { + return nil, fmt.Errorf("socks5: host name too long") + } + req = append(req, 3, byte(len(host))) + req = append(req, host...) + case ip.To4() != nil: + req = append(req, 1) + req = append(req, ip.To4()...) + default: + req = append(req, 4) + req = append(req, ip.To16()...) + } + return binary.BigEndian.AppendUint16(req, uint16(port)), nil +} + +func handshake(conn net.Conn, req []byte) error { + // Greeting: version 5, one method, "no authentication". + if _, err := conn.Write([]byte{5, 1, 0}); err != nil { + return err + } + var sel [2]byte + if _, err := io.ReadFull(conn, sel[:]); err != nil { + return err + } + if sel[0] != 5 { + return errProtocol + } + if sel[1] != 0 { + return errors.New("socks5: the proxy requires authentication, which is not supported") + } + if _, err := conn.Write(req); err != nil { + return err + } + var hdr [4]byte + if _, err := io.ReadFull(conn, hdr[:]); err != nil { + return err + } + if hdr[0] != 5 { + return errProtocol + } + if hdr[1] != 0 { + return &ReplyError{Code: hdr[1]} + } + var n int + switch hdr[3] { + case 1: + n = 4 + case 4: + n = 16 + case 3: + var l [1]byte + if _, err := io.ReadFull(conn, l[:]); err != nil { + return err + } + n = int(l[0]) + default: + return errProtocol + } + // The bound address and port: read and discard. + _, err := io.ReadFull(conn, make([]byte, n+2)) + return err +} diff --git a/internal/netdial/netdial_test.go b/internal/netdial/netdial_test.go new file mode 100644 index 0000000..32522bc --- /dev/null +++ b/internal/netdial/netdial_test.go @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: MIT + +package netdial + +import ( + "context" + "errors" + "io" + "net" + "strconv" + "strings" + "testing" + "time" + + "github.com/otfabric/modbusctl/internal/socks5test" +) + +// echo listens on loopback and echoes what it reads; it returns host:port. +func echo(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { defer func() { _ = c.Close() }(); _, _ = io.Copy(c, c) }() + } + }() + return ln.Addr().String() +} + +func roundTrip(t *testing.T, c net.Conn) { + t.Helper() + defer func() { _ = c.Close() }() + _ = c.SetDeadline(time.Now().Add(2 * time.Second)) + if _, err := c.Write([]byte("ping")); err != nil { + t.Fatal(err) + } + buf := make([]byte, 4) + if _, err := io.ReadFull(c, buf); err != nil || string(buf) != "ping" { + t.Fatalf("echo = %q, %v", buf, err) + } +} + +func ctx2s(t *testing.T) context.Context { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + t.Cleanup(cancel) + return ctx +} + +func TestNewNothingSet(t *testing.T) { + d, err := New("", "") + if d != nil || err != nil { + t.Fatalf("New(\"\", \"\") = %v, %v; want nil, nil", d != nil, err) + } +} + +func TestNewInvalid(t *testing.T) { + for _, tc := range []struct{ src, proxy, want string }{ + {"not-an-ip", "", "--source-addr"}, + {"", "http://127.0.0.1:8080", "scheme must be socks5"}, + {"", "socks5://user:pw@127.0.0.1:1080", "credentials are not supported"}, + {"", "socks5://127.0.0.1", "want socks5://host:port"}, + {"", "socks5://:1080", "want socks5://host:port"}, + {"", "socks5://127.0.0.1:0", "invalid port"}, + {"", "socks5://127.0.0.1:99999", "invalid port"}, + {"", "socks5://127.0.0.1:1080/path", "want socks5://host:port"}, + {"", "socks5://127.0.0.1:1080?x=1", "want socks5://host:port"}, + {"", "socks5://[::1", "invalid --proxy"}, + } { + if _, err := New(tc.src, tc.proxy); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("New(%q, %q) error = %v, want it to mention %q", tc.src, tc.proxy, err, tc.want) + } + } +} + +func TestSourceAddrBinds(t *testing.T) { + d, err := New("127.0.0.1", "") + if err != nil { + t.Fatal(err) + } + c, err := d(ctx2s(t), "tcp", echo(t)) + if err != nil { + t.Fatal(err) + } + if ip := c.LocalAddr().(*net.TCPAddr).IP; !ip.Equal(net.IPv4(127, 0, 0, 1)) { + t.Errorf("local address %v, want 127.0.0.1", ip) + } + roundTrip(t, c) +} + +func TestProxyConnect(t *testing.T) { + target := echo(t) + host, portStr, _ := net.SplitHostPort(target) + port, _ := strconv.Atoi(portStr) + for _, bound := range []byte{1, 3, 4} { + px := socks5test.Start(t) + px.BoundType = bound + d, err := New("127.0.0.1", px.URL()) + if err != nil { + t.Fatal(err) + } + c, err := d(ctx2s(t), "tcp", target) + if err != nil { + t.Fatalf("bound type %d: %v", bound, err) + } + roundTrip(t, c) + if r := px.Requests(); len(r) != 1 || r[0].AddrType != 1 || r[0].Host != host || r[0].Port != port { + t.Errorf("bound type %d: proxy saw %+v, want IPv4 %s:%d", bound, r, host, port) + } + } +} + +func TestProxyIPv6Target(t *testing.T) { + px := socks5test.Start(t) + target := echo(t) + px.Forward = func(string) string { return target } + d, _ := New("", px.URL()) + c, err := d(ctx2s(t), "tcp", "[2001:db8::7]:102") + if err != nil { + t.Fatal(err) + } + roundTrip(t, c) + if r := px.Requests(); len(r) != 1 || r[0].AddrType != 4 || r[0].Host != "2001:db8::7" || r[0].Port != 102 { + t.Errorf("proxy saw %+v, want IPv6 2001:db8::7:102", r) + } +} + +func TestProxyHostNames(t *testing.T) { + target := echo(t) + // socks5h sends the name; socks5 resolves it here first. + for _, tc := range []struct { + scheme string + addrType byte + }{{"socks5h", 3}, {"socks5", 0}} { + px := socks5test.Start(t) + px.Forward = func(string) string { return target } + d, _ := New("", tc.scheme+"://"+px.Addr) + c, err := d(ctx2s(t), "tcp", "localhost:102") + if err != nil { + t.Fatalf("%s: %v", tc.scheme, err) + } + roundTrip(t, c) + r := px.Requests() + switch { + case len(r) != 1: + t.Errorf("%s: %d requests", tc.scheme, len(r)) + case tc.addrType == 3 && (r[0].AddrType != 3 || r[0].Host != "localhost"): + t.Errorf("%s: proxy saw %+v, want the name", tc.scheme, r[0]) + case tc.addrType == 0 && r[0].AddrType == 3: + t.Errorf("%s: proxy saw the name %+v, want a resolved IP", tc.scheme, r[0]) + } + } +} + +func TestProxyRefusals(t *testing.T) { + for _, tc := range []struct { + name string + setup func(*socks5test.Server) + check func(error) bool + }{ + {"connection refused", func(s *socks5test.Server) { s.Reply = 5 }, + func(err error) bool { + var re *ReplyError + return errors.As(err, &re) && re.Code == 5 && strings.Contains(err.Error(), "connection refused") + }}, + {"ruleset", func(s *socks5test.Server) { s.Reply = 2 }, + func(err error) bool { return strings.Contains(err.Error(), "not allowed by ruleset") }}, + {"unknown code", func(s *socks5test.Server) { s.Reply = 0x42 }, + func(err error) bool { return strings.Contains(err.Error(), "reply code 66") }}, + {"authentication required", func(s *socks5test.Server) { s.Method = 0xFF }, + func(err error) bool { return strings.Contains(err.Error(), "requires authentication") }}, + {"wrong version", func(s *socks5test.Server) { s.Version = 4 }, + func(err error) bool { return errors.Is(err, errProtocol) }}, + {"bad bound address type", func(s *socks5test.Server) { s.BoundType = 9 }, + func(err error) bool { return errors.Is(err, errProtocol) }}, + {"silent proxy times out", func(s *socks5test.Server) { s.Silent = true }, + func(err error) bool { return errors.Is(err, context.DeadlineExceeded) }}, + } { + t.Run(tc.name, func(t *testing.T) { + px := socks5test.Start(t) + tc.setup(px) + d, _ := New("", px.URL()) + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + c, err := d(ctx, "tcp", echo(t)) + if c != nil || err == nil || !tc.check(err) { + t.Fatalf("dial = %v, %v", c, err) + } + if !strings.Contains(err.Error(), "socks5 proxy "+px.Addr) { + t.Errorf("error %q does not name the proxy", err) + } + }) + } +} + +func TestProxyCancel(t *testing.T) { + px := socks5test.Start(t) + px.Silent = true + d, _ := New("", px.URL()) + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(50*time.Millisecond, cancel) + if _, err := d(ctx, "tcp", "127.0.0.1:102"); !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +func TestProxyErrors(t *testing.T) { + d, _ := New("", "socks5://127.0.0.1:1") + if _, err := d(ctx2s(t), "udp", "127.0.0.1:102"); err == nil || !strings.Contains(err.Error(), "not supported") { + t.Errorf("udp: %v", err) + } + if _, err := d(ctx2s(t), "tcp", "no-port"); err == nil { + t.Error("address without port: no error") + } + if _, err := d(ctx2s(t), "tcp", "127.0.0.1:70000"); err == nil || !strings.Contains(err.Error(), "invalid port") { + t.Errorf("bad port: %v", err) + } + long := strings.Repeat("a", 256) + ":102" + dh, _ := New("", "socks5h://127.0.0.1:1") + if _, err := dh(ctx2s(t), "tcp", long); err == nil || !strings.Contains(err.Error(), "too long") { + t.Errorf("long name: %v", err) + } + if _, err := d(ctx2s(t), "tcp", "name.invalid:102"); err == nil { + t.Error("unresolvable name with socks5: no error") + } + // Nothing listens on the proxy port. + if _, err := d(ctx2s(t), "tcp", "127.0.0.1:102"); err == nil || !strings.Contains(err.Error(), "socks5 proxy 127.0.0.1:1") { + t.Errorf("proxy down: %v", err) + } +} + +// handshake against a scripted peer on net.Pipe: each step's failure. +func TestHandshakeFailures(t *testing.T) { + req := []byte{5, 1, 0, 1, 127, 0, 0, 1, 0, 102} + for _, tc := range []struct { + name string + peer func(c net.Conn) + }{ + {"peer gone before greeting", func(c net.Conn) { _ = c.Close() }}, + {"peer gone before request", func(c net.Conn) { + _, _ = io.ReadFull(c, make([]byte, 3)) + _, _ = c.Write([]byte{5, 0}) + _ = c.Close() + }}, + {"peer gone before reply", func(c net.Conn) { + _, _ = io.ReadFull(c, make([]byte, 3)) + _, _ = c.Write([]byte{5, 0}) + _, _ = io.ReadFull(c, make([]byte, len(req))) + _ = c.Close() + }}, + {"reply with wrong version", func(c net.Conn) { + _, _ = io.ReadFull(c, make([]byte, 3)) + _, _ = c.Write([]byte{5, 0}) + _, _ = io.ReadFull(c, make([]byte, len(req))) + _, _ = c.Write([]byte{4, 0, 0, 1}) + }}, + {"name bound address cut", func(c net.Conn) { + _, _ = io.ReadFull(c, make([]byte, 3)) + _, _ = c.Write([]byte{5, 0}) + _, _ = io.ReadFull(c, make([]byte, len(req))) + _, _ = c.Write([]byte{5, 0, 0, 3}) + _ = c.Close() + }}, + } { + t.Run(tc.name, func(t *testing.T) { + a, b := net.Pipe() + defer func() { _ = a.Close() }() + go func() { defer func() { _ = b.Close() }(); tc.peer(b) }() + _ = a.SetDeadline(time.Now().Add(2 * time.Second)) + if err := handshake(a, req); err == nil { + t.Fatal("no error") + } + }) + } +} + +func TestSourceAddrBindsUDP(t *testing.T) { + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = pc.Close() }() + d, err := New("127.0.0.1", "") + if err != nil { + t.Fatal(err) + } + c, err := d(ctx2s(t), "udp", pc.LocalAddr().String()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = c.Close() }() + la, ok := c.LocalAddr().(*net.UDPAddr) + if !ok || !la.IP.Equal(net.IPv4(127, 0, 0, 1)) { + t.Fatalf("local address %v, want 127.0.0.1 over udp", c.LocalAddr()) + } + if _, err := c.Write([]byte("x")); err != nil { + t.Fatal(err) + } + _ = pc.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, from, err := pc.ReadFrom(make([]byte, 4)) + if err != nil || from.String() != la.String() { + t.Fatalf("datagram from %v, %v; want %v", from, err, la) + } +} diff --git a/internal/socks5test/socks5test.go b/internal/socks5test/socks5test.go new file mode 100644 index 0000000..6748f7b --- /dev/null +++ b/internal/socks5test/socks5test.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT + +// Package socks5test is a scripted SOCKS5 proxy (RFC 1928, CONNECT only) for +// tests: it records each CONNECT and forwards it to an address the test picks. +package socks5test + +import ( + "encoding/binary" + "io" + "net" + "strconv" + "sync" + "testing" +) + +// Request is one CONNECT the proxy received. +type Request struct { + AddrType byte // 1 IPv4, 3 domain name, 4 IPv6 + Host string // the IP or the name as sent + Port int +} + +// Server is a SOCKS5 proxy on loopback. +type Server struct { + // Addr is the proxy's host:port. + Addr string + // Forward returns where to connect for a requested host:port; nil + // connects to the requested address itself. + Forward func(hostPort string) string + // Reply is the CONNECT reply code; 0 succeeds. + Reply byte + // Method is the selected authentication method; 0 is "none", 0xFF + // refuses every method. + Method byte + // Silent reads the greeting and never answers. + Silent bool + // Version overrides the version octet of the method reply (default 5). + Version byte + // BoundType is the address type of the bound address in a successful + // reply: 1 (default), 3 or 4; any other value is sent as is. + BoundType byte + + ln net.Listener + wg sync.WaitGroup + mu sync.Mutex + reqs []Request +} + +// listen is net.Listen; a test replaces it to make Start fail. +var listen = net.Listen + +// Start listens on loopback and serves until the test ends. Set the +// scripting fields of the returned Server before the first connection. +func Start(t testing.TB) *Server { + t.Helper() + ln, err := listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + return nil // only reached with a testing.TB whose Fatalf returns + } + s := &Server{Addr: ln.Addr().String(), ln: ln} + s.wg.Go(s.accept) + t.Cleanup(func() { + _ = ln.Close() + s.wg.Wait() + }) + return s +} + +// URL returns "socks5://". +func (s *Server) URL() string { return "socks5://" + s.Addr } + +// Requests returns the CONNECTs received so far. +func (s *Server) Requests() []Request { + s.mu.Lock() + defer s.mu.Unlock() + return append([]Request(nil), s.reqs...) +} + +func (s *Server) accept() { + for { + c, err := s.ln.Accept() + if err != nil { + return + } + s.wg.Go(func() { s.serve(c) }) + } +} + +func (s *Server) serve(c net.Conn) { + defer func() { _ = c.Close() }() + var greet [2]byte + if _, err := io.ReadFull(c, greet[:]); err != nil { + return + } + if _, err := io.ReadFull(c, make([]byte, greet[1])); err != nil { + return + } + if s.Silent { + _, _ = io.Copy(io.Discard, c) + return + } + version := s.Version + if version == 0 { + version = 5 + } + if _, err := c.Write([]byte{version, s.Method}); err != nil || s.Method != 0 || version != 5 { + return + } + var hdr [4]byte + if _, err := io.ReadFull(c, hdr[:]); err != nil { + return + } + var host string + switch hdr[3] { + case 1, 4: + b := make([]byte, map[byte]int{1: 4, 4: 16}[hdr[3]]) + if _, err := io.ReadFull(c, b); err != nil { + return + } + host = net.IP(b).String() + case 3: + var l [1]byte + if _, err := io.ReadFull(c, l[:]); err != nil { + return + } + b := make([]byte, l[0]) + if _, err := io.ReadFull(c, b); err != nil { + return + } + host = string(b) + default: + return + } + var pb [2]byte + if _, err := io.ReadFull(c, pb[:]); err != nil { + return + } + port := int(binary.BigEndian.Uint16(pb[:])) + s.mu.Lock() + s.reqs = append(s.reqs, Request{AddrType: hdr[3], Host: host, Port: port}) + s.mu.Unlock() + + if s.Reply != 0 { + _, _ = c.Write([]byte{5, s.Reply, 0, 1, 0, 0, 0, 0, 0, 0}) + return + } + target := net.JoinHostPort(host, strconv.Itoa(port)) + if s.Forward != nil { + target = s.Forward(target) + } + up, err := net.Dial("tcp", target) + if err != nil { + _, _ = c.Write([]byte{5, 5, 0, 1, 0, 0, 0, 0, 0, 0}) + return + } + defer func() { _ = up.Close() }() + reply := []byte{5, 0, 0} + switch s.BoundType { + case 0, 1: + reply = append(reply, 1, 127, 0, 0, 1) + case 3: + reply = append(reply, 3, 9) + reply = append(reply, "localhost"...) + case 4: + reply = append(reply, 4) + reply = append(reply, net.IPv6loopback...) + default: + reply = append(reply, s.BoundType) + } + reply = append(reply, 0, 0) + // A failed write leaves c broken, so the copies below end at once. + _, _ = c.Write(reply) + done := make(chan struct{}, 2) + go func() { _, _ = io.Copy(up, c); done <- struct{}{} }() + go func() { _, _ = io.Copy(c, up); done <- struct{}{} }() + <-done +} diff --git a/internal/socks5test/socks5test_test.go b/internal/socks5test/socks5test_test.go new file mode 100644 index 0000000..b178577 --- /dev/null +++ b/internal/socks5test/socks5test_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT + +package socks5test + +import ( + "errors" + "fmt" + "io" + "net" + "testing" + "time" +) + +// client speaks the client side by hand and returns the reply octets. +func client(t *testing.T, s *Server, msgs ...[]byte) []byte { + t.Helper() + c, err := net.Dial("tcp", s.Addr) + if err != nil { + t.Fatal(err) + } + defer func() { _ = c.Close() }() + _ = c.SetDeadline(time.Now().Add(time.Second)) + for _, m := range msgs { + if _, err := c.Write(m); err != nil { + break + } + } + // Half-close, so the proxy's copy and the echo end without a timeout. + _ = c.(*net.TCPConn).CloseWrite() + out, _ := io.ReadAll(c) + return out +} + +func echoAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { _, _ = io.Copy(c, c); _ = c.Close() }() + } + }() + return ln.Addr().String() +} + +var greeting = []byte{5, 1, 0} + +func connect(atyp byte, addr ...byte) []byte { + return append(append([]byte{5, 1, 0, atyp}, addr...), 0, 102) +} + +func TestServer(t *testing.T) { + target := echoAddr(t) + ipv4 := connect(1, 127, 0, 0, 1) + for _, tc := range []struct { + name string + setup func(*Server) + msgs [][]byte + prefix []byte + req *Request + }{ + {"ipv4, bound ipv4", nil, [][]byte{greeting, ipv4, []byte("hi")}, []byte{5, 0, 5, 0, 0, 1, 127, 0, 0, 1, 0, 0}, &Request{1, "127.0.0.1", 102}}, + {"name, bound name", func(s *Server) { s.BoundType = 3 }, [][]byte{greeting, connect(3, append([]byte{9}, "localhost"...)...), []byte("hi")}, []byte{5, 0, 5, 0, 0, 3, 9}, &Request{3, "localhost", 102}}, + {"ipv6, bound ipv6", func(s *Server) { s.BoundType = 4 }, [][]byte{greeting, connect(4, net.IPv6loopback...), []byte("hi")}, []byte{5, 0, 5, 0, 0, 4}, &Request{4, "::1", 102}}, + {"bound type raw", func(s *Server) { s.BoundType = 9 }, [][]byte{greeting, ipv4}, []byte{5, 0, 5, 0, 0, 9}, nil}, + {"reply code", func(s *Server) { s.Reply = 2 }, [][]byte{greeting, ipv4}, []byte{5, 0, 5, 2}, nil}, + {"auth refused", func(s *Server) { s.Method = 0xFF }, [][]byte{greeting}, []byte{5, 0xFF}, nil}, + {"version", func(s *Server) { s.Version = 4 }, [][]byte{greeting}, []byte{4, 0}, nil}, + {"silent", func(s *Server) { s.Silent = true }, [][]byte{greeting}, nil, nil}, + {"forward fails", func(s *Server) { s.Forward = func(string) string { return "127.0.0.1:1" } }, [][]byte{greeting, ipv4}, []byte{5, 0, 5, 5}, nil}, + {"unknown address type", nil, [][]byte{greeting, {5, 1, 0, 7}}, []byte{5, 0}, nil}, + {"cut greeting", nil, [][]byte{{5}}, nil, nil}, + {"cut methods", nil, [][]byte{{5, 2, 0}}, nil, nil}, + {"cut request", nil, [][]byte{greeting, {5, 1}}, []byte{5, 0}, nil}, + {"cut ipv4", nil, [][]byte{greeting, {5, 1, 0, 1, 127}}, []byte{5, 0}, nil}, + {"cut name length", nil, [][]byte{greeting, {5, 1, 0, 3}}, []byte{5, 0}, nil}, + {"cut name", nil, [][]byte{greeting, {5, 1, 0, 3, 9, 'l'}}, []byte{5, 0}, nil}, + {"cut port", nil, [][]byte{greeting, {5, 1, 0, 1, 127, 0, 0, 1, 0}}, []byte{5, 0}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + s := Start(t) + s.Forward = func(string) string { return target } + if tc.setup != nil { + tc.setup(s) + } + got := client(t, s, tc.msgs...) + if len(got) < len(tc.prefix) || string(got[:len(tc.prefix)]) != string(tc.prefix) { + t.Errorf("reply % x, want prefix % x", got, tc.prefix) + } + if tc.req != nil { + if r := s.Requests(); len(r) != 1 || r[0] != *tc.req { + t.Errorf("requests %+v, want %+v", r, *tc.req) + } + } + }) + } + if u := Start(t); u.URL() != "socks5://"+u.Addr { + t.Errorf("URL = %q", u.URL()) + } +} + +// fatalTB records Fatalf instead of stopping the test. +type fatalTB struct { + testing.TB + fatal string +} + +func (f *fatalTB) Helper() {} + +func (f *fatalTB) Fatalf(format string, args ...any) { f.fatal = fmt.Sprintf(format, args...) } + +func TestStartListenFailure(t *testing.T) { + errListen := errors.New("no sockets") + listen = func(string, string) (net.Listener, error) { return nil, errListen } + t.Cleanup(func() { listen = net.Listen }) + + tb := &fatalTB{} + if s := Start(tb); s != nil { + t.Fatalf("Start returned %v after a listen failure, want nil", s) + } + if tb.fatal != "listen: no sockets" { + t.Errorf("Fatalf message = %q, want the listen error", tb.fatal) + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 2b8c02e..148d54d 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -37,9 +37,10 @@ func validateOptionalTimeoutMilliseconds(ms uint16) error { } // validateModbusEndpoint applies layered checks: [config.ValidateModbusAddress] (exactly one of URL or IP), -// the serial-line settings, the TLS files of a tcp+tls:// URL, then when --ip is set literal IP +// the serial-line settings, the TLS files of a tcp+tls:// URL, --proxy and --source-addr +// against the URL's transport, then when --ip is set literal IP // syntax and a non-zero port. -func validateModbusEndpoint(url, ip string, port uint16, serial config.SerialConfig, tlsCfg config.TLSConfig) error { +func validateModbusEndpoint(url, ip string, port uint16, serial config.SerialConfig, tlsCfg config.TLSConfig, dial config.DialConfig) error { if err := config.ValidateModbusAddress(url, ip); err != nil { return err } @@ -49,6 +50,9 @@ func validateModbusEndpoint(url, ip string, port uint16, serial config.SerialCon if err := config.ValidateTLSConfig(url, tlsCfg); err != nil { return err } + if err := config.ValidateDialConfig(url, dial); err != nil { + return err + } if strings.TrimSpace(ip) == "" { return nil } @@ -70,7 +74,7 @@ func CheckIdentifyConfig(cfg config.IdentifyConfig) error { // validateUnitClientConfig validates URL-or-IP (mutually exclusive), and when IP is set also Port and UnitID ("all" or 1-255). func validateUnitClientConfig(cfg config.UnitClientConfig) error { - if err := validateModbusEndpoint(cfg.URL, cfg.IP, cfg.Port, cfg.SerialConfig, cfg.TLSConfig); err != nil { + if err := validateModbusEndpoint(cfg.URL, cfg.IP, cfg.Port, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig); err != nil { return err } if cfg.UnitID == "" { @@ -328,7 +332,7 @@ func checkSelection(sel config.CaptureSelection) error { } func CheckFingerprintConfig(cfg config.FingerprintConfig) error { - if err := validateModbusEndpoint(cfg.URL, cfg.IP, cfg.Port, cfg.SerialConfig, cfg.TLSConfig); err != nil { + if err := validateModbusEndpoint(cfg.URL, cfg.IP, cfg.Port, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig); err != nil { return err } if err := validateOptionalTimeoutMilliseconds(cfg.Timeout); err != nil { @@ -359,7 +363,7 @@ func CheckDiagnosticConfirmed(cfg config.DiagnosticConfig) error { } func CheckDiagnosticConfig(cfg config.DiagnosticConfig) error { - if err := validateModbusEndpoint(cfg.URL, cfg.IP, cfg.Port, cfg.SerialConfig, cfg.TLSConfig); err != nil { + if err := validateModbusEndpoint(cfg.URL, cfg.IP, cfg.Port, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig); err != nil { return err } if err := validateOptionalTimeoutMilliseconds(cfg.Timeout); err != nil { @@ -427,6 +431,13 @@ func CheckDiscoverConfig(cfg config.DiscoverConfig) error { return fmt.Errorf("network interface must be provided if ResolveMAC is enabled") } + if err := config.ValidateDialConfig("", cfg.DialConfig); err != nil { + return err + } + if cfg.ResolveMAC && strings.TrimSpace(cfg.Proxy) != "" { + return fmt.Errorf("--resolve-mac asks the local network (ARP), which cannot see the hosts behind --proxy") + } + if !cfg.ForceLargeScan { const maxDiscoverHosts = 65536 n, err := modbus.DiscoverHostCountAtMost(cfg, maxDiscoverHosts) @@ -528,7 +539,7 @@ func isValidHostIP(s string) bool { } func validateDeviceConfig(cfg config.DeviceConfig) error { - if err := validateModbusEndpoint(cfg.URL, cfg.IP, cfg.Port, cfg.SerialConfig, cfg.TLSConfig); err != nil { + if err := validateModbusEndpoint(cfg.URL, cfg.IP, cfg.Port, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig); err != nil { return err } // uint8 cannot exceed 255; reject 0 (broadcast / invalid for directed client requests). @@ -612,7 +623,7 @@ func CheckSunSpecProbeConfig(cfg config.SunSpecProbeConfig) error { } func checkSunSpecBaseConfig(cfg *config.SunSpecBaseConfig) error { - if err := validateModbusEndpoint(cfg.URL, cfg.IP, cfg.Port, cfg.SerialConfig, cfg.TLSConfig); err != nil { + if err := validateModbusEndpoint(cfg.URL, cfg.IP, cfg.Port, cfg.SerialConfig, cfg.TLSConfig, cfg.DialConfig); err != nil { return err } if err := validateOptionalTimeoutMilliseconds(cfg.Timeout); err != nil { diff --git a/internal/validate/validate_more_test.go b/internal/validate/validate_more_test.go index fd55847..95153a5 100644 --- a/internal/validate/validate_more_test.go +++ b/internal/validate/validate_more_test.go @@ -46,6 +46,7 @@ func TestValidateModbusEndpoint(t *testing.T) { port uint16 serial config.SerialConfig tls config.TLSConfig + dial config.DialConfig want string }{ {name: "tcp", url: "tcp://192.168.1.10:502"}, @@ -89,10 +90,33 @@ func TestValidateModbusEndpoint(t *testing.T) { {name: "stop bits too high", url: "rtu://COM1", serial: config.SerialConfig{StopBits: 3}, want: "invalid stop bits 3: use 1 or 2 (0 = default)"}, {name: "serial settings are checked for network urls too", url: "tcp://10.0.0.1:502", serial: config.SerialConfig{Parity: "x"}, want: `invalid parity "x"`}, {name: "endpoint is checked before serial settings", serial: config.SerialConfig{Parity: "x"}, want: "modbus endpoint required"}, + + {name: "proxy on tcp", url: "tcp://10.0.0.1:502", dial: config.DialConfig{Proxy: "socks5://127.0.0.1:1080"}}, + {name: "proxy on rtuovertcp", url: "rtuovertcp://10.0.0.1:502", dial: config.DialConfig{Proxy: "socks5://127.0.0.1:1080"}}, + {name: "proxy on asciiovertcp", url: "asciiovertcp://10.0.0.1:502", dial: config.DialConfig{Proxy: "socks5://127.0.0.1:1080"}}, + {name: "socks5h proxy with a host name", url: "tcp://plc.local:502", dial: config.DialConfig{Proxy: "socks5h://127.0.0.1:1080"}}, + {name: "proxy with --ip", ip: "10.0.0.1", port: 502, dial: config.DialConfig{Proxy: "socks5://127.0.0.1:1080"}}, + {name: "proxy on udp", url: "udp://10.0.0.1:502", dial: config.DialConfig{Proxy: "socks5://127.0.0.1:1080"}, want: "--proxy does not apply to udp://: SOCKS5 carries TCP only"}, + {name: "proxy on rtuoverudp", url: "rtuoverudp://10.0.0.1:502", dial: config.DialConfig{Proxy: "socks5://127.0.0.1:1080"}, want: "--proxy does not apply to rtuoverudp://: SOCKS5 carries TCP only"}, + {name: "source address on tcp", url: "tcp://10.0.0.1:502", dial: config.DialConfig{SourceAddr: "127.0.0.1"}}, + {name: "source address on udp", url: "udp://10.0.0.1:502", dial: config.DialConfig{SourceAddr: "127.0.0.1"}}, + {name: "source address on rtuovertcp", url: "rtuovertcp://10.0.0.1:502", dial: config.DialConfig{SourceAddr: "127.0.0.1"}}, + {name: "source address on rtuoverudp", url: "rtuoverudp://10.0.0.1:502", dial: config.DialConfig{SourceAddr: "127.0.0.1"}}, + {name: "source address on asciiovertcp", url: "asciiovertcp://10.0.0.1:502", dial: config.DialConfig{SourceAddr: "127.0.0.1"}}, + {name: "proxy and source address", url: "tcp://10.0.0.1:502", dial: config.DialConfig{Proxy: "socks5://127.0.0.1:1080", SourceAddr: "127.0.0.1"}}, + {name: "proxy on rtu", url: "rtu:///dev/ttyUSB0", dial: config.DialConfig{Proxy: "socks5://127.0.0.1:1080"}, want: "--proxy and --source-addr do not apply to rtu:// (a serial line)"}, + {name: "source address on rtu", url: "rtu:///dev/ttyUSB0", dial: config.DialConfig{SourceAddr: "127.0.0.1"}, want: "do not apply to rtu://"}, + {name: "proxy on ascii", url: "ascii://COM3", dial: config.DialConfig{Proxy: "socks5://127.0.0.1:1080"}, want: "--proxy and --source-addr do not apply to ascii:// (a serial line)"}, + {name: "source address on ascii", url: "ascii://COM3", dial: config.DialConfig{SourceAddr: "127.0.0.1"}, want: "do not apply to ascii://"}, + {name: "invalid proxy scheme", url: "tcp://10.0.0.1:502", dial: config.DialConfig{Proxy: "http://127.0.0.1:8080"}, want: "the scheme must be socks5 or socks5h"}, + {name: "proxy credentials", url: "tcp://10.0.0.1:502", dial: config.DialConfig{Proxy: "socks5://u:p@127.0.0.1:1080"}, want: "proxy credentials are not supported"}, + {name: "proxy without port", url: "tcp://10.0.0.1:502", dial: config.DialConfig{Proxy: "socks5://127.0.0.1"}, want: "want socks5://host:port"}, + {name: "invalid source address", url: "udp://10.0.0.1:502", dial: config.DialConfig{SourceAddr: "eth0"}, want: `invalid --source-addr "eth0": want an IP address`}, + {name: "blank dial settings are unset", url: "rtu:///dev/ttyUSB0", dial: config.DialConfig{Proxy: " ", SourceAddr: " "}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - check(t, validateModbusEndpoint(tt.url, tt.ip, tt.port, tt.serial, tt.tls), tt.want) + check(t, validateModbusEndpoint(tt.url, tt.ip, tt.port, tt.serial, tt.tls, tt.dial), tt.want) }) } }