Client SOCKS5 CONNECT Implementation Plan
Client SOCKS5 CONNECT Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a loopback-safe local SOCKS5 CONNECT proxy to tunnelmesh-client that dynamically opens existing Agent-side TCP streams and optionally requires RFC 1929 username/password authentication.
Architecture: Add proper SOCKS5 method/request parsing and response encoding to internal/proxy, then implement a SOCKS5Forward local listener in internal/client that maps each CONNECT to an existing logical TCP stream. Wire the listener to a new forward socks5 CLI command while leaving authorization and target validation on the Server/Agent side.
Tech Stack: Go standard library, existing Client WebSocket session, existing StreamOpener abstraction, Cobra CLI.
Spec: docs/superpowers/specs/2026-09-09-client-socks5-design.md
Global Constraints
- Do not commit, push, merge, or create a remote PR without explicit user authorization.
- TDD is mandatory: write the failing test first, verify red, implement minimally, verify green.
- Support SOCKS5 CONNECT only; do not implement BIND or UDP ASSOCIATE.
- Default auth mode accepts SOCKS5 method
0x00only; password mode accepts method0x02only. - Default listener is loopback; non-loopback requires explicit
--allow-remoteand--auth password. - SOCKS5 credentials are read only from
TUNNELMESH_SOCKS5_USERNAMEandTUNNELMESH_SOCKS5_PASSWORD. - Do not resolve SOCKS5 domain targets locally.
- Internal stream protocol remains
"tcp"so existing token scope and Agent policy semantics are unchanged. - Public Server ingress remains HTTP/HTTPS/WebSocket only.
- Do not log target hosts, ports, tokens, or handshake bytes.
- Full verification must include
go test ./... -count=1,go test -race ./...,go vet ./..., andgit diff --check.
Task 1: SOCKS5 Handshake Parser and Replies
Files:
- Modify:
internal/proxy/handshake.go - Test:
internal/proxy/handshake_test.go
Interfaces:
- Produces:
type SOCKS5Reply byte
const (
SOCKS5ReplySucceeded SOCKS5Reply = 0x00
SOCKS5ReplyGeneralFailure SOCKS5Reply = 0x01
SOCKS5ReplyCommandUnsupported SOCKS5Reply = 0x07
)
func ReadSOCKS5Methods(r io.Reader) ([]byte, error)
func SupportsSOCKS5NoAuth(methods []byte) bool
func SupportsSOCKS5UsernamePassword(methods []byte) bool
func ReadSOCKS5UsernamePassword(r io.Reader) (SOCKS5Credentials, error)
func EncodeSOCKS5UsernamePasswordReply(success bool) []byte
func ReadSOCKS5Request(r io.Reader) (SOCKS5Request, error)
func EncodeSOCKS5MethodSelection(method byte) []byte
func EncodeSOCKS5Reply(reply SOCKS5Reply) []byte
ReadSOCKS5Request must return the parsed command even when it is not CONNECT so the listener can return 0x07.
- Step 1: Write failing parser tests
Add these tests to internal/proxy/handshake_test.go:
func TestReadSOCKS5Methods(t *testing.T) {
methods, err := proxy.ReadSOCKS5Methods(bytes.NewReader([]byte{5, 2, 0, 2}))
if err != nil || len(methods) != 2 || methods[0] != 0 || methods[1] != 2 {
t.Fatalf("methods=%v err=%v", methods, err)
}
if _, err := proxy.ReadSOCKS5Methods(bytes.NewReader([]byte{5, 0})); err == nil {
t.Fatal("empty method list was accepted")
}
}
func TestSOCKS5NoAuthSelection(t *testing.T) {
if !proxy.SupportsSOCKS5NoAuth([]byte{1, 0, 2}) {
t.Fatal("no-auth method was not detected")
}
if proxy.SupportsSOCKS5NoAuth([]byte{1, 2}) {
t.Fatal("no-auth was incorrectly detected")
}
if got := proxy.EncodeSOCKS5MethodSelection(0xff); !bytes.Equal(got, []byte{5, 0xff}) {
t.Fatalf("method selection=%x", got)
}
}
func TestSOCKS5UsernamePasswordNegotiationAndAuth(t *testing.T) {
if !proxy.SupportsSOCKS5UsernamePassword([]byte{0, 2}) {
t.Fatal("username/password method was not detected")
}
raw := append([]byte{1, 5}, []byte("alice")...)
raw = append(raw, 6)
raw = append(raw, []byte("secret")...)
credentials, err := proxy.ReadSOCKS5UsernamePassword(bytes.NewReader(raw))
if err != nil || credentials.Username != "alice" || credentials.Password != "secret" {
t.Fatalf("credentials=%+v err=%v", credentials, err)
}
if got := proxy.EncodeSOCKS5UsernamePasswordReply(true); !bytes.Equal(got, []byte{1, 0}) {
t.Fatalf("success reply=%x", got)
}
if got := proxy.EncodeSOCKS5UsernamePasswordReply(false); !bytes.Equal(got, []byte{1, 1}) {
t.Fatalf("failure reply=%x", got)
}
}
func TestReadSOCKS5RequestSupportsAddressTypes(t *testing.T) {
requests := []struct {
name string
raw []byte
host string
port int
}{
{"ipv4", []byte{5, 1, 0, 1, 127, 0, 0, 1, 0x1f, 0x90}, "127.0.0.1", 8080},
{"domain", append(append([]byte{5, 1, 0, 3, 11}, []byte("example.com")...), 0, 80), "example.com", 80},
{"ipv6", []byte{5, 1, 0, 4, 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0x00, 0x53, 0x00, 0x50}, "2001:db8::53", 80},
}
for _, test := range requests {
t.Run(test.name, func(t *testing.T) {
request, err := proxy.ReadSOCKS5Request(bytes.NewReader(test.raw))
if err != nil || request.Command != proxy.SOCKS5Connect || request.Host != test.host || request.Port != test.port {
t.Fatalf("request=%+v err=%v", request, err)
}
})
}
}
func TestReadSOCKS5RequestKeepsUnsupportedCommand(t *testing.T) {
request, err := proxy.ReadSOCKS5Request(bytes.NewReader([]byte{5, 3, 0, 1, 127, 0, 0, 1, 0, 80}))
if err != nil || request.Command == proxy.SOCKS5Connect || request.Host != "127.0.0.1" || request.Port != 80 {
t.Fatalf("request=%+v err=%v", request, err)
}
}
func TestEncodeSOCKS5Reply(t *testing.T) {
want := []byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0}
if got := proxy.EncodeSOCKS5Reply(proxy.SOCKS5ReplySucceeded); !bytes.Equal(got, want) {
t.Fatalf("reply=%x want=%x", got, want)
}
}
- Step 2: Verify RED
Run:
go test ./internal/proxy -run 'SOCKS5' -count=1
Expected: compile failure because the new parser functions do not exist.
- Step 3: Implement parser and replies
In internal/proxy/handshake.go:
- Import
io. - Add
SOCKS5Replyconstants. - Read exactly
2 + NMETHODSbytes for method negotiation. - Parse RFC 1929 version, bounded username/password lengths, and encode its two-byte result.
- Read the fixed request header, then address bytes by
ATYP. - Validate version, reserved byte, address length, hostname length, and port.
- Return unsupported commands as parsed requests rather than errors.
- Encode method selection and the fixed success/failure reply.
Keep the existing ParseSOCKS5Connect function for compatibility with its current tests.
- Step 4: Verify GREEN
go test ./internal/proxy -count=1
Task 2: Client SOCKS5 Forward Listener
Files:
- Create:
internal/client/socks5_forward.go - Test:
internal/client/socks5_forward_test.go
Interfaces:
- Consumes:
type StreamOpener interface {
OpenStream(context.Context, StreamRequest) (io.ReadWriteCloser, error)
}
- Produces:
type SOCKS5ForwardConfig struct {
ListenAddr string
AgentID string
ConnectTimeout time.Duration
HandshakeTimeout time.Duration
AllowRemote bool
AuthMode SOCKS5AuthMode
Username string
Password string
}
type SOCKS5Forward struct{}
func NewSOCKS5Forward(opener StreamOpener, cfg SOCKS5ForwardConfig) (*SOCKS5Forward, error)
func (f *SOCKS5Forward) Start(ctx context.Context) error
func (f *SOCKS5Forward) Addr() net.Addr
func (f *SOCKS5Forward) Close() error
- Step 1: Write failing listener tests
Create internal/client/socks5_forward_test.go with tests for:
func TestSOCKS5ForwardConnectsDynamicTCPTarget(t *testing.T)
func TestSOCKS5ForwardPassesDomainWithoutLocalResolution(t *testing.T)
func TestSOCKS5ForwardRejectsUnsupportedMethodWithoutOpeningStream(t *testing.T)
func TestSOCKS5ForwardRejectsUnsupportedCommandWithoutOpeningStream(t *testing.T)
func TestSOCKS5ForwardRejectsNonLoopbackWithoutExplicitAllowRemote(t *testing.T)
func TestSOCKS5ForwardRequiresPasswordAuthForNonLoopback(t *testing.T)
func TestSOCKS5ForwardUsernamePasswordAuthSucceeds(t *testing.T)
func TestSOCKS5ForwardUsernamePasswordAuthFailsWithoutOpeningStream(t *testing.T)
func TestSOCKS5ForwardClosesActiveConnections(t *testing.T)
The main positive test must use a real TCP connection and this handshake:
conn.Write([]byte{5, 1, 0}) // offer NO AUTH
io.ReadFull(conn, make([]byte, 2)) // expect 05 00
conn.Write([]byte{5, 1, 0, 3, 11}) // CONNECT example.com
conn.Write([]byte("example.com"))
conn.Write([]byte{0, 80}) // port 80
io.ReadFull(conn, make([]byte, 10)) // expect success reply
conn.Write([]byte("request"))
The fake opener must assert:
StreamRequest{
AgentID: "agent-a",
Protocol: "tcp",
TargetHost: "example.com",
TargetPort: 80,
}
- Step 2: Verify RED
go test ./internal/client -run 'SOCKS5Forward' -count=1
Expected: compile failure because socks5_forward.go does not exist.
- Step 3: Implement the listener
Implement:
func NewSOCKS5Forward(opener StreamOpener, cfg SOCKS5ForwardConfig) (*SOCKS5Forward, error)
Validation:
openermust not be nil.ListenAddrandAgentIDmust be non-empty.- If
AllowRemoteis false, listen host must belocalhost,127.0.0.1,::1, or another127.x.x.xaddress. - Empty listen host (
:1080) is non-loopback and rejected. AuthModemust benoneorpassword; the zero value isnone.- Password mode requires both a username and password.
- Username and password must each be at most 255 bytes.
- Non-loopback listening requires both
AllowRemote=trueandAuthMode=password.
Connection handling:
- Track accepted connections in a map.
- Set a handshake deadline; default to 10 seconds.
- Negotiate only no-auth in
nonemode, or method0x02in password mode. - In password mode, read RFC 1929 credentials and verify them with a constant-time comparison.
- Read one SOCKS5 request.
- Return
0x07for commands other than CONNECT. - Open an existing logical stream with
Protocol: "tcp". - Return
0x01if the stream cannot open. - Return
0x00after the stream opens. - Clear the handshake deadline and bridge the local connection with the logical stream.
- Close both connections when either side finishes.
- Step 4: Verify GREEN
go test ./internal/client -count=1
Task 3: CLI forward socks5
Files:
- Modify:
internal/cli/root.go - Test:
internal/cli/client_runtime_test.go - Test:
internal/cli/root_test.go
Interfaces:
- Produces command:
tunnelmesh-client forward socks5 \
--listen 127.0.0.1:1080 \
--agent agent-devbox \
[--auth none|password] \
[--allow-remote]
- Step 1: Write failing CLI tests
Add:
func TestClientRootExposesSOCKS5Forward(t *testing.T) {
client := cli.NewClientRoot()
forward := findCommand(client, "forward")
socks5 := findCommand(forward, "socks5")
if socks5 == nil {
t.Fatal("client forward command missing socks5")
}
for _, flag := range []string{"listen", "agent", "auth", "allow-remote"} {
if socks5.Flag(flag) == nil {
t.Fatalf("socks5 command missing --%s", flag)
}
}
}
Extend the authenticated-session CLI test with a SOCKS5 case that asserts:
runClientWebSocketreceives the configured server URL and token.- The command starts
client.NewSOCKS5Forward. - Missing
--agentfails. - Non-loopback without
--allow-remotefails. - Non-loopback with
--allow-remotebut without password auth fails. - Non-loopback with
--allow-remote,--auth password, and both environment variables reaches the authenticated session path. -
Password mode with a missing environment variable fails before connecting to the Server.
- Step 2: Verify RED
go test ./internal/cli -run 'SOCKS5' -count=1
Expected: failure because the command does not exist.
- Step 3: Implement the CLI command
Add clientSOCKS5ForwardCommand(opts *rootOptions) *cobra.Command.
Flags:
--listen, default127.0.0.1:0--agent, required at runtime--auth, defaultnone, allowed valuesnoneandpassword--allow-remote, default false
Credential inputs:
- Read
TUNNELMESH_SOCKS5_USERNAMEandTUNNELMESH_SOCKS5_PASSWORDwithos.Getenv. - Do not add username/password CLI flags.
- Do not log either value.
Runtime checks:
- Require
client.server_urlandclient.token. - Require
--agent. - Construct
client.SOCKS5ForwardConfig. - Start the forward inside
runClientWebSocket. - Print the actual listener address when
--listenuses port zero.
Register it with:
forward.AddCommand(
clientForwardCommand(opts, "tcp"),
clientForwardCommand(opts, "udp"),
clientForwardCommand(opts, "http"),
clientSOCKS5ForwardCommand(opts),
)
- Step 4: Verify GREEN
go test ./internal/cli -count=1
Task 4: Documentation and PR Record
Files:
- Modify:
docs/user-guide/client.md - Modify:
docs/operations/config-examples.md - Modify:
docs/protocol/proxy-modules.md -
Create:
docs/pull-requests/2026-09-09-client-socks5.md - Step 1: Write documentation
Document:
- Command example.
- Browser and
curl --socks5-hostnameusage. - CONNECT-only behavior.
- IPv4, IPv6, and domain target support.
- Loopback default and explicit
--allow-remote. --auth password, RFC 1929 behavior, environment-variable credential injection, and the requirement that non-loopback listening uses password auth.- No local DNS resolution; domain is resolved on the Agent side.
- Existing token/policy/CIDR/port authorization.
-
Explicitly state that UDP ASSOCIATE, BIND, GSSAPI auth, and public Server SOCKS5 ingress are not supported.
- Step 2: Create PR record
Create docs/pull-requests/2026-09-09-client-socks5.md with:
- Summary.
- Security model.
- Compatibility and limitations.
- Verification commands and results.
- Rollback: stop the new client command; no schema or protocol migration is required.
Task 5: Full Verification
- Run:
go test ./... -count=1
go test -race ./...
go vet ./...
git diff --check
- Re-read the design and this plan.
- Confirm every goal and non-goal is satisfied or explicitly deferred.
- Do not commit or push without explicit user authorization.