internal/checker: SOCKS5 primitives + tests
Build / test (push) Failing after 31s
Build / build-windows (push) Has been skipped

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:
2026-05-01 15:46:33 +03:00
parent c83f942716
commit 52ce1e0aa7
4 changed files with 588 additions and 2 deletions
+219
View File
@@ -0,0 +1,219 @@
package checker
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"time"
)
// Sentinel errors returned by the SOCKS5 primitives.
var (
ErrSocks5BadVersion = errors.New("socks5: server returned wrong version")
ErrSocks5RejectedAllAuth = errors.New("socks5: server rejected all offered auth methods (0xFF)")
ErrAuthRejected = errors.New("socks5: user/pass authentication rejected")
ErrCredentialTooLong = errors.New("socks5: login or password longer than 255 bytes")
ErrHostTooLong = errors.New("socks5: target hostname longer than 255 bytes")
ErrUnsupportedRelayATYP = errors.New("socks5: udp associate replied with non-IPv4 ATYP")
ErrShortReply = errors.New("socks5: short server reply")
)
// ErrSocks5Reply wraps a non-zero REP code so callers can react to specific
// SOCKS5 reply codes (e.g. REP=0x07 = command not supported, REP=0x05 =
// connection refused).
type ErrSocks5Reply struct{ Code byte }
// Error implements the error interface.
func (e ErrSocks5Reply) Error() string {
return fmt.Sprintf("socks5: server replied with non-zero REP code 0x%02X", e.Code)
}
// Is reports whether target matches this reply error by Code.
func (e ErrSocks5Reply) Is(target error) bool {
t, ok := target.(ErrSocks5Reply)
if !ok {
return false
}
return t.Code == e.Code
}
// applyDeadline applies the deadline from ctx (if any) to conn. Returns a
// function to clear the deadline.
func applyDeadline(ctx context.Context, conn net.Conn) {
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
_ = conn.SetDeadline(time.Time{})
}
}
// joinCtxErr wraps err with ctx.Err() if ctx has been cancelled or expired,
// so that callers see context.Canceled / context.DeadlineExceeded in the
// error chain even when the underlying I/O reported a deadline-based error.
func joinCtxErr(ctx context.Context, err error) error {
if err == nil {
return nil
}
if cerr := ctx.Err(); cerr != nil {
return errors.Join(err, cerr)
}
return err
}
// socks5Greeting performs the RFC 1928 client greeting on conn.
// useAuth=true sends "05 02 00 02" (offer no-auth and user/pass);
// useAuth=false sends "05 01 00" (offer no-auth only).
func socks5Greeting(ctx context.Context, conn net.Conn, useAuth bool) (method byte, rawReply []byte, err error) {
applyDeadline(ctx, conn)
var greet []byte
if useAuth {
greet = []byte{0x05, 0x02, 0x00, 0x02}
} else {
greet = []byte{0x05, 0x01, 0x00}
}
if _, werr := conn.Write(greet); werr != nil {
return 0, nil, joinCtxErr(ctx, fmt.Errorf("socks5 greeting: write: %w", werr))
}
reply := make([]byte, 2)
n, rerr := io.ReadFull(conn, reply)
if rerr != nil {
partial := reply[:n]
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
return 0, partial, joinCtxErr(ctx, fmt.Errorf("socks5 greeting: %w (raw=%x)", ErrShortReply, partial))
}
return 0, partial, joinCtxErr(ctx, fmt.Errorf("socks5 greeting: read: %w (raw=%x)", rerr, partial))
}
if reply[0] != 0x05 {
return 0, reply, fmt.Errorf("socks5 greeting: %w (raw=%x)", ErrSocks5BadVersion, reply)
}
if reply[1] == 0xFF {
return reply[1], reply, fmt.Errorf("socks5 greeting: %w (raw=%x)", ErrSocks5RejectedAllAuth, reply)
}
return reply[1], reply, nil
}
// socks5Auth performs RFC 1929 user/pass sub-negotiation on conn,
// after greeting selected method 0x02.
func socks5Auth(ctx context.Context, conn net.Conn, login, password string) (rawReply []byte, err error) {
if len(login) > 255 || len(password) > 255 {
return nil, ErrCredentialTooLong
}
applyDeadline(ctx, conn)
buf := make([]byte, 0, 3+len(login)+len(password))
buf = append(buf, 0x01) // VER
buf = append(buf, byte(len(login))) // ULEN
buf = append(buf, []byte(login)...) // UNAME
buf = append(buf, byte(len(password)))
buf = append(buf, []byte(password)...)
if _, werr := conn.Write(buf); werr != nil {
return nil, joinCtxErr(ctx, fmt.Errorf("socks5 auth: write: %w", werr))
}
reply := make([]byte, 2)
n, rerr := io.ReadFull(conn, reply)
if rerr != nil {
partial := reply[:n]
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 auth: %w (raw=%x)", ErrShortReply, partial))
}
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 auth: read: %w (raw=%x)", rerr, partial))
}
if reply[0] != 0x01 {
return reply, fmt.Errorf("socks5 auth: auth subneg version mismatch: got 0x%02X want 0x01 (raw=%x)", reply[0], reply)
}
if reply[1] != 0x00 {
return reply, fmt.Errorf("socks5 auth: %w (raw=%x)", ErrAuthRejected, reply)
}
return reply, nil
}
// socks5Connect performs SOCKS5 CONNECT (CMD=01) to host:port using
// ATYP=03 (domain name).
func socks5Connect(ctx context.Context, conn net.Conn, host string, port uint16) (rawReply []byte, err error) {
if len(host) > 255 {
return nil, ErrHostTooLong
}
applyDeadline(ctx, conn)
// VER=05 CMD=01 RSV=00 ATYP=03 LEN host port
req := make([]byte, 0, 7+len(host))
req = append(req, 0x05, 0x01, 0x00, 0x03)
req = append(req, byte(len(host)))
req = append(req, []byte(host)...)
var portBuf [2]byte
binary.BigEndian.PutUint16(portBuf[:], port)
req = append(req, portBuf[:]...)
if _, werr := conn.Write(req); werr != nil {
return nil, joinCtxErr(ctx, fmt.Errorf("socks5 connect: write: %w", werr))
}
// We always read 10 bytes (assuming ATYP=01 IPv4 reply, the most
// common case from real proxies). Parsing variable-length BND is
// out of scope for the diagnostic.
reply := make([]byte, 10)
n, rerr := io.ReadFull(conn, reply)
if rerr != nil {
partial := reply[:n]
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 connect: %w (raw=%x)", ErrShortReply, partial))
}
return partial, joinCtxErr(ctx, fmt.Errorf("socks5 connect: read: %w (raw=%x)", rerr, partial))
}
if reply[0] != 0x05 {
return reply, fmt.Errorf("socks5 connect: %w (raw=%x)", ErrSocks5BadVersion, reply)
}
if reply[1] != 0x00 {
return reply, fmt.Errorf("socks5 connect: %w (raw=%x)", ErrSocks5Reply{Code: reply[1]}, reply)
}
return reply, nil
}
// socks5UDPAssociate performs SOCKS5 UDP ASSOCIATE (CMD=03) on conn.
func socks5UDPAssociate(ctx context.Context, conn net.Conn) (relay *net.UDPAddr, rawReply []byte, err error) {
applyDeadline(ctx, conn)
// VER=05 CMD=03 RSV=00 ATYP=01 DST.ADDR=0.0.0.0 DST.PORT=0
req := []byte{0x05, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
if _, werr := conn.Write(req); werr != nil {
return nil, nil, joinCtxErr(ctx, fmt.Errorf("socks5 udp-associate: write: %w", werr))
}
reply := make([]byte, 10)
n, rerr := io.ReadFull(conn, reply)
if rerr != nil {
partial := reply[:n]
if errors.Is(rerr, io.ErrUnexpectedEOF) || errors.Is(rerr, io.EOF) {
return nil, partial, joinCtxErr(ctx, fmt.Errorf("socks5 udp-associate: %w (raw=%x)", ErrShortReply, partial))
}
return nil, partial, joinCtxErr(ctx, fmt.Errorf("socks5 udp-associate: read: %w (raw=%x)", rerr, partial))
}
if reply[0] != 0x05 {
return nil, reply, fmt.Errorf("socks5 udp-associate: %w (raw=%x)", ErrSocks5BadVersion, reply)
}
if reply[1] != 0x00 {
return nil, reply, fmt.Errorf("socks5 udp-associate: %w (raw=%x)", ErrSocks5Reply{Code: reply[1]}, reply)
}
if reply[3] != 0x01 {
return nil, reply, fmt.Errorf("socks5 udp-associate: %w (atyp=0x%02X raw=%x)", ErrUnsupportedRelayATYP, reply[3], reply)
}
ip := net.IPv4(reply[4], reply[5], reply[6], reply[7])
port := binary.BigEndian.Uint16(reply[8:10])
relay = &net.UDPAddr{IP: ip, Port: int(port)}
return relay, reply, nil
}