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: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ Run the same read on multiple rack/slot pairs and see if results are identical (
s7commctl compare read --ip 192.168.1.10 --area DB --db 1 --offset 0 --size 32 --candidates 0/0,0/1,0/2
```

`--timeout` bounds each candidate's connect and, separately, its read, so a PLC that goes silent after setup times out instead of blocking.

Table output includes a **SameAsBaseline** column (baseline/yes/no). Use `--repeat` and `--interval-ms` for multiple runs when data may change (e.g. Q area).

### 5. Profile (capabilities summary)
Expand Down Expand Up @@ -420,6 +422,24 @@ Run a deterministic replay endpoint with scripted response frames (hex TPKT, one
s7commctl replay --listen :1102 --script replay.hex --format jsonl
```

## Reaching the PLC: proxy and source address

Every command that connects to a PLC (`identify`, `read`, `write`, `list`, `block`, `blocks`, `diagnostic`, `fingerprint`, `scan`, `record`, `probe rackslot`, `compare read`, `profile` and `discover`) accepts two flags for how the TCP connection is opened:

- `--proxy socks5://host:port` connects through a SOCKS5 proxy (RFC 1928, no authentication), for example an SSH jump host into the plant network (`ssh -D 1080 jumphost`). `socks5h://` sends host names to the proxy to resolve. Proxy credentials are not supported.
- `--source-addr <ip>` connects from that local address, on a host with several networks (an OT and an office interface, say).

With both, the connection to the proxy is made from the source address.

```bash
ssh -N -D 1080 engineer@jumphost &
s7commctl identify --ip 10.20.0.15 --proxy socks5://127.0.0.1:1080
s7commctl discover --cidr 10.20.0.0/24 --proxy socks5://127.0.0.1:1080
s7commctl read --ip 192.168.0.10 --source-addr 192.168.0.2 --area DB --db 1 --size 4
```

An invalid value is a usage error (exit 2). A proxy that refuses the connection is a connection failure; the error names the proxy and its reply, for example `socks5: connection not allowed by ruleset`.

## Configuration via environment variables

Most command flags support `S7COMMCTL_*` environment variables, for example:
Expand All @@ -433,6 +453,8 @@ Most command flags support `S7COMMCTL_*` environment variables, for example:
- S7COMMCTL_BRUTE_RACK_SLOT (`--brute-rack-slot`)
- S7COMMCTL_LOCAL_TSAP (`--local-tsap`)
- S7COMMCTL_REMOTE_TSAP (`--remote-tsap`)
- S7COMMCTL_PROXY (`--proxy`)
- S7COMMCTL_SOURCE_ADDR (`--source-addr`)

Example:

Expand Down
31 changes: 31 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# s7commctl Releases

## v0.7.0

**Date:** 2026-11-09
**Previous release:** v0.6.1

### Summary

Two flags on every command that connects to a PLC 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-s7comm v0.15.0, whose scan helpers take a dialer too, so the flags also cover `discover`, `probe rackslot`, `compare read` and `profile`.

### Added

- `--proxy socks5://host:port` (`S7COMMCTL_PROXY`): connect through a SOCKS5 proxy (RFC 1928, CONNECT, no authentication); `socks5h://` sends host names to the proxy. Proxy credentials, other schemes, a missing port or a path are usage errors (exit 2). A refusal by the proxy is a connection failure naming the proxy and its reply.
- `--source-addr <ip>` (`S7COMMCTL_SOURCE_ADDR`): connect from that local IP address; with `--proxy`, the connection to the proxy.
- Both on `identify`, `read`, `write`, `list`, `block`, `blocks`, `diagnostic`, `fingerprint`, `scan`, `record`, `probe rackslot`, `compare read`, `profile` and `discover`.

### Breaking changes

- `compare read`: `--timeout` now bounds each candidate's read as well as its connect (go-s7comm v0.15.0). A PLC that accepted setup and then went silent used to block the command indefinitely; it now times out. A PLC that answers more slowly than `--timeout` now times out too: raise `--timeout` for it.

### Changed

- go-s7comm v0.15.0 (was v0.12.0): `client.WithDialer` and the dialer of the scan helpers.

### Tests

- `internal/app/netdial`: flag validation, source address binding, SOCKS5 CONNECT for IPv4, IPv6 and host names (`socks5` and `socks5h`), every reply and handshake failure, a silent proxy against the deadline, cancel. A context that ends as the handshake finishes fails the dial on one path instead of a timing-dependent branch.
- `internal/socks5test`: the test proxy's listen failure; it is at 100 % coverage, as is `internal/app/netdial`.
- `cmd/s7commctl`: each connecting command through a test proxy to the fake PLC, `discover` over a whole range with the real `client.Discover` (the proxy forwards every CONNECT to the fake PLC), a refusing proxy, `--source-addr`, and invalid values as usage errors on every command.

---

## 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 @@ -19,6 +19,7 @@ Include a clear description, steps to reproduce, and the impact. We will acknowl
## Security-related considerations

- **Network and PLC access**: s7commctl connects to Siemens S7 devices over TCP (port 102). Use it only on networks and targets you are authorized to access. The CLI warns when the target IP appears publicly routable (`--no-exposure-warning` suppresses this in authorized environments).
- **Proxies**: `--proxy` sends the connection, and the PLC traffic in clear text, through the SOCKS5 proxy you name; use only a proxy you control, such as an SSH jump host. The exposure warning checks the PLC's address, not the proxy's.
- **Writes**: The `write` command can modify PLC memory and requires explicit `--dangerous` (and optionally `--force`). Use with care in production.
- **Credentials**: The tool does not store passwords; S7 over ISO-on-TCP (port 102) typically relies on network segmentation and PLC protection levels rather than application-level authentication. Ensure targets are not exposed to untrusted networks.

Expand Down
98 changes: 98 additions & 0 deletions cmd/s7commctl/cmd_dial_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: MIT

package s7commctl

import (
"net"
"strconv"
"strings"
"testing"

"github.com/otfabric/s7commctl/internal/socks5test"
)

// Every command that connects goes through --proxy: the proxy sees a CONNECT
// to the PLC and the command succeeds.
func TestProxy_EveryConnectingCommand(t *testing.T) {
p := probePLC(t)
for _, tc := range []struct {
name string
args []string
}{
{"identify", p.args("identify", "--rack", "0", "--slot", "2")},
{"probe rackslot", probeArgs(p)},
{"compare read", compareArgs(p)},
{"profile", []string{"profile", "--ip", "127.0.0.1", "--port", p.port, "--timeout", "2000"}},
} {
t.Run(tc.name, func(t *testing.T) {
px := socks5test.Start(t)
runCLI(t, append(tc.args, "--proxy", px.URL())...).ok(t)
reqs := px.Requests()
if len(reqs) == 0 {
t.Fatal("the proxy saw no CONNECT")
}
for _, r := range reqs {
if r.Host != "127.0.0.1" || net.JoinHostPort(r.Host, strconv.Itoa(r.Port)) != net.JoinHostPort("127.0.0.1", p.port) {
t.Errorf("CONNECT %+v, want 127.0.0.1:%s", r, p.port)
}
}
})
}
}

// discover scans a whole range through the proxy, which forwards every
// CONNECT to the fake PLC: client.Discover runs unmodified.
func TestProxy_DiscoverThroughProxy(t *testing.T) {
p := probePLC(t)
px := socks5test.Start(t)
px.Forward = func(string) string { return net.JoinHostPort("127.0.0.1", p.port) }
r := runCLI(t, "discover", "--cidr", "192.0.2.0/30", "--profile", "full", "--timeout", "2000",
"--parallel", "1", "--quiet", "--proxy", px.URL()).ok(t)
if !strings.Contains(r.stdout, "192.0.2.") {
t.Fatalf("stdout = %q, want hosts of 192.0.2.0/30", r.stdout)
}
for _, req := range px.Requests() {
if !strings.HasPrefix(req.Host, "192.0.2.") || req.Port != 102 {
t.Errorf("CONNECT %+v, want 192.0.2.x:102", req)
}
}
}

// A proxy that refuses is a connection failure, not a usage error.
func TestProxy_RefusedIsAFailure(t *testing.T) {
p := probePLC(t)
px := socks5test.Start(t)
px.Reply = 2
runCLI(t, append(p.args("identify", "--rack", "0", "--slot", "2"), "--proxy", px.URL())...).
wantFailure(t, "connection not allowed by ruleset")
}

// --source-addr binds the local end of the connection.
func TestSourceAddr(t *testing.T) {
p := probePLC(t)
runCLI(t, append(p.args("identify", "--rack", "0", "--slot", "2"), "--source-addr", "127.0.0.1")...).ok(t)
}

// An invalid --proxy or --source-addr is a usage error (exit 2) on every
// command that connects, before anything is sent.
func TestDialFlags_InvalidIsUsage(t *testing.T) {
commands := [][]string{
{"identify", "--ip", "127.0.0.1"},
{"read", "--ip", "127.0.0.1", "--area", "DB", "--db", "1", "--size", "1"},
{"probe", "rackslot", "--ip", "127.0.0.1"},
{"compare", "read", "--ip", "127.0.0.1", "--candidates", "0/1", "--area", "DB", "--size", "1"},
{"profile", "--ip", "127.0.0.1"},
{"discover", "--cidr", "192.0.2.0/30"},
}
for _, bad := range [][]string{
{"--proxy", "http://127.0.0.1:8080"},
{"--proxy", "socks5://user:pw@127.0.0.1:1080"},
{"--source-addr", "eth0"},
} {
for _, cmd := range commands {
t.Run(strings.Join(append(cmd[:1:1], bad...), " "), func(t *testing.T) {
runCLI(t, append(append([]string{}, cmd...), bad...)...).wantUsage(t, bad[0])
})
}
}
}
5 changes: 5 additions & 0 deletions cmd/s7commctl/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ func runCompareRead(cmd *cobra.Command, args []string) error {
return ExitUsage(fmt.Errorf("size must be > 0"))
}

dial, err := compareReadConfig.Dialer()
if err != nil {
return ExitUsage(err)
}
req := client.CompareReadRequest{
Dialer: dial,
Address: compareReadConfig.IP,
Port: compareReadConfig.Port,
Candidates: candidates,
Expand Down
9 changes: 7 additions & 2 deletions cmd/s7commctl/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ var discoverConfig = config.DefaultDiscoverConfig()

// discoverHosts runs the scan. client.Discover always dials port 102, which a
// test cannot bind, so the tests replace it to see the options that reach it.
var discoverHosts = func(ctx context.Context, cfg config.DiscoverConfig) ([]client.DiscoverResult, error) {
var discoverHosts = func(ctx context.Context, cfg config.DiscoverConfig, dial client.Dialer) ([]client.DiscoverResult, error) {
return client.Discover(
ctx,
cfg.CIDR,
client.WithDiscoverTimeout(cfg.Timeout),
client.WithDiscoverParallel(cfg.Parallel),
client.WithDiscoverRackSlotRange(cfg.RackMin, cfg.RackMax, cfg.SlotMin, cfg.SlotMax),
client.WithDiscoverRateLimit(cfg.RateMS),
client.WithDiscoverDialer(dial),
)
}

Expand All @@ -55,7 +56,11 @@ func runDiscover(cmd *cobra.Command, args []string) error {
return ExitUsage(err)
}

results, err := discoverHosts(cmd.Context(), cfg)
dial, err := cfg.Dialer()
if err != nil {
return ExitUsage(err)
}
results, err := discoverHosts(cmd.Context(), cfg, dial)
if err != nil {
return ExitRuntime(err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/s7commctl/discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func recordDiscover(t *testing.T) *config.DiscoverConfig {
t.Helper()
got := new(config.DiscoverConfig)
old := discoverHosts
discoverHosts = func(_ context.Context, cfg config.DiscoverConfig) ([]client.DiscoverResult, error) {
discoverHosts = func(_ context.Context, cfg config.DiscoverConfig, _ client.Dialer) ([]client.DiscoverResult, error) {
*got = cfg
return []client.DiscoverResult{{IP: "10.0.0.9", IsS7: true}, {IP: "10.0.0.10"}}, nil
}
Expand Down
8 changes: 8 additions & 0 deletions cmd/s7commctl/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ func runProfile(cmd *cobra.Command, args []string) error {
}
warnIfPublicTarget(profileConfig.IP, false, "verify authorization and network exposure", profileConfig.NoExposureWarning)

dial, err := profileConfig.Dialer()
if err != nil {
return ExitUsage(err)
}
ctx := cmd.Context()
timeout := time.Duration(profileConfig.Timeout) * time.Millisecond
if timeout <= 0 {
Expand All @@ -58,6 +62,7 @@ func runProfile(cmd *cobra.Command, args []string) error {
probeReq.SlotMin, probeReq.SlotMax = 0, 5
probeReq.Timeout = timeout
probeReq.Parallelism = 4
probeReq.Dialer = dial

probeRes, err := client.ProbeRackSlots(ctx, probeReq)
if err != nil {
Expand Down Expand Up @@ -87,6 +92,9 @@ func runProfile(cmd *cobra.Command, args []string) error {
if profileConfig.Port > 0 {
opts = append(opts, client.WithPort(profileConfig.Port))
}
if dial != nil {
opts = append(opts, client.WithDialer(dial))
}
c := client.New(profileConfig.IP, opts...)
if err := c.Connect(ctx); err == nil {
defer func() { _ = c.Close() }()
Expand Down
Loading
Loading