feat(socks5): add Tor RESOLVE/RESOLVE_PTR extension command support (gost#524)

Adds SOCKS5 handler and connector support for Tor-specific RESOLVE (0xF0)
and RESOLVE_PTR (0xF1) commands, enabling tools like torsocks and
tor-resolve to work through GOST when forwarding to a Tor SOCKS5 proxy.

- Define CmdResolve (0xF0) and CmdResolvePTR (0xF1) in x/internal/util/socks
- Add ContextWithSocks5Cmd/Socks5CmdFromContext for per-request command override
- Connector reads context command, sends it upstream, returns resolveConn
- resolveConn wraps net.Conn and exposes resolved addr via Metadata()
- Handler dispatches 0xF0/0xF1 to handleResolve/handleResolvePTR
- Gate behind enableTor metadata (tor=true), reject with CmdUnsupported by default
- 9 unit tests across ctx/, connector/, handler/ packages
This commit is contained in:
ginuerzh
2026-07-16 21:10:13 +08:00
parent f59eac64d9
commit 7b088f8f42
10 changed files with 343 additions and 1 deletions
+10 -1
View File
@@ -137,7 +137,12 @@ func (c *socks5Connector) Connect(ctx context.Context, conn net.Conn, network, a
addr.Host = "127.0.0.1"
}
req := gosocks5.NewRequest(gosocks5.CmdConnect, &addr)
cmd := gosocks5.CmdConnect
if c, ok := ctxvalue.Socks5CmdFromContext(ctx); ok {
cmd = c
}
req := gosocks5.NewRequest(cmd, &addr)
log.Trace(req)
if err := req.Write(conn); err != nil {
log.Error(err)
@@ -157,6 +162,10 @@ func (c *socks5Connector) Connect(ctx context.Context, conn net.Conn, network, a
return nil, err
}
if cmd == socks.CmdResolve || cmd == socks.CmdResolvePTR {
return &resolveConn{Conn: conn, resolvedAddr: reply.Addr}, nil
}
return conn, nil
}
+23
View File
@@ -0,0 +1,23 @@
package v5
import (
"net"
md "github.com/go-gost/core/metadata"
"github.com/go-gost/gosocks5"
xmd "github.com/go-gost/x/metadata"
)
// resolveConn wraps a raw net.Conn and carries the resolved address from a
// Tor RESOLVE/RESOLVE_PTR reply as metadata. It implements net.Conn (passthrough)
// and metadata.Metadatable so the handler can extract the resolved address.
type resolveConn struct {
net.Conn
resolvedAddr *gosocks5.Addr
}
func (c *resolveConn) Metadata() md.Metadata {
return xmd.NewMetadata(map[string]any{
"resolvedAddr": c.resolvedAddr,
})
}
+50
View File
@@ -0,0 +1,50 @@
package v5
import (
"net"
"testing"
"github.com/go-gost/gosocks5"
)
type dummyConn struct{ net.Conn }
func TestResolveConn_Metadata(t *testing.T) {
addr := &gosocks5.Addr{Type: gosocks5.AddrIPv4, Host: "93.184.216.34", Port: 0}
rc := &resolveConn{Conn: dummyConn{}, resolvedAddr: addr}
md := rc.Metadata()
if md == nil {
t.Fatal("Metadata() returned nil")
}
v := md.Get("resolvedAddr")
if v == nil {
t.Fatal("resolvedAddr key not present in metadata")
}
got, ok := v.(*gosocks5.Addr)
if !ok {
t.Fatalf("resolvedAddr is %T, want *gosocks5.Addr", v)
}
if got.Host != "93.184.216.34" {
t.Errorf("Host = %q, want %q", got.Host, "93.184.216.34")
}
if got.Type != gosocks5.AddrIPv4 {
t.Errorf("Type = %d, want %d", got.Type, gosocks5.AddrIPv4)
}
}
func TestResolveConn_Metadata_NilAddr(t *testing.T) {
rc := &resolveConn{Conn: dummyConn{}, resolvedAddr: nil}
md := rc.Metadata()
v := md.Get("resolvedAddr")
addr, ok := v.(*gosocks5.Addr)
if !ok {
t.Fatalf("resolvedAddr has type %T, want *gosocks5.Addr", v)
}
if addr != nil {
t.Errorf("resolvedAddr = %v, want nil", addr)
}
}
+16
View File
@@ -121,6 +121,22 @@ func PeerCertFromContext(ctx context.Context) *PeerCert {
return v
}
type socks5CmdKey struct{}
// ContextWithSocks5Cmd returns a copy of ctx that carries a SOCKS5 command
// override. Connectors use this to send a non-CONNECT command (e.g. Tor
// RESOLVE/RESOLVE_PTR) to the upstream proxy.
func ContextWithSocks5Cmd(ctx context.Context, cmd uint8) context.Context {
return context.WithValue(ctx, socks5CmdKey{}, cmd)
}
// Socks5CmdFromContext returns the SOCKS5 command override stored in ctx.
// The bool is false if no override is present.
func Socks5CmdFromContext(ctx context.Context) (uint8, bool) {
v, ok := ctx.Value(socks5CmdKey{}).(uint8)
return v, ok
}
// labelsKey saves the static service labels.
type labelsKey struct{}
+27
View File
@@ -261,6 +261,33 @@ func (c *testContextConn) Context() context.Context {
return c.ctx
}
func TestContextWithSocks5Cmd(t *testing.T) {
ctx := ContextWithSocks5Cmd(context.Background(), 0xF0)
cmd, ok := Socks5CmdFromContext(ctx)
if !ok {
t.Fatal("Socks5CmdFromContext() ok = false, want true")
}
if cmd != 0xF0 {
t.Errorf("Socks5CmdFromContext() = 0x%02X, want 0xF0", cmd)
}
}
func TestSocks5CmdFromContext_Empty(t *testing.T) {
_, ok := Socks5CmdFromContext(context.Background())
if ok {
t.Error("Socks5CmdFromContext(empty) ok = true, want false")
}
}
func TestSocks5CmdFromContext_WrongType(t *testing.T) {
ctx := context.WithValue(context.Background(), socks5CmdKey{}, "not-a-uint8")
_, ok := Socks5CmdFromContext(ctx)
if ok {
t.Error("Socks5CmdFromContext(wrong type) ok = true, want false")
}
}
func TestContextInterface(t *testing.T) {
ctx := context.Background()
ctx = ContextWithSid(ctx, Sid("iface-test"))
+4
View File
@@ -208,6 +208,10 @@ func (h *socks5Handler) Handle(ctx context.Context, conn net.Conn, opts ...handl
case socks.CmdUDPTun:
ro.Network = "udp"
return h.handleUDPTun(ctx, conn, networkAddr("udp", req.Addr), address, ro, log)
case socks.CmdResolve:
return h.handleResolve(ctx, conn, address, log)
case socks.CmdResolvePTR:
return h.handleResolvePTR(ctx, conn, address, log)
default:
err = ErrUnknownCmd
log.Error(err)
+2
View File
@@ -31,6 +31,7 @@ type metadata struct {
udpBindMax int
compatibilityMode bool
hash string
enableTor bool
muxCfg *mux.Config
observerPeriod time.Duration
@@ -67,6 +68,7 @@ func (h *socks5Handler) parseMetadata(md mdata.Metadata) (err error) {
h.md.compatibilityMode = mdutil.GetBool(md, "comp")
h.md.hash = mdutil.GetString(md, "hash")
h.md.enableTor = mdutil.GetBool(md, "tor", "enableTor", "socks5.tor")
h.md.muxCfg = &mux.Config{
Version: mdutil.GetInt(md, "mux.version"),
+80
View File
@@ -0,0 +1,80 @@
package v5
import (
"context"
"errors"
"net"
"github.com/go-gost/core/logger"
md "github.com/go-gost/core/metadata"
"github.com/go-gost/gosocks5"
xctx "github.com/go-gost/x/ctx"
"github.com/go-gost/x/internal/util/socks"
)
// handleResolve handles a Tor SOCKS5 RESOLVE command (0xF0).
// It resolves a hostname to an IP address via the upstream Tor SOCKS5 proxy.
func (h *socks5Handler) handleResolve(ctx context.Context, conn net.Conn, address string, log logger.Logger) error {
if !h.md.enableTor {
return h.rejectCmd(conn, log)
}
ctx = xctx.ContextWithSocks5Cmd(ctx, socks.CmdResolve)
return h.doResolve(ctx, conn, address, log)
}
// handleResolvePTR handles a Tor SOCKS5 RESOLVE_PTR command (0xF1).
// It performs a reverse DNS lookup (IP to hostname) via the upstream Tor SOCKS5 proxy.
func (h *socks5Handler) handleResolvePTR(ctx context.Context, conn net.Conn, address string, log logger.Logger) error {
if !h.md.enableTor {
return h.rejectCmd(conn, log)
}
ctx = xctx.ContextWithSocks5Cmd(ctx, socks.CmdResolvePTR)
return h.doResolve(ctx, conn, address, log)
}
// rejectCmd writes a CmdUnsupported reply and returns ErrUnknownCmd.
func (h *socks5Handler) rejectCmd(conn net.Conn, log logger.Logger) error {
log.Error(ErrUnknownCmd)
resp := gosocks5.NewReply(gosocks5.CmdUnsupported, nil)
log.Trace(resp)
resp.Write(conn)
return ErrUnknownCmd
}
func (h *socks5Handler) doResolve(ctx context.Context, conn net.Conn, address string, log logger.Logger) error {
cc, err := h.options.Router.Dial(ctx, "tcp", address)
if err != nil {
log.Error(err)
writeSocksReply(conn, gosocks5.Failure, nil)
return err
}
defer cc.Close()
// Extract resolved address from the chain connection's metadata.
var resolvedAddr *gosocks5.Addr
if mdConn, ok := cc.(md.Metadatable); ok {
if v := mdConn.Metadata().Get("resolvedAddr"); v != nil {
resolvedAddr, _ = v.(*gosocks5.Addr)
}
}
if resolvedAddr == nil {
err = errors.New("socks5: no resolved address in reply")
log.Error(err)
writeSocksReply(conn, gosocks5.Failure, nil)
return err
}
log.Debugf("resolved %s -> %s", address, resolvedAddr)
resp := gosocks5.NewReply(gosocks5.Succeeded, resolvedAddr)
log.Trace(resp)
resp.Write(conn)
return nil
}
func writeSocksReply(conn net.Conn, rep uint8, addr *gosocks5.Addr) error {
resp := gosocks5.NewReply(rep, addr)
return resp.Write(conn)
}
+124
View File
@@ -0,0 +1,124 @@
package v5
import (
"context"
"net"
"testing"
"github.com/go-gost/core/logger"
"github.com/go-gost/gosocks5"
xlogger "github.com/go-gost/x/logger"
)
func testLogger() logger.Logger {
return xlogger.Nop()
}
func TestWriteSocksReply(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
addr := &gosocks5.Addr{Type: gosocks5.AddrIPv4, Host: "127.0.0.1", Port: 9050}
done := make(chan error, 1)
go func() {
done <- writeSocksReply(server, gosocks5.Succeeded, addr)
}()
reply, err := gosocks5.ReadReply(client)
if err != nil {
t.Fatalf("ReadReply: %v", err)
}
if reply.Rep != gosocks5.Succeeded {
t.Errorf("Rep = %d, want %d", reply.Rep, gosocks5.Succeeded)
}
if reply.Addr == nil || reply.Addr.Host != "127.0.0.1" {
t.Errorf("Addr = %v, want 127.0.0.1", reply.Addr)
}
if err := <-done; err != nil {
t.Errorf("writeSocksReply error: %v", err)
}
}
func TestWriteSocksReply_CmdUnsupported(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
done := make(chan error, 1)
go func() {
done <- writeSocksReply(server, gosocks5.CmdUnsupported, nil)
}()
reply, err := gosocks5.ReadReply(client)
if err != nil {
t.Fatalf("ReadReply: %v", err)
}
if reply.Rep != gosocks5.CmdUnsupported {
t.Errorf("Rep = %d, want %d", reply.Rep, gosocks5.CmdUnsupported)
}
if err := <-done; err != nil {
t.Errorf("writeSocksReply error: %v", err)
}
}
func TestHandleResolve_TorDisabled(t *testing.T) {
h := &socks5Handler{
md: metadata{enableTor: false},
}
h.options.Logger = testLogger()
server, client := net.Pipe()
defer server.Close()
defer client.Close()
done := make(chan error, 1)
go func() {
done <- h.handleResolve(context.Background(), server, "example.com:0", testLogger())
}()
reply, err := gosocks5.ReadReply(client)
if err != nil {
t.Fatalf("ReadReply: %v", err)
}
if reply.Rep != gosocks5.CmdUnsupported {
t.Errorf("Rep = %d, want CmdUnsupported (%d)", reply.Rep, gosocks5.CmdUnsupported)
}
err = <-done
if err == nil {
t.Error("expected error when tor is disabled, got nil")
}
}
func TestHandleResolvePTR_TorDisabled(t *testing.T) {
h := &socks5Handler{
md: metadata{enableTor: false},
}
h.options.Logger = testLogger()
server, client := net.Pipe()
defer server.Close()
defer client.Close()
done := make(chan error, 1)
go func() {
done <- h.handleResolvePTR(context.Background(), server, "192.168.1.1:0", testLogger())
}()
reply, err := gosocks5.ReadReply(client)
if err != nil {
t.Fatalf("ReadReply: %v", err)
}
if reply.Rep != gosocks5.CmdUnsupported {
t.Errorf("Rep = %d, want CmdUnsupported (%d)", reply.Rep, gosocks5.CmdUnsupported)
}
err = <-done
if err == nil {
t.Error("expected error when tor is disabled, got nil")
}
}
+7
View File
@@ -10,6 +10,13 @@ const (
)
const (
// CmdResolve is a Tor SOCKS5 extension command (0xF0) for resolving
// a hostname to an IP address via Tor's DNS.
CmdResolve uint8 = 0xF0
// CmdResolvePTR is a Tor SOCKS5 extension command (0xF1) for reverse
// DNS lookup (IP to hostname) via Tor's DNS.
CmdResolvePTR uint8 = 0xF1
// CmdMuxBind is an extended SOCKS5 request CMD for
// multiplexing transport with the binding server.
CmdMuxBind uint8 = 0xF2