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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,33 @@ 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).

`--url` selects the transport: `tcp://host:port`, `rtu://<device>` (for example `rtu:///dev/ttyUSB0` or `rtu://COM3`), `ascii://<device>`, `udp://host:port`, `rtuovertcp://host:port` for a serial-to-Ethernet gateway, and the other [go-modbus URL schemes](https://github.com/otfabric/go-modbus#transport-modes). The serial flags are ignored for network URLs.

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 <ip>` 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
Expand Down Expand Up @@ -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

Expand Down
27 changes: 27 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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 <ip>` (`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 <addr>:` 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
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 65 additions & 0 deletions dial.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading