internal/checker: SOCKS5 primitives + tests
socks5Greeting/Auth/Connect/UDPAssociate per docs/superpowers/specs/
2026-05-01-checker-design.md. RFC 1928 + RFC 1929 wire bytes, raw
reply bytes returned on every error path for RawHex display, ctx
deadline applied via SetDeadline, ctx.Err() joined into error chain
on cancellation. Sentinel errors and ErrSocks5Reply{Code} for code
matching via errors.Is.
Tests: 22 subtests with fake net.Listen server, table-driven per
primitive (happy paths, REP codes, short reads, bad version,
oversize input rejection without I/O, ctx-cancel mid-read).
go test -race -cover passes at 89.0%, go vet clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newFakeSocks5Server starts a TCP listener on 127.0.0.1:0. On the first
|
||||
// accepted connection it reads up to 1024 bytes (enough for any of our
|
||||
// primitives' fixed-length frames in a single Write), then writes
|
||||
// scriptedReply, then closes the connection. The listener is closed by
|
||||
// t.Cleanup.
|
||||
func newFakeSocks5Server(t *testing.T, scriptedReply []byte) (addr string) {
|
||||
t.Helper()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err, "listen")
|
||||
|
||||
done := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
_ = ln.Close()
|
||||
<-done
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
buf := make([]byte, 1024)
|
||||
_, _ = conn.Read(buf)
|
||||
if len(scriptedReply) > 0 {
|
||||
_, _ = conn.Write(scriptedReply)
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
// dial connects to addr and registers t.Cleanup to close the conn.
|
||||
func dial(t *testing.T, addr string) net.Conn {
|
||||
t.Helper()
|
||||
conn, err := net.DialTimeout("tcp", addr, 1*time.Second)
|
||||
require.NoError(t, err, "dial")
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
func ctxShort(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
t.Cleanup(cancel)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestSocks5Greeting(t *testing.T) {
|
||||
t.Run("happy_no_auth", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
method, raw, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(0x00), method)
|
||||
assert.Equal(t, []byte{0x05, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("happy_userpass_selected", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0x02})
|
||||
conn := dial(t, addr)
|
||||
|
||||
method, raw, err := socks5Greeting(ctxShort(t), conn, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(0x02), method)
|
||||
assert.Equal(t, []byte{0x05, 0x02}, raw)
|
||||
})
|
||||
|
||||
t.Run("happy_no_auth_when_offered_both", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
method, raw, err := socks5Greeting(ctxShort(t), conn, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, byte(0x00), method)
|
||||
assert.Equal(t, []byte{0x05, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("rejected_all_auth", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05, 0xFF})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, raw, err := socks5Greeting(ctxShort(t), conn, true)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5RejectedAllAuth), "expected ErrSocks5RejectedAllAuth in chain, got: %v", err)
|
||||
assert.Equal(t, []byte{0x05, 0xFF}, raw)
|
||||
})
|
||||
|
||||
t.Run("bad_version", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x04, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, raw, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5BadVersion), "expected ErrSocks5BadVersion in chain, got: %v", err)
|
||||
assert.Equal(t, []byte{0x04, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x05})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, _, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply in chain, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("garbage_http_response", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte("HTTP/1.1 200 OK\r\n"))
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, raw, err := socks5Greeting(ctxShort(t), conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5BadVersion), "expected ErrSocks5BadVersion, got: %v", err)
|
||||
// First two bytes "HT" = 0x48 0x54
|
||||
assert.Equal(t, []byte{'H', 'T'}, raw)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocks5Auth(t *testing.T) {
|
||||
t.Run("happy", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x01, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte{0x01, 0x00}, raw)
|
||||
})
|
||||
|
||||
t.Run("rejected", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x01, 0x01})
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrAuthRejected), "expected ErrAuthRejected, got: %v", err)
|
||||
assert.Equal(t, []byte{0x01, 0x01}, raw)
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x01})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("bad_subneg_version", func(t *testing.T) {
|
||||
addr := newFakeSocks5Server(t, []byte{0x02, 0x00})
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Auth(ctxShort(t), conn, "user", "pass")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "auth subneg version", "want subneg version mention, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("login_too_long", func(t *testing.T) {
|
||||
// 300 chars, no I/O should occur
|
||||
conn := &noopConn{}
|
||||
long := strings.Repeat("a", 300)
|
||||
_, err := socks5Auth(context.Background(), conn, long, "pass")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrCredentialTooLong), "expected ErrCredentialTooLong, got: %v", err)
|
||||
assert.False(t, conn.touched, "no I/O should occur for over-long credential")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocks5Connect(t *testing.T) {
|
||||
t.Run("happy", func(t *testing.T) {
|
||||
// 05 00 00 01 00000000 0000
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("rep_connection_refused", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5Reply{Code: 0x05}), "expected ErrSocks5Reply{Code:5}, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("rep_cmd_not_supported", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
raw, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5Reply{Code: 0x07}), "expected ErrSocks5Reply{Code:7}, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
// And it should NOT match other codes:
|
||||
assert.False(t, errors.Is(err, ErrSocks5Reply{Code: 0x05}))
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("bad_version", func(t *testing.T) {
|
||||
reply := []byte{0x04, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, err := socks5Connect(ctxShort(t), conn, "example.com", 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrSocks5BadVersion), "expected ErrSocks5BadVersion, got: %v", err)
|
||||
})
|
||||
|
||||
t.Run("host_too_long", func(t *testing.T) {
|
||||
conn := &noopConn{}
|
||||
long := strings.Repeat("h", 300)
|
||||
_, err := socks5Connect(context.Background(), conn, long, 443)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrHostTooLong), "expected ErrHostTooLong, got: %v", err)
|
||||
assert.False(t, conn.touched, "no I/O should occur for over-long host")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocks5UDPAssociate(t *testing.T) {
|
||||
t.Run("happy_ipv4", func(t *testing.T) {
|
||||
// 05 00 00 01 7F000001 0539 -> 127.0.0.1:1337
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x01, 0x7F, 0x00, 0x00, 0x01, 0x05, 0x39}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
relay, raw, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, relay)
|
||||
assert.True(t, relay.IP.Equal(net.IPv4(127, 0, 0, 1)), "ip=%s", relay.IP)
|
||||
assert.Equal(t, 1337, relay.Port)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("rep_cmd_not_supported", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
relay, raw, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, relay)
|
||||
assert.True(t, errors.Is(err, ErrSocks5Reply{Code: 0x07}), "expected ErrSocks5Reply{Code:7}, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("atyp_ipv6_unsupported", func(t *testing.T) {
|
||||
// REP=0x00 (success), ATYP=0x04 (IPv6) — unsupported by us. We
|
||||
// only read 10 bytes total so the trailing IPv6 bytes are
|
||||
// implicitly ignored on the wire.
|
||||
reply := []byte{0x05, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
relay, raw, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, relay)
|
||||
assert.True(t, errors.Is(err, ErrUnsupportedRelayATYP), "expected ErrUnsupportedRelayATYP, got: %v", err)
|
||||
assert.Equal(t, reply, raw)
|
||||
})
|
||||
|
||||
t.Run("short_read", func(t *testing.T) {
|
||||
reply := []byte{0x05, 0x00, 0x00}
|
||||
addr := newFakeSocks5Server(t, reply)
|
||||
conn := dial(t, addr)
|
||||
|
||||
_, _, err := socks5UDPAssociate(ctxShort(t), conn)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrShortReply), "expected ErrShortReply, got: %v", err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSocks5GreetingCtxCancel verifies that a cancelled ctx surfaces
|
||||
// context.Canceled in the error chain even if the underlying I/O fails
|
||||
// with a deadline-style error.
|
||||
func TestSocks5GreetingCtxCancel(t *testing.T) {
|
||||
// Server that accepts but never replies — read will hang until ctx
|
||||
// deadline triggers SetDeadline-induced timeout.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
accepted := make(chan struct{})
|
||||
go func() {
|
||||
defer close(accepted)
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Hold the connection open without writing anything.
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
<-accepted // intentionally blocks; actually we close immediately on test end
|
||||
}()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", ln.Addr().String(), 1*time.Second)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, _, err = socks5Greeting(ctx, conn, false)
|
||||
require.Error(t, err)
|
||||
assert.True(t,
|
||||
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled),
|
||||
"expected ctx error in chain, got: %v", err)
|
||||
}
|
||||
|
||||
// noopConn is a minimal net.Conn that records whether any I/O was
|
||||
// attempted. Used to assert that pre-I/O validation rejects oversized
|
||||
// inputs without ever touching the wire.
|
||||
type noopConn struct {
|
||||
touched bool
|
||||
}
|
||||
|
||||
func (c *noopConn) Read(b []byte) (int, error) { c.touched = true; return 0, io.EOF }
|
||||
func (c *noopConn) Write(b []byte) (int, error) { c.touched = true; return len(b), nil }
|
||||
func (c *noopConn) Close() error { return nil }
|
||||
func (c *noopConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *noopConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
|
||||
func (c *noopConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *noopConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *noopConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
Reference in New Issue
Block a user