diff --git a/README.md b/README.md index 842fa61..6da88fc 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 ` 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: @@ -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: diff --git a/RELEASE.md b/RELEASE.md index c2556ad..c6996e6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 ` (`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 diff --git a/SECURITY.md b/SECURITY.md index 5f49e91..85eaf99 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/cmd/s7commctl/cmd_dial_test.go b/cmd/s7commctl/cmd_dial_test.go new file mode 100644 index 0000000..01504d6 --- /dev/null +++ b/cmd/s7commctl/cmd_dial_test.go @@ -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]) + }) + } + } +} diff --git a/cmd/s7commctl/compare.go b/cmd/s7commctl/compare.go index ccbce21..1a8082c 100644 --- a/cmd/s7commctl/compare.go +++ b/cmd/s7commctl/compare.go @@ -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, diff --git a/cmd/s7commctl/discover.go b/cmd/s7commctl/discover.go index 253c60d..2c1f31c 100644 --- a/cmd/s7commctl/discover.go +++ b/cmd/s7commctl/discover.go @@ -22,7 +22,7 @@ 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, @@ -30,6 +30,7 @@ var discoverHosts = func(ctx context.Context, cfg config.DiscoverConfig) ([]clie client.WithDiscoverParallel(cfg.Parallel), client.WithDiscoverRackSlotRange(cfg.RackMin, cfg.RackMax, cfg.SlotMin, cfg.SlotMax), client.WithDiscoverRateLimit(cfg.RateMS), + client.WithDiscoverDialer(dial), ) } @@ -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) } diff --git a/cmd/s7commctl/discover_test.go b/cmd/s7commctl/discover_test.go index 399823b..50bd3e9 100644 --- a/cmd/s7commctl/discover_test.go +++ b/cmd/s7commctl/discover_test.go @@ -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 } diff --git a/cmd/s7commctl/profile.go b/cmd/s7commctl/profile.go index 0baf93e..2f65236 100644 --- a/cmd/s7commctl/profile.go +++ b/cmd/s7commctl/profile.go @@ -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 { @@ -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 { @@ -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() }() diff --git a/docs/commands.md b/docs/commands.md index cff4526..e5a3943 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -22,11 +22,13 @@ Get block metadata | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--number` | int | Block number | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | | `--type` | string | Block type: OB, DB, FC, FB | @@ -44,11 +46,13 @@ Upload block contents | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--number` | int | Block number | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | | `--type` | string | Block type: OB, DB, FC, FB | @@ -66,11 +70,13 @@ Alias/shortcut for listing blocks. Use "list blocks" for the same behavior under | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--number` | int | Block number | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | | `--type` | string | Block type: OB, DB, FC, FB | @@ -95,8 +101,10 @@ Connect to each candidate (rack/slot), perform the same read, report results and | `--no-exposure-warning` | bool | Suppress public routable IP warning [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--offset` | int | Byte offset [env: S7COMMCTL_OFFSET] | | `--port` | int | Port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--repeat` | int | Repeat compare N times (for dynamic data) [env: S7COMMCTL_COMPARE_REPEAT] | | `--size` | int | Number of bytes [env: S7COMMCTL_SIZE] | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout per candidate in ms [env: S7COMMCTL_TIMEOUT] (default 5000) | ## `s7commctl completion` @@ -118,11 +126,13 @@ Read diagnostic buffer from S7 device | `--local-tsap` | string | Local TSAP (decimal or 0x hex) [env: S7COMMCTL_LOCAL_TSAP] | | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | ## `s7commctl discover` @@ -135,12 +145,14 @@ Discover S7 devices on a network | `--format` | string | Output format: json, table, text [env: S7COMMCTL_FORMAT] (default "table") | | `--parallel` | int | Number of parallel probes [env: S7COMMCTL_PARALLEL] (default 10) | | `--profile` | string | Probe profile: quick (rack 0, slot 1-2), standard (rack 0-1, slot 0-3), full (rack 0-3, slot 0-5) [env: S7COMMCTL_DISCOVER_PROFILE] (default "standard") | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Only print discovered IP addresses (one per line) [env: S7COMMCTL_QUIET] | | `--rack-max` | int | Maximum rack to probe, overrides --profile [env: S7COMMCTL_RACK_MAX] (default 1) | | `--rack-min` | int | Minimum rack to probe, overrides --profile [env: S7COMMCTL_RACK_MIN] | | `--rate-ms` | int | Delay between rack/slot probes in ms [env: S7COMMCTL_RATE_MS] | | `--slot-max` | int | Maximum slot to probe, overrides --profile [env: S7COMMCTL_SLOT_MAX] (default 3) | | `--slot-min` | int | Minimum slot to probe, overrides --profile [env: S7COMMCTL_SLOT_MIN] | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout per host in ms [env: S7COMMCTL_TIMEOUT] (default 2000) | ## `s7commctl fingerprint` @@ -156,11 +168,13 @@ Fingerprint an S7 device | `--local-tsap` | string | Local TSAP (decimal or 0x hex) [env: S7COMMCTL_LOCAL_TSAP] | | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | ## `s7commctl identify` @@ -176,11 +190,13 @@ Identify an S7 device | `--local-tsap` | string | Local TSAP (decimal or 0x hex) [env: S7COMMCTL_LOCAL_TSAP] | | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | ## `s7commctl list` @@ -209,11 +225,13 @@ List program blocks | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--number` | int | Block number | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | | `--type` | string | Block type: OB, DB, FC, FB | @@ -231,11 +249,13 @@ List DB blocks | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--number` | int | Block number | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | | `--type` | string | Block type: OB, DB, FC, FB | @@ -272,6 +292,7 @@ Probe a specific IP for valid Siemens S7 rack/slot pairs. | `--no-exposure-warning` | bool | Suppress public routable IP warning [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--parallelism` | int | Concurrent probes [env: S7COMMCTL_PROBE_PARALLELISM] (default 4) | | `--port` | int | TCP port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--rack-max` | int | Maximum rack to probe [env: S7COMMCTL_RACK_MAX] (default 7) | | `--rack-min` | int | Minimum rack to probe [env: S7COMMCTL_RACK_MIN] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | @@ -281,6 +302,7 @@ Probe a specific IP for valid Siemens S7 rack/slot pairs. | `--show-all` | bool | Show all candidates in table including non-valid [env: S7COMMCTL_PROBE_SHOW_ALL] | | `--slot-max` | int | Maximum slot to probe [env: S7COMMCTL_SLOT_MAX] (default 31) | | `--slot-min` | int | Minimum slot to probe [env: S7COMMCTL_SLOT_MIN] | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--strict` | bool | Only count valid-query as valid (confirm with follow-up) [env: S7COMMCTL_PROBE_STRICT] | | `--summary-only` | bool | Print only summary and interpretation (no table or examples) [env: S7COMMCTL_PROBE_SUMMARY_ONLY] | | `--timeout` | int | Per-attempt timeout in ms [env: S7COMMCTL_TIMEOUT] (default 2000) | @@ -295,6 +317,8 @@ Probe target for valid rack/slot, then run identify and list blocks to summarize | `--ip` | string | Target IP address [env: S7COMMCTL_IP] | | `--no-exposure-warning` | bool | Suppress public routable IP warning [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--port` | int | TCP port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | ## `s7commctl read` @@ -313,12 +337,14 @@ Read data from an S7 device | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--offset` | int | Byte offset [env: S7COMMCTL_OFFSET] | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--size` | int | Number of bytes [env: S7COMMCTL_SIZE] (default 1) | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--strict-read` | bool | Fail when status is not success (e.g. short-read, empty-read) [env: S7COMMCTL_STRICT_READ] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | | `--type` | string | Data type [env: S7COMMCTL_TYPE] (default "raw") | @@ -342,12 +368,14 @@ Record periodic S7 reads to a local file | `--offset` | int | Byte offset [env: S7COMMCTL_OFFSET] | | `--output` | string | Output file path [env: S7COMMCTL_OUTPUT] (default "record.jsonl") | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--size` | int | Number of bytes per sample [env: S7COMMCTL_SIZE] (default 1) | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | | `--type` | string | Data type [env: S7COMMCTL_TYPE] (default "raw") | @@ -385,6 +413,7 @@ Probe an area from start to end by step; consolidate adjacent readable probes in | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | | `--probe-size` | int | Bytes to read per probe [env: S7COMMCTL_PROBE_SIZE] (default 8) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | @@ -393,6 +422,7 @@ Probe an area from start to end by step; consolidate adjacent readable probes in | `--retries` | int | Retries per probe [env: S7COMMCTL_SCAN_RETRIES] | | `--retry-delay-ms` | int | Delay between retries in ms [env: S7COMMCTL_SCAN_RETRY_DELAY_MS] | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--start` | int | Start offset (inclusive) [env: S7COMMCTL_SCAN_START] | | `--step` | int | Step between probes (0 = use probe-size) [env: S7COMMCTL_SCAN_STEP] | | `--summary-only` | bool | Print only span summary (no per-probe table) [env: S7COMMCTL_SCAN_SUMMARY_ONLY] | @@ -441,11 +471,13 @@ Write data to an S7 device | `--no-exposure-warning` | bool | Suppress public routable IP warning (e.g. authorized environments) [env: S7COMMCTL_NO_EXPOSURE_WARNING] | | `--offset` | int | Byte offset [env: S7COMMCTL_OFFSET] | | `--port` | int | S7 port (default 102) [env: S7COMMCTL_PORT] (default 102) | +| `--proxy` | string | Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy) [env: S7COMMCTL_PROXY] | | `--quiet` | bool | Minimal output [env: S7COMMCTL_QUIET] | | `--rack` | int | PLC rack number [env: S7COMMCTL_RACK] | | `--rate-limit-ms` | int | Minimum delay between protocol operations in ms [env: S7COMMCTL_RATE_LIMIT_MS] | | `--remote-tsap` | string | Remote TSAP (decimal or 0x hex) [env: S7COMMCTL_REMOTE_TSAP] | | `--slot` | int | PLC slot number [env: S7COMMCTL_SLOT] (default 1) | +| `--source-addr` | string | Local IP address to connect from (multi-homed hosts) [env: S7COMMCTL_SOURCE_ADDR] | | `--timeout` | int | Timeout in milliseconds [env: S7COMMCTL_TIMEOUT] (default 5000) | | `--type` | string | Data type (default "raw") | | `--value` | string | Value to write | diff --git a/go.mod b/go.mod index 5b1b0c3..82782b0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/otfabric/s7commctl go 1.26.0 require ( - github.com/otfabric/go-s7comm v0.12.0 + github.com/otfabric/go-s7comm v0.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 ) diff --git a/go.sum b/go.sum index 83e22a3..020277e 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/otfabric/go-cotp v1.3.0 h1:IXfqzdt86GPg6+il9g2GIWLFFmtNuVBMiE2ceLrXJpo= github.com/otfabric/go-cotp v1.3.0/go.mod h1:0XVoeSHnOPj8XWI484tUC2U6dbaKCtVk0z18MipM37s= -github.com/otfabric/go-s7comm v0.12.0 h1:x7a1eUuOduIPzjujxwQTMfGU3OAbFvVHXMmXtQcC7Ss= -github.com/otfabric/go-s7comm v0.12.0/go.mod h1:td4Iz+J3NNMVk8xeuls+J5Mnya2tBr+7Qeymi447oyg= +github.com/otfabric/go-s7comm v0.15.0 h1:Zu1Qsh5lmM76p9rbOxMc1g2CzPsvJi6ifnDCyy1WXIc= +github.com/otfabric/go-s7comm v0.15.0/go.mod h1:td4Iz+J3NNMVk8xeuls+J5Mnya2tBr+7Qeymi447oyg= github.com/otfabric/go-tpkt v1.1.0 h1:KTCQePVqqnvDB0A3O4ReRqfa5jJ3jnsEYNUdS91vD4o= github.com/otfabric/go-tpkt v1.1.0/go.mod h1:qGkrTZ03mIwhCQZCL9S3OaOAVyw9TVdZXJf1lSYRLog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/app/config/config.go b/internal/app/config/config.go index 56f9021..bb59c12 100644 --- a/internal/app/config/config.go +++ b/internal/app/config/config.go @@ -12,12 +12,27 @@ import ( "time" "github.com/otfabric/go-s7comm/client" + "github.com/otfabric/s7commctl/internal/app/netdial" "github.com/spf13/cobra" ) +// DialConfig selects how TCP connections to the PLC are opened. Every +// command that connects embeds it. +type DialConfig struct { + SourceAddr string `flag:"source-addr" env:"S7COMMCTL_SOURCE_ADDR" desc:"Local IP address to connect from (multi-homed hosts)"` + Proxy string `flag:"proxy" env:"S7COMMCTL_PROXY" desc:"Connect through a SOCKS5 proxy: socks5://host:port (socks5h:// resolves names at the proxy)"` +} + +// Dialer returns the client.Dialer of the flags, or nil when neither is set +// (the library's default dialer). An error means a flag is invalid. +func (d DialConfig) Dialer() (client.Dialer, error) { + return netdial.New(d.SourceAddr, d.Proxy) +} + // DeviceConfig contains common connection parameters type DeviceConfig struct { + DialConfig IP string `flag:"ip" env:"S7COMMCTL_IP" desc:"S7 device IP address"` Port int `flag:"port" env:"S7COMMCTL_PORT" desc:"S7 port (default 102)"` Rack int `flag:"rack" env:"S7COMMCTL_RACK" desc:"PLC rack number"` @@ -67,6 +82,13 @@ func (c DeviceConfig) ToClientOptions() ([]client.Option, error) { if c.RateLimitMS > 0 { opts = append(opts, client.WithRateLimit(time.Duration(c.RateLimitMS)*time.Millisecond)) } + d, err := c.Dialer() + if err != nil { + return nil, err + } + if d != nil { + opts = append(opts, client.WithDialer(d)) + } return opts, nil } @@ -104,6 +126,7 @@ func DefaultDeviceConfig() DeviceConfig { // DiscoverConfig for the discover command type DiscoverConfig struct { + DialConfig CIDR string `flag:"cidr" env:"S7COMMCTL_CIDR" desc:"CIDR range to scan"` Timeout int `flag:"timeout" env:"S7COMMCTL_TIMEOUT" desc:"Timeout per host in ms"` Parallel int `flag:"parallel" env:"S7COMMCTL_PARALLEL" desc:"Number of parallel probes"` @@ -198,6 +221,7 @@ func DefaultFingerprintConfig() FingerprintConfig { // ProbeRackSlotConfig for probe rackslot command type ProbeRackSlotConfig struct { + DialConfig IP string `flag:"ip" env:"S7COMMCTL_IP" desc:"Target IP address"` NoExposureWarning bool `flag:"no-exposure-warning" env:"S7COMMCTL_NO_EXPOSURE_WARNING" desc:"Suppress public routable IP warning"` Port int `flag:"port" env:"S7COMMCTL_PORT" desc:"TCP port (default 102)"` @@ -264,6 +288,11 @@ func (c ProbeRackSlotConfig) ToProbeRequest() (client.RackSlotProbeRequest, erro req.Retries = max(c.Retries, 0) req.RetryDelay = time.Duration(c.RetryDelayMS) * time.Millisecond req.StopOnFirstConfirmed = c.FirstConfirmed + d, err := c.Dialer() + if err != nil { + return req, err + } + req.Dialer = d if c.LocalTSAP != "" || c.RemoteTSAP != "" { if (c.LocalTSAP == "") != (c.RemoteTSAP == "") { return req, fmt.Errorf("both --local-tsap and --remote-tsap must be set together") @@ -314,6 +343,7 @@ func DefaultScanReadRangesConfig() ScanReadRangesConfig { // CompareReadConfig for compare read command type CompareReadConfig struct { + DialConfig IP string `flag:"ip" env:"S7COMMCTL_IP" desc:"Target IP"` NoExposureWarning bool `flag:"no-exposure-warning" env:"S7COMMCTL_NO_EXPOSURE_WARNING" desc:"Suppress public routable IP warning"` Port int `flag:"port" env:"S7COMMCTL_PORT" desc:"Port (default 102)"` @@ -334,6 +364,7 @@ func DefaultCompareReadConfig() CompareReadConfig { // ProfileConfig for profile/capabilities command type ProfileConfig struct { + DialConfig IP string `flag:"ip" env:"S7COMMCTL_IP" desc:"Target IP address"` Port int `flag:"port" env:"S7COMMCTL_PORT" desc:"TCP port (default 102)"` Timeout int `flag:"timeout" env:"S7COMMCTL_TIMEOUT" desc:"Timeout in milliseconds"` diff --git a/internal/app/config/dial_test.go b/internal/app/config/dial_test.go new file mode 100644 index 0000000..2744863 --- /dev/null +++ b/internal/app/config/dial_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +package config + +import "testing" + +func TestDialConfigFromEnvironment(t *testing.T) { + t.Setenv("S7COMMCTL_PROXY", "socks5://127.0.0.1:1080") + t.Setenv("S7COMMCTL_SOURCE_ADDR", "127.0.0.1") + for _, cfg := range []any{new(DeviceConfig), new(DiscoverConfig), new(ProbeRackSlotConfig), new(CompareReadConfig), new(ProfileConfig)} { + LoadFromEnv(cfg) + var d DialConfig + switch c := cfg.(type) { + case *DeviceConfig: + d = c.DialConfig + case *DiscoverConfig: + d = c.DialConfig + case *ProbeRackSlotConfig: + d = c.DialConfig + case *CompareReadConfig: + d = c.DialConfig + case *ProfileConfig: + d = c.DialConfig + } + if d.Proxy != "socks5://127.0.0.1:1080" || d.SourceAddr != "127.0.0.1" { + t.Errorf("%T: %+v", cfg, d) + } + } +} + +func TestDialConfigReachesOptions(t *testing.T) { + c := DefaultDeviceConfig() + base, err := c.ToClientOptions() + if err != nil { + t.Fatal(err) + } + c.Proxy = "socks5://127.0.0.1:1080" + with, err := c.ToClientOptions() + if err != nil || len(with) != len(base)+1 { + t.Fatalf("options %d (err %v), want one more than %d for the dialer", len(with), err, len(base)) + } + c.Proxy = "ftp://x:1" + if _, err := c.ToClientOptions(); err == nil { + t.Error("invalid proxy: no error") + } + + p := ProbeRackSlotConfig{IP: "127.0.0.1"} + req, err := p.ToProbeRequest() + if err != nil || req.Dialer != nil { + t.Fatalf("no flags: dialer set (%v)", err) + } + p.SourceAddr = "127.0.0.1" + if req, err = p.ToProbeRequest(); err != nil || req.Dialer == nil { + t.Fatalf("--source-addr: no dialer (%v)", err) + } + p.SourceAddr = "nope" + if _, err = p.ToProbeRequest(); err == nil { + t.Error("invalid source address: no error") + } +} diff --git a/internal/app/netdial/netdial.go b/internal/app/netdial/netdial.go new file mode 100644 index 0000000..7733cfb --- /dev/null +++ b/internal/app/netdial/netdial.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MIT + +// Package netdial builds the client.Dialer of the --source-addr and --proxy +// flags: a TCP dial bound to a local address, optionally through a SOCKS5 +// proxy (RFC 1928, no authentication). +package netdial + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/url" + "strconv" + "time" + + "github.com/otfabric/go-s7comm/client" +) + +// 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) (client.Dialer, error) { + if sourceAddr == "" && proxy == "" { + return nil, nil + } + var base net.Dialer + if sourceAddr != "" { + ip := net.ParseIP(sourceAddr) + if ip == nil { + return nil, fmt.Errorf("invalid --source-addr %q: want an IP address", sourceAddr) + } + base.LocalAddr = &net.TCPAddr{IP: ip} + } + if proxy == "" { + return base.DialContext, nil + } + p, err := parseProxy(proxy) + if err != nil { + return nil, err + } + p.base = base + return p.dial, nil +} + +type socks5 struct { + addr string // proxy host:port + remoteName bool // socks5h: the proxy resolves host names + base net.Dialer +} + +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.DialContext(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/app/netdial/netdial_test.go b/internal/app/netdial/netdial_test.go new file mode 100644 index 0000000..6599cd6 --- /dev/null +++ b/internal/app/netdial/netdial_test.go @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: MIT + +package netdial + +import ( + "context" + "errors" + "io" + "net" + "strconv" + "strings" + "testing" + "time" + + "github.com/otfabric/s7commctl/internal/socks5test" +) + +// echo listens on loopback and echoes what it reads; it returns host:port. +func echo(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { defer func() { _ = c.Close() }(); _, _ = io.Copy(c, c) }() + } + }() + return ln.Addr().String() +} + +func roundTrip(t *testing.T, c net.Conn) { + t.Helper() + defer func() { _ = c.Close() }() + _ = c.SetDeadline(time.Now().Add(2 * time.Second)) + if _, err := c.Write([]byte("ping")); err != nil { + t.Fatal(err) + } + buf := make([]byte, 4) + if _, err := io.ReadFull(c, buf); err != nil || string(buf) != "ping" { + t.Fatalf("echo = %q, %v", buf, err) + } +} + +func ctx2s(t *testing.T) context.Context { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + t.Cleanup(cancel) + return ctx +} + +func TestNewNothingSet(t *testing.T) { + d, err := New("", "") + if d != nil || err != nil { + t.Fatalf("New(\"\", \"\") = %v, %v; want nil, nil", d != nil, err) + } +} + +func TestNewInvalid(t *testing.T) { + for _, tc := range []struct{ src, proxy, want string }{ + {"not-an-ip", "", "--source-addr"}, + {"", "http://127.0.0.1:8080", "scheme must be socks5"}, + {"", "socks5://user:pw@127.0.0.1:1080", "credentials are not supported"}, + {"", "socks5://127.0.0.1", "want socks5://host:port"}, + {"", "socks5://:1080", "want socks5://host:port"}, + {"", "socks5://127.0.0.1:0", "invalid port"}, + {"", "socks5://127.0.0.1:99999", "invalid port"}, + {"", "socks5://127.0.0.1:1080/path", "want socks5://host:port"}, + {"", "socks5://127.0.0.1:1080?x=1", "want socks5://host:port"}, + {"", "socks5://[::1", "invalid --proxy"}, + } { + if _, err := New(tc.src, tc.proxy); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("New(%q, %q) error = %v, want it to mention %q", tc.src, tc.proxy, err, tc.want) + } + } +} + +func TestSourceAddrBinds(t *testing.T) { + d, err := New("127.0.0.1", "") + if err != nil { + t.Fatal(err) + } + c, err := d(ctx2s(t), "tcp", echo(t)) + if err != nil { + t.Fatal(err) + } + if ip := c.LocalAddr().(*net.TCPAddr).IP; !ip.Equal(net.IPv4(127, 0, 0, 1)) { + t.Errorf("local address %v, want 127.0.0.1", ip) + } + roundTrip(t, c) +} + +func TestProxyConnect(t *testing.T) { + target := echo(t) + host, portStr, _ := net.SplitHostPort(target) + port, _ := strconv.Atoi(portStr) + for _, bound := range []byte{1, 3, 4} { + px := socks5test.Start(t) + px.BoundType = bound + d, err := New("127.0.0.1", px.URL()) + if err != nil { + t.Fatal(err) + } + c, err := d(ctx2s(t), "tcp", target) + if err != nil { + t.Fatalf("bound type %d: %v", bound, err) + } + roundTrip(t, c) + if r := px.Requests(); len(r) != 1 || r[0].AddrType != 1 || r[0].Host != host || r[0].Port != port { + t.Errorf("bound type %d: proxy saw %+v, want IPv4 %s:%d", bound, r, host, port) + } + } +} + +func TestProxyIPv6Target(t *testing.T) { + px := socks5test.Start(t) + target := echo(t) + px.Forward = func(string) string { return target } + d, _ := New("", px.URL()) + c, err := d(ctx2s(t), "tcp", "[2001:db8::7]:102") + if err != nil { + t.Fatal(err) + } + roundTrip(t, c) + if r := px.Requests(); len(r) != 1 || r[0].AddrType != 4 || r[0].Host != "2001:db8::7" || r[0].Port != 102 { + t.Errorf("proxy saw %+v, want IPv6 2001:db8::7:102", r) + } +} + +func TestProxyHostNames(t *testing.T) { + target := echo(t) + // socks5h sends the name; socks5 resolves it here first. + for _, tc := range []struct { + scheme string + addrType byte + }{{"socks5h", 3}, {"socks5", 0}} { + px := socks5test.Start(t) + px.Forward = func(string) string { return target } + d, _ := New("", tc.scheme+"://"+px.Addr) + c, err := d(ctx2s(t), "tcp", "localhost:102") + if err != nil { + t.Fatalf("%s: %v", tc.scheme, err) + } + roundTrip(t, c) + r := px.Requests() + switch { + case len(r) != 1: + t.Errorf("%s: %d requests", tc.scheme, len(r)) + case tc.addrType == 3 && (r[0].AddrType != 3 || r[0].Host != "localhost"): + t.Errorf("%s: proxy saw %+v, want the name", tc.scheme, r[0]) + case tc.addrType == 0 && r[0].AddrType == 3: + t.Errorf("%s: proxy saw the name %+v, want a resolved IP", tc.scheme, r[0]) + } + } +} + +func TestProxyRefusals(t *testing.T) { + for _, tc := range []struct { + name string + setup func(*socks5test.Server) + check func(error) bool + }{ + {"connection refused", func(s *socks5test.Server) { s.Reply = 5 }, + func(err error) bool { + var re *ReplyError + return errors.As(err, &re) && re.Code == 5 && strings.Contains(err.Error(), "connection refused") + }}, + {"ruleset", func(s *socks5test.Server) { s.Reply = 2 }, + func(err error) bool { return strings.Contains(err.Error(), "not allowed by ruleset") }}, + {"unknown code", func(s *socks5test.Server) { s.Reply = 0x42 }, + func(err error) bool { return strings.Contains(err.Error(), "reply code 66") }}, + {"authentication required", func(s *socks5test.Server) { s.Method = 0xFF }, + func(err error) bool { return strings.Contains(err.Error(), "requires authentication") }}, + {"wrong version", func(s *socks5test.Server) { s.Version = 4 }, + func(err error) bool { return errors.Is(err, errProtocol) }}, + {"bad bound address type", func(s *socks5test.Server) { s.BoundType = 9 }, + func(err error) bool { return errors.Is(err, errProtocol) }}, + {"silent proxy times out", func(s *socks5test.Server) { s.Silent = true }, + func(err error) bool { return errors.Is(err, context.DeadlineExceeded) }}, + } { + t.Run(tc.name, func(t *testing.T) { + px := socks5test.Start(t) + tc.setup(px) + d, _ := New("", px.URL()) + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + c, err := d(ctx, "tcp", echo(t)) + if c != nil || err == nil || !tc.check(err) { + t.Fatalf("dial = %v, %v", c, err) + } + if !strings.Contains(err.Error(), "socks5 proxy "+px.Addr) { + t.Errorf("error %q does not name the proxy", err) + } + }) + } +} + +func TestProxyCancel(t *testing.T) { + px := socks5test.Start(t) + px.Silent = true + d, _ := New("", px.URL()) + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(50*time.Millisecond, cancel) + if _, err := d(ctx, "tcp", "127.0.0.1:102"); !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } +} + +func TestProxyErrors(t *testing.T) { + d, _ := New("", "socks5://127.0.0.1:1") + if _, err := d(ctx2s(t), "udp", "127.0.0.1:102"); err == nil || !strings.Contains(err.Error(), "not supported") { + t.Errorf("udp: %v", err) + } + if _, err := d(ctx2s(t), "tcp", "no-port"); err == nil { + t.Error("address without port: no error") + } + if _, err := d(ctx2s(t), "tcp", "127.0.0.1:70000"); err == nil || !strings.Contains(err.Error(), "invalid port") { + t.Errorf("bad port: %v", err) + } + long := strings.Repeat("a", 256) + ":102" + dh, _ := New("", "socks5h://127.0.0.1:1") + if _, err := dh(ctx2s(t), "tcp", long); err == nil || !strings.Contains(err.Error(), "too long") { + t.Errorf("long name: %v", err) + } + if _, err := d(ctx2s(t), "tcp", "name.invalid:102"); err == nil { + t.Error("unresolvable name with socks5: no error") + } + // Nothing listens on the proxy port. + if _, err := d(ctx2s(t), "tcp", "127.0.0.1:102"); err == nil || !strings.Contains(err.Error(), "socks5 proxy 127.0.0.1:1") { + t.Errorf("proxy down: %v", err) + } +} + +// handshake against a scripted peer on net.Pipe: each step's failure. +func TestHandshakeFailures(t *testing.T) { + req := []byte{5, 1, 0, 1, 127, 0, 0, 1, 0, 102} + for _, tc := range []struct { + name string + peer func(c net.Conn) + }{ + {"peer gone before greeting", func(c net.Conn) { _ = c.Close() }}, + {"peer gone before request", func(c net.Conn) { + _, _ = io.ReadFull(c, make([]byte, 3)) + _, _ = c.Write([]byte{5, 0}) + _ = c.Close() + }}, + {"peer gone before reply", func(c net.Conn) { + _, _ = io.ReadFull(c, make([]byte, 3)) + _, _ = c.Write([]byte{5, 0}) + _, _ = io.ReadFull(c, make([]byte, len(req))) + _ = c.Close() + }}, + {"reply with wrong version", func(c net.Conn) { + _, _ = io.ReadFull(c, make([]byte, 3)) + _, _ = c.Write([]byte{5, 0}) + _, _ = io.ReadFull(c, make([]byte, len(req))) + _, _ = c.Write([]byte{4, 0, 0, 1}) + }}, + {"name bound address cut", func(c net.Conn) { + _, _ = io.ReadFull(c, make([]byte, 3)) + _, _ = c.Write([]byte{5, 0}) + _, _ = io.ReadFull(c, make([]byte, len(req))) + _, _ = c.Write([]byte{5, 0, 0, 3}) + _ = c.Close() + }}, + } { + t.Run(tc.name, func(t *testing.T) { + a, b := net.Pipe() + defer func() { _ = a.Close() }() + go func() { defer func() { _ = b.Close() }(); tc.peer(b) }() + _ = a.SetDeadline(time.Now().Add(2 * time.Second)) + if err := handshake(a, req); err == nil { + t.Fatal("no error") + } + }) + } +} 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) + } +}