mirror of
https://github.com/mandiant/gopacket
synced 2026-06-21 13:57:02 +00:00
transport: add SOCKS5 proxy support with UDP-under-proxy guard
Splits pkg/transport into a router (tcp.go) plus two platform-specific direct dialers: direct_libc.go preserves the existing cgo libc connect() path on Unix (so proxychains can still hook it), and direct_portable.go provides a pure-Go fallback for CGO_ENABLED=0 and Windows builds. Adds proxy.go with: - Configure(Options) / ConfigureProxy to wire a SOCKS5 URL (socks5 or socks5h), with ALL_PROXY / all_proxy env fallback read directly via os.Getenv (not proxy.FromEnvironment, whose sync.Once cache is not test-friendly). - DialContext routing through the configured proxy, or directDial. - DialUDP returning ErrUDPUnderProxy when proxied, rather than silently leaking UDP packets past the SOCKS5. - IsProxyConfigured and ProxyURL for callers that need to short-circuit features that can't be tunneled. - libcForwarder so the TCP leg to the SOCKS5 proxy itself still goes through libc, enabling proxychains -> gopacket -> -proxy chaining. Tests: - export_test.go exposes ResetForTest for test isolation (Configure panics on double-call, so each test resets package state). - socks5_server_test.go hand-rolls a minimal in-process SOCKS5 server (CONNECT + no-auth, ~130 LOC, no new deps). - proxy_test.go covers invalid scheme rejection, socks5/socks5h accept, ALL_PROXY env fallback, double-call panic, default state, DialUDP/DialContext UDP rejection, credential redaction, direct-path behavior when unconfigured, and an end-to-end round-trip through the in-process SOCKS5.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build cgo && !windows
|
||||
|
||||
package transport
|
||||
|
||||
/*
|
||||
#include <stdlib.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netdb.h>
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
|
||||
// libc_dial connects via libc's getaddrinfo + connect, which ARE hookable by LD_PRELOAD.
|
||||
// Returns the file descriptor on success, or -1 (getaddrinfo fail) / -2 (connect fail) on error.
|
||||
int libc_dial(const char *host, const char *port, int timeout_sec) {
|
||||
struct addrinfo hints, *res, *p;
|
||||
int sockfd;
|
||||
|
||||
memset(&hints, 0, sizeof(hints));
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
|
||||
int rv = getaddrinfo(host, port, &hints, &res);
|
||||
if (rv != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (p = res; p != NULL; p = p->ai_next) {
|
||||
sockfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
|
||||
if (sockfd == -1) continue;
|
||||
|
||||
// Set connect timeout via SO_SNDTIMEO (Linux honors this for connect())
|
||||
if (timeout_sec > 0) {
|
||||
struct timeval tv;
|
||||
tv.tv_sec = timeout_sec;
|
||||
tv.tv_usec = 0;
|
||||
setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
}
|
||||
|
||||
if (connect(sockfd, p->ai_addr, p->ai_addrlen) == -1) {
|
||||
close(sockfd);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
freeaddrinfo(res);
|
||||
|
||||
if (p == NULL) return -2;
|
||||
|
||||
// Clear the send timeout so it doesn't affect subsequent writes
|
||||
if (timeout_sec > 0) {
|
||||
struct timeval tv;
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = 0;
|
||||
setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
}
|
||||
|
||||
return sockfd;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// directDial opens a connection via libc connect(), bypassing any configured
|
||||
// proxy. The libc path is what LD_PRELOAD-based proxies like proxychains hook.
|
||||
// UDP falls through to net.Dial because libc_dial is wired for SOCK_STREAM only
|
||||
// and LD_PRELOAD proxies rarely handle UDP anyway.
|
||||
func directDial(network, address string, timeoutSec int) (net.Conn, error) {
|
||||
if strings.HasPrefix(network, "udp") {
|
||||
return net.Dial(network, address)
|
||||
}
|
||||
|
||||
host, port, err := splitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cHost := C.CString(host)
|
||||
cPort := C.CString(port)
|
||||
defer C.free(unsafe.Pointer(cHost))
|
||||
defer C.free(unsafe.Pointer(cPort))
|
||||
|
||||
fd := C.libc_dial(cHost, cPort, C.int(timeoutSec))
|
||||
if fd == -1 {
|
||||
return nil, fmt.Errorf("getaddrinfo failed for %s", address)
|
||||
}
|
||||
if fd == -2 {
|
||||
return nil, fmt.Errorf("connect failed for %s", address)
|
||||
}
|
||||
|
||||
f := os.NewFile(uintptr(fd), fmt.Sprintf("tcp:%s", address))
|
||||
conn, err := net.FileConn(f)
|
||||
f.Close() // FileConn dups the fd
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FileConn failed: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// directDialContext dials directly, honoring ctx's deadline for the connect timeout.
|
||||
func directDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
timeout := DefaultTimeout
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
if d := time.Until(dl); d > 0 {
|
||||
timeout = int(d.Seconds())
|
||||
if timeout < 1 {
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return directDial(network, address, timeout)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !cgo || windows
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// directDial opens a connection using the Go standard net.Dialer, bypassing
|
||||
// any configured proxy. This path is used on Windows and on CGO_ENABLED=0
|
||||
// builds. It does not interoperate with LD_PRELOAD proxies like proxychains.
|
||||
// Users on those platforms should use the -proxy flag instead.
|
||||
func directDial(network, address string, timeoutSec int) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
|
||||
return d.Dial(network, address)
|
||||
}
|
||||
|
||||
// directDialContext dials directly using ctx for timeout/cancellation.
|
||||
func directDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, address)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package transport
|
||||
|
||||
// ResetForTest clears package-level proxy state so a subsequent Configure
|
||||
// call won't panic on the one-shot guard. Only linked into test binaries.
|
||||
func ResetForTest() {
|
||||
proxyHolder.Store(nil)
|
||||
configured.Store(false)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// ErrUDPUnderProxy is returned by DialUDP when a proxy is configured. SOCKS5
|
||||
// UDP ASSOCIATE is rarely supported by proxies and servers, and silently
|
||||
// leaking UDP packets from the attacker host when a proxy is configured would
|
||||
// reveal the operator's real source IP. Callers should arrange for direct
|
||||
// operation, or skip the UDP-dependent feature under -proxy.
|
||||
var ErrUDPUnderProxy = errors.New("UDP disabled under -proxy; the underlying feature cannot be tunneled")
|
||||
|
||||
// Options holds runtime configuration for the transport layer.
|
||||
type Options struct {
|
||||
// Proxy, if non-empty, is a SOCKS5 URL that outbound TCP is routed through.
|
||||
// Accepted schemes: socks5, socks5h. When empty, the ALL_PROXY / all_proxy
|
||||
// environment variables are consulted.
|
||||
Proxy string
|
||||
}
|
||||
|
||||
// proxyHolder points to the configured proxy dialer, or nil for direct.
|
||||
// Stored atomically so Dial is lock-free on the hot path.
|
||||
var proxyHolder atomic.Pointer[dialerHolder]
|
||||
|
||||
type dialerHolder struct {
|
||||
cd proxy.ContextDialer
|
||||
url string
|
||||
}
|
||||
|
||||
var configured atomic.Bool
|
||||
|
||||
// Configure initializes the transport layer. Must be called exactly once,
|
||||
// typically from flags.Parse at tool startup. Panics on subsequent calls so a
|
||||
// misconfigured tool fails loudly rather than silently racing on package state.
|
||||
func Configure(opts Options) error {
|
||||
if !configured.CompareAndSwap(false, true) {
|
||||
panic("transport.Configure called more than once")
|
||||
}
|
||||
|
||||
// We intentionally don't use proxy.FromEnvironment here: it caches the env
|
||||
// value via sync.Once for the process lifetime, which breaks tests and
|
||||
// prevents consistent URL validation for env-supplied values.
|
||||
fromEnv := false
|
||||
if opts.Proxy == "" {
|
||||
envURL := os.Getenv("ALL_PROXY")
|
||||
if envURL == "" {
|
||||
envURL = os.Getenv("all_proxy")
|
||||
}
|
||||
if envURL == "" {
|
||||
return nil
|
||||
}
|
||||
opts.Proxy = envURL
|
||||
fromEnv = true
|
||||
}
|
||||
|
||||
u, err := url.Parse(opts.Proxy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("transport: invalid proxy URL %q: %v", opts.Proxy, err)
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "socks5", "socks5h":
|
||||
// x/net/proxy treats both identically (always sends hostname to the
|
||||
// proxy; server decides resolution). socks5h is the documented
|
||||
// recommendation because it mirrors proxychains' proxy_dns=on default.
|
||||
default:
|
||||
return fmt.Errorf("transport: unsupported proxy scheme %q (supported: socks5, socks5h)", u.Scheme)
|
||||
}
|
||||
|
||||
// Route the TCP connection to the SOCKS5 server through the libc dialer so
|
||||
// LD_PRELOAD-based proxies (proxychains) can still hook it, useful for
|
||||
// chaining: proxychains -> gopacket -> -proxy SOCKS5 -> target.
|
||||
d, err := proxy.FromURL(u, libcForwarder{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("transport: initialize proxy dialer for %q: %v", opts.Proxy, err)
|
||||
}
|
||||
cd, ok := d.(proxy.ContextDialer)
|
||||
if !ok {
|
||||
return fmt.Errorf("transport: proxy dialer %T does not implement ContextDialer", d)
|
||||
}
|
||||
urlLabel := u.Redacted()
|
||||
if fromEnv {
|
||||
urlLabel += " (from ALL_PROXY)"
|
||||
}
|
||||
proxyHolder.Store(&dialerHolder{cd: cd, url: urlLabel})
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsProxyConfigured reports whether a proxy is in effect. Callers that cannot
|
||||
// meaningfully operate through a proxy (UDP probes, local-IP discovery) should
|
||||
// consult this and short-circuit.
|
||||
func IsProxyConfigured() bool { return proxyHolder.Load() != nil }
|
||||
|
||||
// ProxyURL returns the configured proxy URL (redacted), or "" if none.
|
||||
func ProxyURL() string {
|
||||
if h := proxyHolder.Load(); h != nil {
|
||||
return h.url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// libcForwarder is the base proxy.Dialer used to reach the SOCKS5 server
|
||||
// itself. Goes through directDial so the TCP connect to the proxy is still
|
||||
// hookable by LD_PRELOAD.
|
||||
type libcForwarder struct{}
|
||||
|
||||
func (libcForwarder) Dial(network, address string) (net.Conn, error) {
|
||||
return directDial(network, address, DefaultTimeout)
|
||||
}
|
||||
|
||||
func (libcForwarder) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return directDialContext(ctx, network, address)
|
||||
}
|
||||
|
||||
// DialContext is a context-aware variant of Dial, suitable as an
|
||||
// http.Transport.DialContext. Routes through the configured proxy if set.
|
||||
// UDP under -proxy returns ErrUDPUnderProxy rather than letting a cryptic
|
||||
// "network not implemented" bubble up from the SOCKS5 layer.
|
||||
func DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
if h := proxyHolder.Load(); h != nil {
|
||||
if strings.HasPrefix(network, "udp") {
|
||||
return nil, ErrUDPUnderProxy
|
||||
}
|
||||
return h.cd.DialContext(ctx, network, address)
|
||||
}
|
||||
return directDialContext(ctx, network, address)
|
||||
}
|
||||
|
||||
// DialUDP opens a connected UDP socket to address. Returns ErrUDPUnderProxy
|
||||
// if a proxy is configured. SOCKS5 UDP ASSOCIATE is rarely supported and
|
||||
// silently bypassing the proxy under -proxy would reveal the operator's real
|
||||
// source IP.
|
||||
func DialUDP(address string) (*net.UDPConn, error) {
|
||||
if IsProxyConfigured() {
|
||||
return nil, ErrUDPUnderProxy
|
||||
}
|
||||
addr, err := net.ResolveUDPAddr("udp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.DialUDP("udp", nil, addr)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigureRejectsInvalidScheme(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
err := Configure(Options{Proxy: "http://127.0.0.1:8080"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported proxy scheme") {
|
||||
t.Fatalf("want unsupported scheme error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureRejectsMalformedURL(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
err := Configure(Options{Proxy: "::not a url::"})
|
||||
if err == nil {
|
||||
t.Fatalf("want error for malformed URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureAcceptsSocks5(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
if err := Configure(Options{Proxy: "socks5://127.0.0.1:1080"}); err != nil {
|
||||
t.Fatalf("socks5:// should be accepted: %v", err)
|
||||
}
|
||||
if !IsProxyConfigured() {
|
||||
t.Fatal("IsProxyConfigured should be true after successful Configure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureAcceptsSocks5h(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
if err := Configure(Options{Proxy: "socks5h://127.0.0.1:1080"}); err != nil {
|
||||
t.Fatalf("socks5h:// should be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureFromEnv(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
t.Setenv("ALL_PROXY", "socks5h://127.0.0.1:1080")
|
||||
if err := Configure(Options{Proxy: ""}); err != nil {
|
||||
t.Fatalf("env-based configure should succeed: %v", err)
|
||||
}
|
||||
if !IsProxyConfigured() {
|
||||
t.Fatal("ALL_PROXY should enable proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureDoubleCallPanics(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
if err := Configure(Options{}); err != nil {
|
||||
t.Fatalf("first Configure failed: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatal("second Configure should panic")
|
||||
}
|
||||
}()
|
||||
_ = Configure(Options{})
|
||||
}
|
||||
|
||||
func TestIsProxyConfiguredFalseByDefault(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
t.Setenv("ALL_PROXY", "")
|
||||
if err := Configure(Options{}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
if IsProxyConfigured() {
|
||||
t.Fatal("IsProxyConfigured should be false with no -proxy and no ALL_PROXY")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialUDPReturnsErrWhenProxied(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
if err := Configure(Options{Proxy: "socks5h://127.0.0.1:1080"}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
_, err := DialUDP("127.0.0.1:53")
|
||||
if !errors.Is(err, ErrUDPUnderProxy) {
|
||||
t.Fatalf("want ErrUDPUnderProxy, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialContextReturnsErrOnUDPUnderProxy(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
if err := Configure(Options{Proxy: "socks5h://127.0.0.1:1080"}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
_, err := DialContext(t.Context(), "udp", "127.0.0.1:53")
|
||||
if !errors.Is(err, ErrUDPUnderProxy) {
|
||||
t.Fatalf("want ErrUDPUnderProxy, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyURLRedactsCredentials(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
if err := Configure(Options{Proxy: "socks5h://user:s3cret@127.0.0.1:1080"}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
got := ProxyURL()
|
||||
if strings.Contains(got, "s3cret") {
|
||||
t.Fatalf("ProxyURL leaked password: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "user") || !strings.Contains(got, "127.0.0.1:1080") {
|
||||
t.Fatalf("ProxyURL unexpected shape: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyURLEmptyWhenUnconfigured(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
t.Setenv("ALL_PROXY", "")
|
||||
if err := Configure(Options{}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
if got := ProxyURL(); got != "" {
|
||||
t.Fatalf("ProxyURL should be empty when unconfigured, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialDirectWithNoProxy verifies that with no -proxy and no ALL_PROXY,
|
||||
// transport.Dial reaches a target directly (via directDial). Guards against
|
||||
// regressions where proxy plumbing accidentally intercepts the no-proxy path.
|
||||
func TestDialDirectWithNoProxy(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
t.Setenv("ALL_PROXY", "")
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer c.Close()
|
||||
_, _ = io.Copy(c, c)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := Configure(Options{}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
if IsProxyConfigured() {
|
||||
t.Fatal("no proxy should be configured")
|
||||
}
|
||||
|
||||
conn, err := Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("Dial direct: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
want := []byte("direct-path-ok")
|
||||
if _, err := conn.Write(want); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got := make([]byte, len(want))
|
||||
if _, err := io.ReadFull(conn, got); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("echo mismatch: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndToEndDialThroughSocks5 wires Configure to an in-process SOCKS5 server
|
||||
// and verifies a TCP round-trip flows through it.
|
||||
func TestEndToEndDialThroughSocks5(t *testing.T) {
|
||||
t.Cleanup(ResetForTest)
|
||||
|
||||
// Echo server, the "real" target.
|
||||
echoLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen echo: %v", err)
|
||||
}
|
||||
defer echoLn.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := echoLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer c.Close()
|
||||
_, _ = io.Copy(c, c)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
socks := newTestSOCKS5(t)
|
||||
|
||||
if err := Configure(Options{Proxy: "socks5h://" + socks.addr}); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
|
||||
conn, err := Dial("tcp", echoLn.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("Dial through proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
want := []byte("hello-over-socks5")
|
||||
if _, err := conn.Write(want); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got := make([]byte, len(want))
|
||||
if _, err := io.ReadFull(conn, got); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("echo mismatch: got %q want %q", got, want)
|
||||
}
|
||||
|
||||
if n := socks.connects.Load(); n != 1 {
|
||||
t.Fatalf("SOCKS5 server saw %d CONNECTs, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testSOCKS5 is a minimal SOCKS5 server for use in transport tests. It
|
||||
// supports only CONNECT with no authentication (method 0x00) and only IPv4
|
||||
// and DOMAIN address types. It is NOT a production SOCKS5 implementation.
|
||||
type testSOCKS5 struct {
|
||||
ln net.Listener
|
||||
addr string
|
||||
wg sync.WaitGroup
|
||||
connects atomic.Int32 // number of successful CONNECTs handled
|
||||
}
|
||||
|
||||
// newTestSOCKS5 starts a SOCKS5 server on 127.0.0.1 with an ephemeral port.
|
||||
// It terminates when t.Cleanup runs.
|
||||
func newTestSOCKS5(t *testing.T) *testSOCKS5 {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
s := &testSOCKS5{ln: ln, addr: ln.Addr().String()}
|
||||
s.wg.Add(1)
|
||||
go s.serve()
|
||||
t.Cleanup(func() {
|
||||
ln.Close()
|
||||
s.wg.Wait()
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *testSOCKS5) serve() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
c, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.handle(c)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *testSOCKS5) handle(client net.Conn) {
|
||||
defer client.Close()
|
||||
|
||||
// Greeting: VER | NMETHODS | METHODS...
|
||||
hdr := make([]byte, 2)
|
||||
if _, err := io.ReadFull(client, hdr); err != nil {
|
||||
return
|
||||
}
|
||||
if hdr[0] != 0x05 {
|
||||
return
|
||||
}
|
||||
methods := make([]byte, hdr[1])
|
||||
if _, err := io.ReadFull(client, methods); err != nil {
|
||||
return
|
||||
}
|
||||
// Accept only NO AUTH (0x00)
|
||||
if _, err := client.Write([]byte{0x05, 0x00}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Request: VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT
|
||||
req := make([]byte, 4)
|
||||
if _, err := io.ReadFull(client, req); err != nil {
|
||||
return
|
||||
}
|
||||
if req[0] != 0x05 || req[1] != 0x01 /* CONNECT */ {
|
||||
_, _ = client.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) // cmd not supported
|
||||
return
|
||||
}
|
||||
|
||||
var host string
|
||||
switch req[3] {
|
||||
case 0x01: // IPv4
|
||||
b := make([]byte, 4)
|
||||
if _, err := io.ReadFull(client, b); err != nil {
|
||||
return
|
||||
}
|
||||
host = net.IP(b).String()
|
||||
case 0x03: // DOMAIN
|
||||
lb := make([]byte, 1)
|
||||
if _, err := io.ReadFull(client, lb); err != nil {
|
||||
return
|
||||
}
|
||||
b := make([]byte, lb[0])
|
||||
if _, err := io.ReadFull(client, b); err != nil {
|
||||
return
|
||||
}
|
||||
host = string(b)
|
||||
default:
|
||||
_, _ = client.Write([]byte{0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) // atyp not supported
|
||||
return
|
||||
}
|
||||
|
||||
pb := make([]byte, 2)
|
||||
if _, err := io.ReadFull(client, pb); err != nil {
|
||||
return
|
||||
}
|
||||
port := binary.BigEndian.Uint16(pb)
|
||||
|
||||
target, err := net.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
|
||||
if err != nil {
|
||||
_, _ = client.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) // conn refused
|
||||
return
|
||||
}
|
||||
defer target.Close()
|
||||
|
||||
// Reply: VER | REP=0 | RSV | ATYP=1 | BND.ADDR=0 | BND.PORT=0
|
||||
if _, err := client.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
|
||||
return
|
||||
}
|
||||
s.connects.Add(1)
|
||||
|
||||
// Splice both directions
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { _, _ = io.Copy(target, client); done <- struct{}{} }()
|
||||
go func() { _, _ = io.Copy(client, target); done <- struct{}{} }()
|
||||
<-done
|
||||
}
|
||||
+23
-99
@@ -14,116 +14,44 @@
|
||||
|
||||
package transport
|
||||
|
||||
/*
|
||||
#include <stdlib.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netdb.h>
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
|
||||
// libc_dial connects via libc's getaddrinfo + connect, which ARE hookable by LD_PRELOAD.
|
||||
// Returns the file descriptor on success, or -1 (getaddrinfo fail) / -2 (connect fail) on error.
|
||||
int libc_dial(const char *host, const char *port, int timeout_sec) {
|
||||
struct addrinfo hints, *res, *p;
|
||||
int sockfd;
|
||||
|
||||
memset(&hints, 0, sizeof(hints));
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
|
||||
int rv = getaddrinfo(host, port, &hints, &res);
|
||||
if (rv != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (p = res; p != NULL; p = p->ai_next) {
|
||||
sockfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
|
||||
if (sockfd == -1) continue;
|
||||
|
||||
// Set connect timeout via SO_SNDTIMEO (Linux honors this for connect())
|
||||
if (timeout_sec > 0) {
|
||||
struct timeval tv;
|
||||
tv.tv_sec = timeout_sec;
|
||||
tv.tv_usec = 0;
|
||||
setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
}
|
||||
|
||||
if (connect(sockfd, p->ai_addr, p->ai_addrlen) == -1) {
|
||||
close(sockfd);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
freeaddrinfo(res);
|
||||
|
||||
if (p == NULL) return -2;
|
||||
|
||||
// Clear the send timeout so it doesn't affect subsequent writes
|
||||
if (timeout_sec > 0) {
|
||||
struct timeval tv;
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = 0;
|
||||
setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
}
|
||||
|
||||
return sockfd;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultTimeout is the default connect timeout in seconds.
|
||||
const DefaultTimeout = 30
|
||||
|
||||
// Dial connects to the address on the named network using libc's connect(),
|
||||
// which is hookable by LD_PRELOAD-based proxies like proxychains.
|
||||
// The address must be in "host:port" format.
|
||||
// Dial opens a TCP connection. Routes through the configured proxy if one was
|
||||
// set via Configure; otherwise uses the platform's direct dialer (libc
|
||||
// connect() on Unix/cgo, net.Dialer elsewhere).
|
||||
func Dial(network, address string) (net.Conn, error) {
|
||||
return DialTimeout(network, address, DefaultTimeout)
|
||||
}
|
||||
|
||||
// DialTimeout connects using libc's connect() with the given timeout in seconds.
|
||||
// DialTimeout is Dial with an explicit connect timeout in seconds.
|
||||
// A non-positive timeoutSec is normalized to DefaultTimeout so the direct and
|
||||
// proxy branches behave consistently.
|
||||
func DialTimeout(network, address string, timeoutSec int) (net.Conn, error) {
|
||||
host, port, err := splitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = DefaultTimeout
|
||||
}
|
||||
|
||||
cHost := C.CString(host)
|
||||
cPort := C.CString(port)
|
||||
defer C.free(unsafe.Pointer(cHost))
|
||||
defer C.free(unsafe.Pointer(cPort))
|
||||
|
||||
fd := C.libc_dial(cHost, cPort, C.int(timeoutSec))
|
||||
if fd == -1 {
|
||||
return nil, fmt.Errorf("getaddrinfo failed for %s", address)
|
||||
if h := proxyHolder.Load(); h != nil {
|
||||
if strings.HasPrefix(network, "udp") {
|
||||
return nil, ErrUDPUnderProxy
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
return h.cd.DialContext(ctx, network, address)
|
||||
}
|
||||
if fd == -2 {
|
||||
return nil, fmt.Errorf("connect failed for %s", address)
|
||||
}
|
||||
|
||||
// Convert C file descriptor to Go net.Conn
|
||||
f := os.NewFile(uintptr(fd), fmt.Sprintf("tcp:%s", address))
|
||||
conn, err := net.FileConn(f)
|
||||
f.Close() // FileConn dups the fd, so close the original
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FileConn failed: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
return directDial(network, address, timeoutSec)
|
||||
}
|
||||
|
||||
// DialTLS connects via libc then wraps the connection in TLS.
|
||||
// DialTLS opens a TCP connection (via proxy if configured) and wraps it in TLS.
|
||||
func DialTLS(network, address string, config *tls.Config) (*tls.Conn, error) {
|
||||
rawConn, err := Dial(network, address)
|
||||
if err != nil {
|
||||
@@ -142,24 +70,20 @@ func DialTLS(network, address string, config *tls.Config) (*tls.Conn, error) {
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// Dialer provides a way to establish connections via libc.
|
||||
// Dialer is a value-typed dialer suitable for APIs that expect a struct with a
|
||||
// Dial method (e.g. pkg/smb, pkg/ldap). Respects the configured proxy.
|
||||
type Dialer struct {
|
||||
TimeoutSec int
|
||||
}
|
||||
|
||||
// Dial establishes a TCP connection to the specified address using libc's connect().
|
||||
// Dial establishes a TCP connection to address. Routes through the proxy if configured.
|
||||
func (d *Dialer) Dial(network, address string) (net.Conn, error) {
|
||||
timeout := d.TimeoutSec
|
||||
if timeout == 0 {
|
||||
timeout = DefaultTimeout
|
||||
}
|
||||
return DialTimeout(network, address, timeout)
|
||||
return DialTimeout(network, address, d.TimeoutSec)
|
||||
}
|
||||
|
||||
func splitHostPort(address string) (host, port string, err error) {
|
||||
host, port, err = net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
// Try treating the whole thing as a host (no port)
|
||||
if !strings.Contains(address, ":") {
|
||||
return address, "", fmt.Errorf("missing port in address: %s", address)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user