diff --git a/README.md b/README.md index 723bd26..25d8c2d 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ sunspecctl | `--tls-cert` | | Certificate (PEM) this side presents, for `tcp+tls://` URLs | | `--tls-key` | | Private key (PEM) of `--tls-cert` | | `--tls-ca` | | CA certificates (PEM) that the other side's certificate must chain to | +| `--proxy` | | Connect through a SOCKS5 proxy, `socks5://host:port` or `socks5h://host:port`, for TCP URLs; see [Reaching the device: proxy and source address](#reaching-the-device-proxy-and-source-address) | +| `--source-addr` | | Local IP address to connect from, for network URLs | For `serve`, `--url` is the address to listen on and `--unit-id` overrides the unit ID of the device file; see [Serving / simulating a device](#serving--simulating-a-device). @@ -123,6 +125,24 @@ For `serve`, `--url` is the address to listen on and `--unit-id` overrides the u Many SunSpec devices on RS-485 use 9600 baud, 8 data bits, no parity and 1 stop bit, which is not the default: pass `--baud 9600 --stop-bits 1` (and the device's Modbus address as `--unit-id`). +## Reaching the device: proxy and source address + +Every command that connects to a device (`detect`, `models`, `read`, `read-model`, `read-point`, `poll`, `poll-model`, `poll-point` and `capture`) accepts two flags for how the network connection is opened: + +- `--proxy socks5://host:port` connects through a SOCKS5 proxy (RFC 1928 CONNECT, no authentication), for example an SSH jump host into the plant network (`ssh -D 1080 jumphost`). `socks5h://` sends the host name of `--url` to the proxy to resolve; `socks5://` resolves it locally. Proxy credentials are not supported. It applies to `tcp://`, `tcp+tls://`, `rtuovertcp://` and `asciiovertcp://`; with `udp://` or `rtuoverudp://` it is a usage error, as SOCKS5 UDP ASSOCIATE is not implemented. Over `tcp+tls://` the TLS handshake runs through the proxy and the device certificate is still verified against the host of `--url`. +- `--source-addr ` connects from that local address, on a host with several networks (an OT and an office interface, say). It applies to the TCP and the UDP URLs; with `--proxy`, it is the source of the connection to the proxy. + +`SUNSPECCTL_PROXY` and `SUNSPECCTL_SOURCE_ADDR` set them when the flag is not given. Neither applies to `rtu://` or `ascii://` (a usage error), nor to `serve`, which listens (the flags are a usage error there, the environment variables are ignored). + +```bash +ssh -N -D 1080 engineer@jumphost & +sunspecctl detect --url tcp://10.20.0.15:502 --proxy socks5://127.0.0.1:1080 +sunspecctl read --url tcp://inverter-3.plant.local:502 --proxy socks5h://127.0.0.1:1080 +sunspecctl models --url tcp://192.168.10.20:502 --source-addr 192.168.10.2 +``` + +An invalid value is a usage error (exit 2), before anything is sent. A proxy that refuses the connection is a connection failure (exit 4); the error names the proxy and its reply, for example `socks5: connection not allowed by ruleset`. + ## Commands ```bash @@ -382,7 +402,7 @@ Tests need no hardware: they run against the SunSpec fixture server of go-sunspe ## Requirements - Go 1.26+ to build from source (not needed with the installer) -- [otfabric/go-sunspec](https://github.com/otfabric/go-sunspec) v0.10.0+ and [otfabric/go-modbus](https://github.com/otfabric/go-modbus) v1.13.1+ +- [otfabric/go-sunspec](https://github.com/otfabric/go-sunspec) v0.10.0+ and [otfabric/go-modbus](https://github.com/otfabric/go-modbus) v1.14.2+ ## Contributing diff --git a/RELEASE.md b/RELEASE.md index 4720acd..0b477fa 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,32 @@ # sunspecctl Releases +## v0.7.0 + +**Date:** 2026-11-09 +**Previous release:** v0.6.1 + +### 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` opens the connections of every network transport. + +### Added + +- `--proxy socks5://host:port` (`SUNSPECCTL_PROXY`): connect through a SOCKS5 proxy (RFC 1928 CONNECT, no authentication); `socks5h://` sends host names to the proxy. For `tcp://`, `tcp+tls://` (the TLS handshake runs through the proxy and is verified against the host of `--url`), `rtuovertcp://` and `asciiovertcp://`. With `udp://` or `rtuoverudp://` it is a usage error (exit 2): SOCKS5 UDP ASSOCIATE is not implemented. Proxy credentials, other schemes, a missing port or a path are usage errors. A refusal by the proxy is a connection failure (exit 4) naming the proxy and its reply. +- `--source-addr ` (`SUNSPECCTL_SOURCE_ADDR`): connect from that local IP address, over TCP and UDP; with `--proxy`, the connection to the proxy. +- Both on `detect`, `models`, `read`, `read-model`, `read-point`, `poll`, `poll-model`, `poll-point` and `capture`. Either flag with `rtu://` or `ascii://`, or on `serve`, is a usage error; `serve` ignores the environment variables. + +### Changed + +- Built on go-modbus v1.14.2 (was v1.13.1): `Config.Dialer`; a failed connect no longer repeats `dial tcp :` in its message (exit codes unchanged). `version` lists `go-modbus 1.14.2`. +- `make build` marks the commit `-dirty` when the working tree has changes, as `go build` does; release builds are unchanged. + +### Tests + +- `internal/netdial`: flag validation, source address binding over TCP and UDP, 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 are at 100 % coverage. +- Every client command through the test proxy to a fake SunSpec device, `socks5h` name forwarding, `tcp+tls` through the proxy (and a URL host the certificate does not hold failing the handshake), a refusing and a silent proxy, `--source-addr` on TCP, UDP and RTU over UDP, the environment variables, and every invalid value and transport mismatch as a usage error on every client command, with nothing sent. + +--- + ## v0.6.1 **Date:** 2026-11-08 diff --git a/SECURITY.md b/SECURITY.md index bccc4ec..86203f3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,6 +31,7 @@ SunSpec devices are read, and with `sunspecctl serve` simulated, over Modbus, wh - **Use `sunspecctl` only on devices and networks you own or are authorized to access** - **Keep Modbus traffic on isolated, trusted networks**; never expose port 502 of an inverter, meter or battery to the Internet - Across untrusted networks, use Modbus over TLS (`tcp+tls://`, supported by go-modbus) where the device supports it, or a VPN or secured gateway +- **Proxies**: `--proxy` (or `SUNSPECCTL_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 is not authenticated (SOCKS5 without credentials): use only a proxy you control, such as an SSH jump host (`ssh -D`) listening on loopback. Over `tcp+tls://` the device certificate is verified against the host of `--url`, so a proxy cannot pose as the device. A `SUNSPECCTL_PROXY` left set in the environment applies to every client command; `version` does not show it ### Data Read From Devices Is Untrusted Input diff --git a/dial.go b/dial.go new file mode 100644 index 0000000..58639fc --- /dev/null +++ b/dial.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/otfabric/go-modbus" + "github.com/otfabric/sunspecctl/internal/netdial" + "github.com/spf13/pflag" +) + +// How the client commands open their network connection: --proxy and +// --source-addr, or SUNSPECCTL_PROXY and SUNSPECCTL_SOURCE_ADDR. +var ( + flagProxy string + flagSourceAddr string +) + +// applyDialEnv sets --proxy and --source-addr, when not given, from +// SUNSPECCTL_PROXY and SUNSPECCTL_SOURCE_ADDR. +func applyDialEnv(flags *pflag.FlagSet) { + if !flags.Changed("proxy") { + flagProxy = os.Getenv("SUNSPECCTL_PROXY") + } + if !flags.Changed("source-addr") { + flagSourceAddr = os.Getenv("SUNSPECCTL_SOURCE_ADDR") + } +} + +// dialer returns the go-modbus dialer of --proxy and --source-addr for url, +// or nil when neither is set (go-modbus dials itself). An invalid value, or +// one that does not apply to the transport of url, is a usage error: nothing +// has been sent. +// +// --proxy carries TCP only (SOCKS5 CONNECT): tcp, tcp+tls, rtuovertcp and +// asciiovertcp. --source-addr binds TCP and UDP. Neither applies to a serial +// line. A tcp+tls handshake runs on the proxied connection and verifies the +// device certificate against the host of url, as without a proxy. +func dialer(url string) (modbus.Dialer, error) { + d, err := netdial.New(flagSourceAddr, flagProxy) + if err != nil { + return nil, withExit(exitUsage, err) + } + if d == nil { + return nil, nil + } + flag := "--source-addr" + if flagProxy != "" { + flag = "--proxy" + } + // The scheme as go-modbus reads it: what precedes "://", as written. + scheme, _, _ := strings.Cut(url, "://") + switch scheme { + case "rtu", "ascii": + return nil, withExit(exitUsage, fmt.Errorf("%s does not apply to %s:// URLs: a serial line has no network connection", flag, scheme)) + case "udp", "rtuoverudp": + if flagProxy != "" { + return nil, withExit(exitUsage, fmt.Errorf("--proxy does not apply to %s:// URLs: the SOCKS5 proxy carries TCP only (UDP ASSOCIATE is not supported)", scheme)) + } + } + return d, nil +} diff --git a/dial_test.go b/dial_test.go new file mode 100644 index 0000000..f2241aa --- /dev/null +++ b/dial_test.go @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "net" + "strconv" + "strings" + "testing" + "time" + + "github.com/otfabric/sunspecctl/internal/socks5test" +) + +// hostPort returns the host:port of a tcp:// URL. +func hostPort(url string) string { return strings.TrimPrefix(url, "tcp://") } + +// wantProxied asserts that every CONNECT the proxy saw went to target. +func wantProxied(t *testing.T, px *socks5test.Server, target string) { + t.Helper() + reqs := px.Requests() + if len(reqs) == 0 { + t.Fatal("the proxy saw no CONNECT") + } + for _, r := range reqs { + if got := net.JoinHostPort(r.Host, strconv.Itoa(r.Port)); got != target { + t.Errorf("CONNECT %s, want %s", got, target) + } + } +} + +// Every command that connects to a device goes through --proxy: the proxy +// sees a CONNECT to the device and the command succeeds. +func TestProxyEveryClientCommand(t *testing.T) { + url := startDevice(t, inverter(t)) + commands := append(append([][]string{}, deviceCommands...), []string{"capture"}) + for _, args := range commands { + t.Run(args[0], func(t *testing.T) { + px := socks5test.Start(t) + r := runCLI(t, append(args, "--url", url, "--proxy", px.URL(), "--timeout", "5s")...) + if r.err != nil { + t.Fatalf("%v\nstderr: %s", r.err, r.stderr) + } + wantProxied(t, px, hostPort(url)) + }) + } +} + +// socks5h sends the host name of --url to the proxy, which resolves it. +func TestProxyRemoteName(t *testing.T) { + url := startDevice(t, inverter(t)) + px := socks5test.Start(t) + px.Forward = func(string) string { return hostPort(url) } + _, port, _ := net.SplitHostPort(hostPort(url)) + r := runCLI(t, "detect", "--url", "tcp://plant-inverter.invalid:"+port, "--proxy", "socks5h://"+px.Addr) + if r.err != nil { + t.Fatalf("%v\nstderr: %s", r.err, r.stderr) + } + if reqs := px.Requests(); len(reqs) != 1 || reqs[0].AddrType != 3 || reqs[0].Host != "plant-inverter.invalid" { + t.Errorf("proxy saw %+v, want the host name", reqs) + } +} + +// Over tcp+tls the TLS handshake runs on the proxied connection, and the +// device certificate is verified against the host of --url, not the proxy. +func TestProxyTLS(t *testing.T) { + ca := newTestCA(t, "plant CA") + serverCert, serverKey := ca.issue("device") + clientCert, clientKey := ca.issue("client") + url := "tcp+tls://127.0.0.1:" + strconv.Itoa(freePort(t)) + s := startServe(t, writeFile(t, exampleDevice), + "--url", url, "--tls-cert", serverCert, "--tls-key", serverKey, "--tls-ca", ca.file) + tlsArgs := []string{"--unit-id", "3", "--timeout", "5s", "--tls-cert", clientCert, "--tls-key", clientKey, "--tls-ca", ca.file} + + px := socks5test.Start(t) + r := runCLI(t, append([]string{"read-point", "--model", "1", "--point", "Mn", "--url", url, "--proxy", px.URL()}, tlsArgs...)...) + if r.err != nil { + t.Fatalf("read-point over TLS through the proxy: %v\nstderr: %s", r.err, r.stderr) + } + if !strings.Contains(r.stdout, "Acme") { + t.Errorf("stdout:\n%s", r.stdout) + } + wantProxied(t, px, strings.TrimPrefix(url, "tcp+tls://")) + + // The certificate is for 127.0.0.1: a URL naming another host fails the + // handshake although the proxy forwards it to the same device. + other := socks5test.Start(t) + other.Forward = func(string) string { return strings.TrimPrefix(url, "tcp+tls://") } + _, port, _ := net.SplitHostPort(strings.TrimPrefix(url, "tcp+tls://")) + r = runCLI(t, append([]string{"detect", "--url", "tcp+tls://device.invalid:" + port, "--proxy", "socks5h://" + other.Addr}, tlsArgs...)...) + if r.err == nil || !strings.Contains(r.err.Error(), "certificate") { + t.Errorf("TLS to a name the certificate does not hold: err = %v", r.err) + } + if err := s.stop(t); err != nil { + t.Errorf("serve: %v", err) + } +} + +// A proxy that refuses is a connection failure (exit 4), naming the proxy +// and its reply; a proxy that does not answer is a timeout (exit 5). +func TestProxyRefusedIsATransportError(t *testing.T) { + url := startDevice(t, inverter(t)) + px := socks5test.Start(t) + px.Reply = 2 + r := runCLI(t, "detect", "--url", url, "--proxy", px.URL()) + if r.err == nil || !strings.Contains(r.err.Error(), "connection not allowed by ruleset") || + !strings.Contains(r.err.Error(), "socks5 proxy "+px.Addr) { + t.Fatalf("err = %v", r.err) + } + if got := exitCode(r.err); got != exitTransport { + t.Errorf("exit code %d, want %d", got, exitTransport) + } + wantNoCommandOutput(t, r) + + silent := socks5test.Start(t) + silent.Silent = true + r = runCLI(t, "detect", "--url", url, "--proxy", silent.URL(), "--timeout", "1s") + if got := exitCode(r.err); got != exitTimeout { + t.Errorf("silent proxy: exit code %d (err %v), want %d", got, r.err, exitTimeout) + } +} + +// --source-addr binds the local end of a TCP connection, and with --proxy +// the connection to the proxy. +func TestSourceAddrTCP(t *testing.T) { + url := startDevice(t, inverter(t)) + if r := runCLI(t, "detect", "--url", url, "--source-addr", "127.0.0.1"); r.err != nil { + t.Fatalf("%v\nstderr: %s", r.err, r.stderr) + } + px := socks5test.Start(t) + if r := runCLI(t, "detect", "--url", url, "--source-addr", "127.0.0.1", "--proxy", px.URL()); r.err != nil { + t.Fatalf("with --proxy: %v\nstderr: %s", r.err, r.stderr) + } + wantProxied(t, px, hostPort(url)) +} + +// --source-addr binds the local end of the udp and rtuoverudp transports: a +// silent UDP peer sees the requests come from that address. +func TestSourceAddrUDP(t *testing.T) { + for _, scheme := range []string{"udp", "rtuoverudp"} { + t.Run(scheme, func(t *testing.T) { + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = pc.Close() }() + from := make(chan net.Addr, 1) + go func() { + buf := make([]byte, 512) + if _, a, err := pc.ReadFrom(buf); err == nil { + from <- a + } + }() + r := runCLI(t, "detect", "--url", scheme+"://"+pc.LocalAddr().String(), "--source-addr", "127.0.0.1", "--timeout", "300ms") + if got := exitCode(r.err); got == exitUsage || got == exitOK { + t.Fatalf("exit code %d (err %v), want a device error", got, r.err) + } + select { + case a := <-from: + if ip := a.(*net.UDPAddr).IP; !ip.Equal(net.IPv4(127, 0, 0, 1)) { + t.Errorf("request from %v, want 127.0.0.1", a) + } + case <-time.After(2 * time.Second): + t.Fatal("no request arrived") + } + }) + } +} + +// Invalid values, and flags that do not apply to the transport, are usage +// errors (exit 2) before anything is sent: the proxy sees no connection. +func TestDialFlagsUsageErrors(t *testing.T) { + px := socks5test.Start(t) + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"--proxy", "http://127.0.0.1:8080"}, "the scheme must be socks5 or socks5h"}, + {[]string{"--proxy", "socks5://user:pw@127.0.0.1:1080"}, "proxy credentials are not supported"}, + {[]string{"--proxy", "socks5://127.0.0.1"}, "want socks5://host:port"}, + {[]string{"--source-addr", "eth0"}, `invalid --source-addr "eth0": want an IP address`}, + {[]string{"--url", "udp://127.0.0.1:502", "--proxy", px.URL()}, "--proxy does not apply to udp:// URLs"}, + {[]string{"--url", "rtuoverudp://127.0.0.1:502", "--proxy", px.URL()}, "--proxy does not apply to rtuoverudp:// URLs"}, + {[]string{"--url", "udp://127.0.0.1:502", "--proxy", px.URL(), "--source-addr", "127.0.0.1"}, "--proxy does not apply to udp:// URLs"}, + {[]string{"--url", "rtu:///dev/ttyUSB0", "--proxy", px.URL()}, "--proxy does not apply to rtu:// URLs"}, + {[]string{"--url", "ascii:///dev/ttyUSB0", "--proxy", px.URL()}, "--proxy does not apply to ascii:// URLs"}, + {[]string{"--url", "rtu:///dev/ttyUSB0", "--source-addr", "127.0.0.1"}, "--source-addr does not apply to rtu:// URLs"}, + {[]string{"--url", "ascii:///dev/ttyUSB0", "--source-addr", "127.0.0.1"}, "--source-addr does not apply to ascii:// URLs"}, + } { + for _, cmd := range append(append([][]string{}, deviceCommands...), []string{"capture"}) { + t.Run(cmd[0]+" "+strings.Join(tc.args, " "), func(t *testing.T) { + r := runCLI(t, append(append([]string{}, cmd...), tc.args...)...) + if r.err == nil || !strings.Contains(r.err.Error(), tc.want) { + t.Fatalf("err = %v, want it to mention %q", r.err, tc.want) + } + if got := exitCode(r.err); got != exitUsage { + t.Errorf("exit code %d, want %d", got, exitUsage) + } + wantNoCommandOutput(t, r) + }) + } + } + if n := len(px.Requests()); n != 0 { + t.Errorf("the proxy saw %d CONNECTs", n) + } +} + +// serve listens: --proxy and --source-addr are refused there, while the +// environment variables are ignored. +func TestDialFlagsServe(t *testing.T) { + path := writeFile(t, exampleDevice) + for _, f := range []string{"--proxy", "--source-addr"} { + r := runCLI(t, "serve", "--file", path, "--check", f, "127.0.0.1") + if r.err == nil || r.err.Error() != f+" does not apply to serve, which listens on --url" || exitCode(r.err) != exitUsage { + t.Errorf("serve %s: err = %v", f, r.err) + } + } + t.Setenv("SUNSPECCTL_PROXY", "socks5://127.0.0.1:1080") + t.Setenv("SUNSPECCTL_SOURCE_ADDR", "127.0.0.1") + if r := runCLI(t, "serve", "--file", path, "--check"); r.err != nil { + t.Errorf("serve --check with the environment set: %v", r.err) + } +} + +// SUNSPECCTL_PROXY and SUNSPECCTL_SOURCE_ADDR set the flags when they are +// not given; a flag given wins. +func TestDialEnvironment(t *testing.T) { + url := startDevice(t, inverter(t)) + px := socks5test.Start(t) + t.Setenv("SUNSPECCTL_PROXY", px.URL()) + t.Setenv("SUNSPECCTL_SOURCE_ADDR", "127.0.0.1") + if r := runCLI(t, "detect", "--url", url); r.err != nil { + t.Fatalf("%v\nstderr: %s", r.err, r.stderr) + } + wantProxied(t, px, hostPort(url)) + if flagSourceAddr != "127.0.0.1" { + t.Errorf("--source-addr = %q, want it from SUNSPECCTL_SOURCE_ADDR", flagSourceAddr) + } + + refusing := socks5test.Start(t) + refusing.Reply = 5 + t.Setenv("SUNSPECCTL_PROXY", refusing.URL()) + if r := runCLI(t, "detect", "--url", url, "--proxy", px.URL()); r.err != nil { + t.Fatalf("--proxy over SUNSPECCTL_PROXY: %v", r.err) + } + if n := len(refusing.Requests()); n != 0 { + t.Errorf("the proxy of SUNSPECCTL_PROXY saw %d CONNECTs", n) + } + + t.Setenv("SUNSPECCTL_PROXY", "ftp://127.0.0.1:21") + r := runCLI(t, "detect", "--url", url) + if r.err == nil || exitCode(r.err) != exitUsage { + t.Errorf("invalid SUNSPECCTL_PROXY: err = %v", r.err) + } +} diff --git a/docs/exitcodes.md b/docs/exitcodes.md index 0d19297..c08f1ea 100644 --- a/docs/exitcodes.md +++ b/docs/exitcodes.md @@ -6,9 +6,9 @@ sunspecctl's exit codes are a script-facing contract (`exit.go`). They follow th |------|------|---------| | 0 | ok | Success. `poll` and `serve` also exit 0 after SIGINT or SIGTERM | | 1 | findings | The device answered and is not a SunSpec device: no probed base address holds the "SunS" marker. This includes `detect`, which then prints no result | -| 2 | usage | Unknown command or flag, missing argument, invalid serial setting, an invalid `SUNSPECCTL_FORMAT`, a `--timeout` or `--interval` that is not positive, a negative `--count`, `--unit-id 0` for a client command | +| 2 | usage | Unknown command or flag, missing argument, invalid serial setting, an invalid `SUNSPECCTL_FORMAT`, a `--timeout` or `--interval` that is not positive, a negative `--count`, `--unit-id 0` for a client command, an invalid `--proxy` or `--source-addr` (or `SUNSPECCTL_PROXY`, `SUNSPECCTL_SOURCE_ADDR`), `--proxy` with a UDP or serial URL, `--source-addr` with a serial URL, either flag on `serve` | | 3 | invalid_input | An invalid URL, device file, TLS file (also one over its size limit: 16 MiB for a device file, 4 MiB for a PEM file), unknown point name, or a model without a schema for `template` | -| 4 | transport | Connecting failed or the connection was lost; `serve` cannot listen | +| 4 | transport | Connecting failed or the connection was lost, also a `--proxy` that refuses the connection; `serve` cannot listen | | 5 | timeout | `--timeout` passed | | 6 | protocol | A Modbus exception, a malformed frame, registers that do not decode as SunSpec | | 8 | unsupported | The device does not have the requested model | diff --git a/go.mod b/go.mod index f3b18ec..5374000 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/otfabric/sunspecctl go 1.26.0 require ( - github.com/otfabric/go-modbus v1.13.1 + github.com/otfabric/go-modbus v1.14.2 github.com/otfabric/go-sunspec v0.10.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 304af6c..dca0f0d 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -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-serial v0.2.1 h1:PCOdnCDAHIHFaxTBNmisC/4SlD+NVd74d+EJPnXrCOw= github.com/otfabric/go-serial v0.2.1/go.mod h1:TqbYpWWg6W+IuCLs8YM2cndlMAEaTyaKyUX6Z08gtq8= github.com/otfabric/go-sunspec v0.10.0 h1:4vHV94iaj4ukUbSiEKf/aDUvVYmY9MTb5+dos4UdtkQ= diff --git a/internal/netdial/netdial.go b/internal/netdial/netdial.go new file mode 100644 index 0000000..247f280 --- /dev/null +++ b/internal/netdial/netdial.go @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: MIT + +// Package netdial builds the dialer of the --source-addr and --proxy flags +// (go-modbus Config.Dialer): a TCP or UDP dial bound to a local address, +// optionally a TCP connection through a SOCKS5 proxy (RFC 1928 CONNECT, no +// authentication). +package netdial + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/url" + "strconv" + "time" +) + +// Dialer has the signature of go-modbus's Config.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 direct + 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 +} + +// direct dials without a proxy, from ip when it is set. +type direct struct{ ip net.IP } + +func (d direct) dial(ctx context.Context, network, addr string) (net.Conn, error) { + var nd net.Dialer + if d.ip != nil { + switch network { + case "tcp", "tcp4", "tcp6": + nd.LocalAddr = &net.TCPAddr{IP: d.ip} + case "udp", "udp4", "udp6": + nd.LocalAddr = &net.UDPAddr{IP: d.ip} + default: + return nil, fmt.Errorf("--source-addr: network %q not supported", network) + } + } + return nd.DialContext(ctx, network, addr) +} + +type socks5 struct { + addr string // proxy host:port + remoteName bool // socks5h: the proxy resolves host names + base direct +} + +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..814979b --- /dev/null +++ b/internal/netdial/netdial_test.go @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: MIT + +package netdial + +import ( + "context" + "errors" + "io" + "net" + "strconv" + "strings" + "testing" + "time" + + "github.com/otfabric/sunspecctl/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]:502") + 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 != 502 { + t.Errorf("proxy saw %+v, want IPv6 2001:db8::7:502", 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:502") + 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:502"); !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:502"); 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) + ":502" + 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:502"); 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:502"); 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") + } + }) + } +} + +// --source-addr binds UDP too: udp and rtuoverudp dial "udp", and each Read +// returns one datagram of the connected socket. +func TestSourceAddrUDP(t *testing.T) { + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = pc.Close() }() + go func() { + buf := make([]byte, 64) + for { + n, from, err := pc.ReadFrom(buf) + if err != nil { + return + } + _, _ = pc.WriteTo(buf[:n], from) + } + }() + 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) + } + if ip := c.LocalAddr().(*net.UDPAddr).IP; !ip.Equal(net.IPv4(127, 0, 0, 1)) { + t.Errorf("local address %v, want 127.0.0.1", ip) + } + roundTrip(t, c) +} + +func TestSourceAddrUnknownNetwork(t *testing.T) { + d, _ := New("127.0.0.1", "") + if _, err := d(ctx2s(t), "unix", "/tmp/x"); err == nil || !strings.Contains(err.Error(), "not supported") { + t.Errorf("unix: %v", err) + } +} 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/main.go b/main.go index bfdca1b..4c64a23 100644 --- a/main.go +++ b/main.go @@ -73,6 +73,7 @@ func newRootCmd() *cobra.Command { CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { commandEntered = true + applyDialEnv(cmd.Flags()) return applyFormatEnv(cmd.Flags().Changed("json")) }, } @@ -90,6 +91,8 @@ func newRootCmd() *cobra.Command { root.PersistentFlags().StringVar(&flagTLSCert, "tls-cert", "", "Certificate (PEM) this side presents, for tcp+tls:// URLs") root.PersistentFlags().StringVar(&flagTLSKey, "tls-key", "", "Private key (PEM) of --tls-cert") root.PersistentFlags().StringVar(&flagTLSCA, "tls-ca", "", "CA certificates (PEM) the other side's certificate must chain to") + root.PersistentFlags().StringVar(&flagProxy, "proxy", "", "Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy); TCP URLs only") + root.PersistentFlags().StringVar(&flagSourceAddr, "source-addr", "", "Local IP address to connect from (multi-homed hosts); network URLs only") root.AddCommand(detectCmd(), modelsCmd(), readCmd(), readModelCmd(), readPointCmd(), pollCmd(), pollModelCmd(), pollPointCmd(), serveCmd(), captureCmd(), templateCmd(), completionCmd(root), versionCmd()) @@ -137,8 +140,8 @@ PowerShell. Installation: see "Shell completion" in the README.`, // The client may send read requests (FC03, FC04) only. // The serial-line settings only take effect for rtu:// and ascii:// URLs; // go-modbus fills in the defaults for the ones left at zero. -func clientConfig() (modbus.Config, error) { - conf := modbus.Config{ +func clientConfig() (conf modbus.Config, err error) { + conf = modbus.Config{ URL: flagURL, Timeout: flagTimeout, DialTimeout: 5 * time.Second, @@ -174,6 +177,9 @@ func clientConfig() (modbus.Config, error) { // another unit than the one asked for. return conf, withExit(exitUsage, errors.New("invalid --unit-id 0: that is the Modbus broadcast address, which a device does not answer; use 1-255")) } + if conf.Dialer, err = dialer(flagURL); err != nil { + return conf, err + } cert, pool, err := loadTLS(flagURL) if err != nil { return conf, err diff --git a/main_test.go b/main_test.go index c5449b7..d85eed4 100644 --- a/main_test.go +++ b/main_test.go @@ -1145,11 +1145,13 @@ func TestHelp(t *testing.T) { " template Write a device file skeleton for the given models\n", " completion Generate shell completion script\n", " version Print the sunspecctl version\n", - " --json Output as JSON\n", - " --raw Include raw register hex in output\n", - " --timeout duration Operation timeout (default 10s)\n", - " --unit-id uint8 Modbus unit ID (default 1)\n", - " --url string Modbus device URL (default \"tcp://localhost:502\")\n", + " --json Output as JSON\n", + " --raw Include raw register hex in output\n", + " --timeout duration Operation timeout (default 10s)\n", + " --unit-id uint8 Modbus unit ID (default 1)\n", + " --url string Modbus device URL (default \"tcp://localhost:502\")\n", + " --proxy string Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy); TCP URLs only\n", + " --source-addr string Local IP address to connect from (multi-homed hosts); network URLs only\n", } { if !strings.Contains(r.stdout, want) { t.Errorf("root help lacks %q:\n%s", want, r.stdout) diff --git a/serve.go b/serve.go index 1d473ae..0e32202 100644 --- a/serve.go +++ b/serve.go @@ -178,6 +178,13 @@ SunSpec defines as writable. Accepted writes are printed. The device runs until it is interrupted (Ctrl-C) or terminated.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + // serve listens: there is no connection to proxy or bind. The + // environment variables are ignored here, the flags refused. + for _, f := range []string{"proxy", "source-addr"} { + if cmd.Flags().Changed(f) { + return withExit(exitUsage, fmt.Errorf("--%s does not apply to serve, which listens on --url", f)) + } + } df, err := loadDeviceFile(file) if err != nil { return withExit(exitInvalidInput, fmt.Errorf("device file: %w", err))