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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
coverage-min: "90"
fuzz-time: 10s
codecov-name: sunspecctl
go-versions: '["1.23", "1.24", "1.25", "1.26"]'
go-versions: '["1.26", "1.27"]'
test-exclude-regex: "/example(s)?($|/)"
secrets: inherit

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Thank you for your interest in contributing to `sunspecctl`! This document cover

### Prerequisites

- Go 1.23+ (see [go.mod](go.mod)); the code must build and pass on Go 1.23, so do not use newer language or standard-library features
- Go 1.26+ (see [go.mod](go.mod)); the code must build and pass on Go 1.26, so do not use newer language or standard-library features
- [staticcheck](https://staticcheck.dev/), [golangci-lint](https://golangci-lint.run/) and [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck) for the checks
- No hardware: tests use the SunSpec fixture server of go-sunspec's [`testutil`](https://pkg.go.dev/github.com/otfabric/go-sunspec/testutil) package, and `serve` itself running in-process

Expand Down Expand Up @@ -83,7 +83,7 @@ Examples:
- Use go-sunspec's `testutil` package for anything that talks to a device: it serves a SunSpec model chain over Modbus TCP. For `serve`, start the command in-process on a free port (`startServe` in `serve_test.go`) and read it back with the other commands
- A change to `capture`, `serve` or `template`, or a go-sunspec update, must keep `TestCaptureServeReproducesTheDevice` meaningful: capture → serve → read gives the same decoded values
- Assert behaviour: the printed output, the JSON document, the returned error — not just that the code ran
- Keep tests fast, deterministic and race-clean; CI runs them with `-race` on every Go version from 1.23 and a 120 s per-package timeout
- Keep tests fast, deterministic and race-clean; CI runs them with `-race` on Go 1.26 and 1.27 and a 120 s per-package timeout
- Coverage is enforced by `make check` and Codecov; new code should come with tests
- If your change affects what is read from a device, verify against real hardware when possible and say which device in the pull request

Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# sunspecctl — SunSpec Modbus Command-Line Tool for Solar Inverters, Meters and Batteries

[![Go](https://img.shields.io/badge/Go-1.23%2B-00ADD8?style=flat&logo=go)](https://go.dev/)
[![Go](https://img.shields.io/badge/Go-1.26%2B-00ADD8?style=flat&logo=go)](https://go.dev/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![CI](https://github.com/otfabric/sunspecctl/actions/workflows/ci.yml/badge.svg)](https://github.com/otfabric/sunspecctl/actions/workflows/ci.yml)
[![Code Coverage](https://codecov.io/gh/otfabric/sunspecctl/graph/badge.svg)](https://codecov.io/gh/otfabric/sunspecctl)
Expand Down Expand Up @@ -182,7 +182,7 @@ Run the same commands again. They always fetch the latest release and overwrite

### With Go

If you have Go 1.23 or later, you can build and install the latest release straight from source:
If you have Go 1.26 or later, you can build and install the latest release straight from source:

```bash
go install github.com/otfabric/sunspecctl@latest
Expand All @@ -203,8 +203,8 @@ The existing commands, their flags and their output are unchanged.
| Flag | Default | Description |
|------|---------|-------------|
| `--url` | `tcp://localhost:502` | Modbus device URL |
| `--unit-id` | `1` | Modbus unit ID |
| `--timeout` | `10s` | Operation timeout |
| `--unit-id` | `1` | Modbus unit ID, 1–255 |
| `--timeout` | `10s` | Operation timeout, a positive duration |
| `--json` | `false` | Output as JSON |
| `--raw` | `false` | Include raw register hex in output |
| `--baud` | `0` (19200) | Serial baud rate, for `rtu://` and `ascii://` URLs |
Expand Down Expand Up @@ -252,8 +252,8 @@ The `poll`, `poll-model`, and `poll-point` commands work like their `read` count

| Flag | Default | Description |
|------|---------|-------------|
| `--interval` | `30s` | Interval between polls (supports `s`, `m`, `h`) |
| `--count` | `0` | Number of polls (`0` = infinite, stop with Ctrl-C) |
| `--interval` | `30s` | Interval between polls, a positive duration (supports `ms`, `s`, `m`, `h`) |
| `--count` | `0` | Number of polls (`0` = infinite, stop with Ctrl-C); not negative |

```bash
# Poll all models every 10 seconds
Expand Down Expand Up @@ -379,6 +379,8 @@ sunspecctl serve --file device.json --check

`capture` writes models that decode cleanly as point values, which are easy to read and edit. A model without a known schema, or one that does not match its schema exactly (shorter, longer, a value that would not read back the same), is written as `registers`. Either way, serving the file gives a device that reads the same as the original: the same points, values, scale factors and not-implemented points at the same addresses. With `--raw`, every model is written as `registers` and the copy is register-for-register identical. `capture` writes enum and bitfield points as numbers, so that values the model definition does not name survive.

With `-o`, the file is written with mode 0600 to a temporary file next to it and then renamed into place, so an existing file is replaced whole or not at all. `-o` refuses a symbolic link and anything that is not a regular file (a directory, `/dev/null`); leave out `-o` to write to standard output. A device file `serve` reads may be at most 16 MiB.

A capture is a snapshot: the served values do not change by themselves. It can contain serial numbers and network addresses of the device; look before you share it.

### Unit IDs
Expand Down Expand Up @@ -477,7 +479,7 @@ Tests need no hardware: they run against the SunSpec fixture server of go-sunspe

## Requirements

- Go 1.23+ to build from source (not needed for the pre-built binaries)
- Go 1.26+ to build from source (not needed for the pre-built binaries)
- [otfabric/go-sunspec](https://github.com/otfabric/go-sunspec) v0.6.0+ and [otfabric/go-modbus](https://github.com/otfabric/go-modbus) v1.5.0+

## Contributing
Expand Down
25 changes: 25 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# sunspecctl Releases

## v0.5.0

**Date:** 2026-11-05
**Previous release:** v0.4.4

### Security

- Text output and error messages escape control characters, escape sequences, bidirectional overrides and invalid UTF-8 in device data (shown as `\x1b`, `\n`, `\u202e`, `\xff`), so a device can no longer clear the screen, set the window title or forge output lines. `serve`'s write log is escaped too. JSON output is unchanged.
- `capture -o` and `template -o` write the file with mode 0600 through a temporary file and a rename, so the old file is kept if writing fails, and refuse a symbolic link or a non-regular file such as a directory or `/dev/null` (exit 10). `-o /dev/stdout` no longer works: leave out `-o` instead.
- `serve --file` reads at most 16 MiB, and `--tls-cert`, `--tls-key` and `--tls-ca` at most 4 MiB each; a larger file is exit 3 "file too large".

### Fixed

- Ctrl-C or SIGTERM during discovery stops `poll`, `poll-model` and `poll-point` at once (exit 0), instead of after `--timeout`.
- Text output that cannot be written exits 10 instead of 0, and `poll*` stops instead of polling on. JSON output already did this.
- Usage errors (exit 2): a `--timeout` that is not positive (was exit 5), an `--interval` that is not positive (polled with no pause), a negative `--count` (exit 0 with no output) and `--unit-id 0` on client commands (silently read unit 1).
- The docs said `detect` on a device without the SunSpec marker prints `Detected: false` and exits 0; it exits 1 and prints no result, as before.

### Changed

- Go 1.26 or later is required to build from source (was 1.23).
- Built on go-sunspec v0.9.0 (scale factors outside -10..10 are not applied) and go-modbus v1.11.1 (serial, UDP and server fixes).

---

## v0.4.4

**Date:** 2026-11-03
Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ SunSpec devices are read, and with `sunspecctl serve` simulated, over Modbus, wh
### Data Read From Devices Is Untrusted Input

- Model IDs, lengths, strings and values come from the device. Discovery is bounded and lengths are validated before decoding, but treat what `sunspecctl` prints as untrusted: validate ranges before using values for control decisions, and escape strings (manufacturer, model, serial number) before putting them in HTML, SQL or shell commands
- Text output and error messages escape what a terminal would act on in device data (control characters and escape sequences, bidirectional overrides, bytes that are not UTF-8), so a device cannot clear the screen, set the window title or forge output lines. JSON output keeps the strings as sent, JSON-escaped
- `sunspecctl` output can contain serial numbers, network addresses and production figures; treat it as sensitive operational data when storing or sharing it

### Reading Only, as a Client
Expand All @@ -47,7 +48,7 @@ As a client `sunspecctl` reads and decodes; no command writes SunSpec control po

- **Listen on the loopback address unless others must reach it**: `--url tcp://127.0.0.1:1502`. `tcp://0.0.0.0:...` and `tcp://[::]:...` accept connections from every network the machine is on. Never expose a served device to the Internet
- **Do not run a simulator on a production network** where a controller or a monitoring system could take it for real equipment: a device file made with `capture` carries the manufacturer, model and serial number of the original
- **A captured device file is operational data**: it can contain serial numbers, network addresses, settings and production figures. Check it before sharing or committing it
- **A captured device file is operational data**: it can contain serial numbers, network addresses, settings and production figures. `capture -o` writes it with mode 0600 and never through a symbolic link. Check it before sharing or committing it
- Device files are parsed and validated before anything is served, but only serve files you wrote or reviewed

### Dependencies
Expand Down
10 changes: 8 additions & 2 deletions capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,16 @@ The file goes to standard output, or to the file named with --output.`,
}

// writeDeviceFile writes the file as indented JSON, to the named file or,
// when path is empty, to out.
// when path is empty, to out. A named file is written as writeFileAtomic
// does: mode 0600, since a capture holds serial numbers and addresses, never
// through a symbolic link, and never half.
func writeDeviceFile(out io.Writer, path string, file *devicefile.File) error {
if path == "" {
return file.Write(out)
}
return file.WriteFile(path)
data, err := file.Marshal()
if err != nil {
return err
}
return writeFileAtomic(path, data)
}
8 changes: 4 additions & 4 deletions docs/exitcodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ sunspecctl's exit codes are a script-facing contract (`exit.go`). They follow th
| Code | Kind | Meaning |
|------|------|---------|
| 0 | ok | Success. `poll` and `serve` also exit 0 after SIGINT or SIGTERM |
| 1 | findings | The device answered and is not a SunSpec device (`detect` itself reports this as a result and exits 0) |
| 2 | usage | Unknown command or flag, missing argument, invalid serial setting, an invalid `SUNSPECCTL_FORMAT` |
| 3 | invalid_input | An invalid URL, device file, TLS file, unknown point name, or a model without a schema for `template` |
| 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 |
| 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 |
| 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 |
| 9 | permission | The operating system refused an operation |
| 10 | internal | The output could not be written, or something unexpected happened |
| 10 | internal | The output could not be written (standard output, text or JSON, or the `-o` file of `capture` and `template`, also when it is a symbolic link or not a regular file), or something unexpected happened |
| 130 | interrupted | SIGINT or SIGTERM before a result |

Exit 7 (partial) is not used.
Expand Down
6 changes: 5 additions & 1 deletion docs/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ With `read` and `poll`, a model that cannot be read gives `warning: partial read
}
```

`Detected: false` is a result: `detect` exits 0. `RegType` is go-modbus's number for the table, with 0 for holding registers. `Marker` holds the two registers of "SunS". There is one `Attempts` entry per probed base address; `error` is its only lower-case field, and is left out when the read worked.
`detect` prints this document only when it found the marker, so `Detected` is always true. A device that answers but has no marker at any probed address is not a result: `detect` prints nothing on stdout, the error on stderr, and exits 1 ([exit codes](exitcodes.md)). `RegType` is go-modbus's number for the table, with 0 for holding registers. `Marker` holds the two registers of "SunS". There is one `Attempts` entry per probed base address; `error` is its only lower-case field, and is left out when the read worked.

## Discovery

Expand Down Expand Up @@ -110,6 +110,10 @@ With `read` and `poll`, a model that cannot be read gives `warning: partial read

`RawValue` is the value in the registers, with the type of `Type`: a number, or a string for string points. `ScaledValue` is `RawValue` times 10 to the power of `SFRawValue`, and null when that does not apply: no scale factor, or a point that is not implemented. `Implemented` is false when the device reports the SunSpec "not implemented" value, and `RawValue` then holds that value (-32768 for `int16`). `Symbols` lists the names of the set bits or of the enum value, or is null.

JSON has no NaN or infinity: a float `RawValue`, or a `ScaledValue`, that is not a finite number is null. A float point the device reports as NaN is SunSpec's "not implemented" (`Implemented` false); an infinite one keeps `Implemented` true with a null `RawValue`. The model's `RawRegisters` hold the registers as read.

Strings are written as the device sent them, escaped as JSON escapes them. Text output escapes what a terminal would act on instead: control characters as `\x1b`, `\t`, `\n`, `\r`, other non-printable characters such as bidirectional overrides as `\u202e`, and bytes that are not UTF-8 as `\xff`. Error messages on stderr are escaped the same way.

## Served device

```json
Expand Down
3 changes: 1 addition & 2 deletions exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ func exitCode(err error) int {
errors.As(err, &ne) && ne.Timeout():
return exitTimeout
}
var ee *exitError
if errors.As(err, &ee) {
if ee, ok := errors.AsType[*exitError](err); ok {
return ee.code
}
switch {
Expand Down
85 changes: 85 additions & 0 deletions files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: MIT

package main

import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
)

// Size limits for the files sunspecctl reads. They are far above what a
// real file needs and keep a wrong path (/dev/zero, a disk image) from
// exhausting memory.
const (
// maxDeviceFileSize bounds a device file. A device with every register
// of the address space given as a number takes about 1 MiB.
maxDeviceFileSize = 16 << 20
// maxPEMFileSize bounds a certificate, key or CA bundle. A system CA
// bundle is a few hundred KiB.
maxPEMFileSize = 4 << 20
)

// errTooLarge is returned by readFileLimited for a file over its limit.
var errTooLarge = errors.New("file too large")

// readFileLimited reads the file at path, failing with errTooLarge without
// reading further when it holds more than limit bytes. Errors name the path,
// as os.ReadFile's do.
func readFileLimited(path string, limit int64) ([]byte, error) {
f, err := os.Open(filepath.Clean(path))
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(io.LimitReader(f, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("%s: %w: more than %d MiB", path, errTooLarge, limit>>20)
}
return data, nil
}

// writeFileAtomic writes data to path as a new file with mode 0600: to a
// temporary file in the same directory first, which is then renamed over
// path, so that path holds either its old content or all of data. It refuses
// a path that is a symbolic link or not a regular file: the rename would
// replace the link or the device node, not write to what it names.
func writeFileAtomic(path string, data []byte) error {
path = filepath.Clean(path)
switch fi, err := os.Lstat(path); {
case errors.Is(err, fs.ErrNotExist):
case err != nil:
return err
case fi.Mode()&fs.ModeSymlink != 0:
return fmt.Errorf("%s is a symbolic link: refusing to write through it", path)
case !fi.Mode().IsRegular():
return fmt.Errorf("%s is not a regular file", path)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
_, err = tmp.Write(data)
if err == nil {
err = tmp.Sync()
}
if cerr := tmp.Close(); err == nil {
err = cerr
}
if err == nil {
err = rename(tmp.Name(), path)
}
if err != nil {
_ = os.Remove(tmp.Name())
}
return err
}

// rename is os.Rename; tests replace it to make the last step fail.
var rename = os.Rename
84 changes: 84 additions & 0 deletions fuzz_hostile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: MIT

package main

import (
"bytes"
"strings"
"testing"
"unicode"
"unicode/utf8"

sunspec "github.com/otfabric/go-sunspec"
"github.com/otfabric/go-sunspec/registry"
)

// wantNoControl fails when s holds anything a terminal acts on, other than
// the newlines and the tabs the table layout writes itself when allowTab.
func wantNoControl(t *testing.T, s string, allowTab bool) {
t.Helper()
if !utf8.ValidString(s) {
t.Fatalf("output is not UTF-8: %q", s)
}
for _, r := range s {
if r == '\n' || allowTab && r == '\t' {
continue
}
if r != ' ' && !unicode.IsPrint(r) {
t.Fatalf("output holds %U: %q", r, s)
}
}
}

// FuzzTermSafe checks the escaping of device strings for the terminal: the
// result holds printable runes only, and a printable string is left alone.
func FuzzTermSafe(f *testing.F) {
for _, s := range []string{"", "Acme", "\x1b[2J", "\x1b]0;title\x07", "a\tb\nc\r", "\xff\xfe", "1\u202e2", "\u009b31m", "ü€😀", `\x41`, "\x00\x7f"} {
f.Add(s)
}
f.Fuzz(func(t *testing.T, s string) {
got := termSafe(s)
wantNoControl(t, got, false)
if utf8.ValidString(s) && !strings.ContainsFunc(s, func(r rune) bool { return r != ' ' && !unicode.IsPrint(r) }) && got != s {
t.Fatalf("printable %q changed to %q", s, got)
}
if len(got) < len(s) {
t.Fatalf("escaping lost bytes: %q -> %q", s, got)
}
})
}

// FuzzTextOutput decodes registers as a device reports them and prints them
// as the text commands do: whatever the device sends, the output must not
// hold a control sequence.
func FuzzTextOutput(f *testing.F) {
f.Add(uint16(1), []byte("\x00\x01\x00\x42\x1b[2J\x1b]0;x\x07\xff\xfe\n\tSN"))
f.Add(uint16(160), []byte{0x00, 0xA0, 0x00, 0x30, 0x9b, 0x9b})
f.Add(uint16(111), []byte{0x00, 0x6F, 0x00, 0x3C, 0x7F, 0xC0, 0, 0, 0x7F, 0x80, 0, 0})
f.Fuzz(func(t *testing.T, id uint16, data []byte) {
meta := registry.ByID(id)
if meta == nil {
return
}
regs := make([]uint16, len(data)/2)
for i := range regs {
regs[i] = uint16(data[2*i])<<8 | uint16(data[2*i+1])
}
dm, _ := sunspec.DecodeModel(regs, meta, 40000)
if dm == nil {
return
}
var out bytes.Buffer
printDecodedModel(&out, dm)
// printGroup's tabwriter pads with spaces; the only tabs left would
// be a device's.
wantNoControl(t, out.String(), false)
if dm.Group != nil {
for _, p := range dm.Group.Points {
out.Reset()
printPoint(&out, &p)
wantNoControl(t, out.String(), false)
}
}
})
}
Loading
Loading