diff --git a/pkg/transport/direct_libc.go b/pkg/transport/direct_libc.go new file mode 100644 index 0000000..d9f9b95 --- /dev/null +++ b/pkg/transport/direct_libc.go @@ -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 +#include +#include +#include +#include +#include +#include + +// 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) +} diff --git a/pkg/transport/direct_portable.go b/pkg/transport/direct_portable.go new file mode 100644 index 0000000..63e81a1 --- /dev/null +++ b/pkg/transport/direct_portable.go @@ -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) +} diff --git a/pkg/transport/export_test.go b/pkg/transport/export_test.go new file mode 100644 index 0000000..6f07658 --- /dev/null +++ b/pkg/transport/export_test.go @@ -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) +} diff --git a/pkg/transport/proxy.go b/pkg/transport/proxy.go new file mode 100644 index 0000000..769e90c --- /dev/null +++ b/pkg/transport/proxy.go @@ -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) +} diff --git a/pkg/transport/proxy_test.go b/pkg/transport/proxy_test.go new file mode 100644 index 0000000..8be905b --- /dev/null +++ b/pkg/transport/proxy_test.go @@ -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) + } +} diff --git a/pkg/transport/socks5_server_test.go b/pkg/transport/socks5_server_test.go new file mode 100644 index 0000000..b2a1497 --- /dev/null +++ b/pkg/transport/socks5_server_test.go @@ -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 +} diff --git a/pkg/transport/tcp.go b/pkg/transport/tcp.go index 173bcdb..fed17d3 100644 --- a/pkg/transport/tcp.go +++ b/pkg/transport/tcp.go @@ -14,116 +14,44 @@ package transport -/* -#include -#include -#include -#include -#include -#include -#include - -// 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) }