feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// bindToInterface returns a Control function that binds connections
|
||||
// to the specified network interface using SO_BINDTODEVICE.
|
||||
func bindToInterface(iface string) func(string, string, syscall.RawConn) error {
|
||||
return func(network, address string, c syscall.RawConn) error {
|
||||
var err error
|
||||
c.Control(func(fd uintptr) {
|
||||
err = syscall.BindToDevice(int(fd), iface)
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure net import is used
|
||||
var _ net.Conn
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !linux
|
||||
// +build !linux
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// bindToInterface returns nil on non-Linux platforms
|
||||
// since SO_BINDTODEVICE is a Linux-specific socket option.
|
||||
func bindToInterface(_ string) func(string, string, syscall.RawConn) error { return nil }
|
||||
|
||||
var _ net.Conn
|
||||
@@ -0,0 +1,285 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DialerConfig configures the dialer
|
||||
type DialerConfig struct {
|
||||
// Timeout for total connection
|
||||
Timeout time.Duration
|
||||
|
||||
// Timeout for IPv6 attempt before fallback to IPv4
|
||||
IPv6Timeout time.Duration
|
||||
|
||||
// Enable IPv4 fallback
|
||||
IPv4Fallback bool
|
||||
|
||||
// Debug mode for logging
|
||||
Debug bool
|
||||
|
||||
// BindInterface specifies the network interface to bind to
|
||||
BindInterface string
|
||||
|
||||
// BlockPrivateIPs blocks connections to private/internal IP ranges
|
||||
BlockPrivateIPs bool
|
||||
|
||||
// Callback for reporting used IP version
|
||||
OnConnect func(ip string, version int)
|
||||
}
|
||||
|
||||
// DefaultDialerConfig returns the default configuration
|
||||
func DefaultDialerConfig() *DialerConfig {
|
||||
return &DialerConfig{
|
||||
Timeout: 30 * time.Second,
|
||||
IPv6Timeout: 5 * time.Second,
|
||||
IPv4Fallback: true,
|
||||
Debug: false,
|
||||
BlockPrivateIPs: true, // SSRF protection on by default
|
||||
}
|
||||
}
|
||||
|
||||
// Dialer is an IPv6-first dialer with fallback
|
||||
type Dialer struct {
|
||||
config *DialerConfig
|
||||
resolver *net.Resolver
|
||||
dialer *net.Dialer
|
||||
}
|
||||
|
||||
// NewDialer creates a new dialer
|
||||
func NewDialer(cfg *DialerConfig) *Dialer {
|
||||
if cfg == nil {
|
||||
cfg = DefaultDialerConfig()
|
||||
}
|
||||
|
||||
d := &Dialer{
|
||||
config: cfg,
|
||||
resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dial := net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
DualStack: true,
|
||||
}
|
||||
return dial.DialContext(ctx, network, address)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Configure bind interface if specified
|
||||
if cfg.BindInterface != "" {
|
||||
d.dialer = &net.Dialer{
|
||||
Timeout: cfg.Timeout,
|
||||
DualStack: false,
|
||||
Control: bindToInterface(cfg.BindInterface),
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// DialContext connects to an address with IPv6-first strategy
|
||||
func (d *Dialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address: %w", err)
|
||||
}
|
||||
|
||||
// Resolve all IP addresses
|
||||
ips, err := d.resolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dns lookup failed: %w", err)
|
||||
}
|
||||
|
||||
// Split into IPv6 and IPv4
|
||||
var ipv6Addrs, ipv4Addrs []net.IPAddr
|
||||
for _, ip := range ips {
|
||||
if ip.IP.To4() == nil {
|
||||
ipv6Addrs = append(ipv6Addrs, ip)
|
||||
} else {
|
||||
ipv4Addrs = append(ipv4Addrs, ip)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: IPv6 first, then IPv4
|
||||
var orderedAddrs []net.IPAddr
|
||||
orderedAddrs = append(orderedAddrs, ipv6Addrs...)
|
||||
if d.config.IPv4Fallback {
|
||||
orderedAddrs = append(orderedAddrs, ipv4Addrs...)
|
||||
}
|
||||
|
||||
if len(orderedAddrs) == 0 {
|
||||
return nil, fmt.Errorf("no ip addresses found for %s", host)
|
||||
}
|
||||
|
||||
// SSRF protection: block private/internal IPs
|
||||
if d.config.BlockPrivateIPs {
|
||||
for _, addr := range orderedAddrs {
|
||||
if isPrivateIP(addr.IP) {
|
||||
return nil, fmt.Errorf("ssrf protection: blocked private ip %s for host %s", addr.IP, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to connect in order (IPv6 first)
|
||||
var lastErr error
|
||||
for _, ipAddr := range orderedAddrs {
|
||||
ip := ipAddr.IP.String()
|
||||
address := net.JoinHostPort(ip, port)
|
||||
|
||||
// Determine timeout based on IP version
|
||||
dialTimeout := d.config.Timeout
|
||||
if ipAddr.IP.To4() == nil && d.config.IPv4Fallback {
|
||||
// IPv6 with fallback: shorter timeout
|
||||
dialTimeout = d.config.IPv6Timeout
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
if d.dialer != nil {
|
||||
// Use pre-configured dialer (e.g., with bind interface)
|
||||
dialer := *d.dialer
|
||||
dialer.Timeout = dialTimeout
|
||||
conn, err = dialer.DialContext(dialCtx, network, address)
|
||||
} else {
|
||||
conn, err = (&net.Dialer{
|
||||
Timeout: dialTimeout,
|
||||
DualStack: false, // Explicitly disabled for deterministic behavior
|
||||
}).DialContext(dialCtx, network, address)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
// Connection successful
|
||||
ipVersion := 6
|
||||
if ipAddr.IP.To4() != nil {
|
||||
ipVersion = 4
|
||||
}
|
||||
|
||||
if d.config.Debug {
|
||||
fmt.Printf("[DEBUG] Connected to %s (IPv%d)\n", ip, ipVersion)
|
||||
}
|
||||
|
||||
if d.config.OnConnect != nil {
|
||||
d.config.OnConnect(ip, ipVersion)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
if d.config.Debug {
|
||||
fmt.Printf("[DEBUG] Failed to connect to %s: %v\n", ip, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all connection attempts failed: %w", lastErr)
|
||||
}
|
||||
|
||||
// LookupIPAddr performs DNS lookup with IPv6 preference
|
||||
func (d *Dialer) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
ips, err := d.resolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sort: IPv6 first
|
||||
sort.Slice(ips, func(i, j int) bool {
|
||||
iIsV6 := ips[i].IP.To4() == nil
|
||||
jIsV6 := ips[j].IP.To4() == nil
|
||||
if iIsV6 && !jIsV6 {
|
||||
return true
|
||||
}
|
||||
if !iIsV6 && jIsV6 {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// ParseProxyURL parses the proxy URL and returns host:port
|
||||
func ParseProxyURL(proxyURL string) (*url.URL, error) {
|
||||
if proxyURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy url: %w", err)
|
||||
}
|
||||
|
||||
if u.Host == "" {
|
||||
return nil, fmt.Errorf("proxy url missing host")
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// SetCustomDNS configures custom DNS servers for the dialer
|
||||
func (d *Dialer) SetCustomDNS(servers []string) {
|
||||
if d == nil || len(servers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
d.resolver = &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: d.config.Timeout}
|
||||
server := servers[0]
|
||||
if !strings.Contains(server, ":") {
|
||||
server = net.JoinHostPort(server, "53")
|
||||
}
|
||||
return dialer.DialContext(ctx, "udp", server)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseDNSServers parses a comma-separated list of DNS servers
|
||||
func ParseDNSServers(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
var servers []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
servers = append(servers, p)
|
||||
}
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// isPrivateIP checks if an IP address is in a private/internal range.
|
||||
// Always allows localhost (127.0.0.0/8, ::1).
|
||||
// IPv4-mapped IPv6 addresses (e.g. ::ffff:10.0.0.1) are normalized to IPv4
|
||||
// first, otherwise net.IP.IsPrivate returns false for them and an attacker
|
||||
// controlling DNS could bypass SSRF protection.
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip.IsLoopback() {
|
||||
return false // always allow localhost
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsPrivate() || ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsPrivateIP verifies that isPrivateIP correctly blocks private and
|
||||
// reserved ranges, allows loopback and public addresses, and — critically —
|
||||
// detects IPv4-mapped IPv6 representations of private addresses.
|
||||
func TestIsPrivateIP(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
// Loopback — must always pass (localhost is allowed).
|
||||
{"ipv4 loopback", "127.0.0.1", false},
|
||||
{"ipv6 loopback", "::1", false},
|
||||
|
||||
// Plain IPv4 private/reserved — must be blocked.
|
||||
{"ipv4 rfc1918 10/8", "10.0.0.1", true},
|
||||
{"ipv4 rfc1918 172.16/12", "172.16.0.1", true},
|
||||
{"ipv4 rfc1918 192.168/16", "192.168.1.1", true},
|
||||
{"ipv4 link-local", "169.254.0.1", true},
|
||||
{"ipv4 unspecified", "0.0.0.0", true},
|
||||
|
||||
// IPv4-mapped IPv6 — must be normalized and blocked.
|
||||
// This is the SSRF bypass the fix addresses.
|
||||
{"ipv4-mapped private", "::ffff:10.0.0.1", true},
|
||||
{"ipv4-mapped rfc1918 172.16", "::ffff:172.16.0.1", true},
|
||||
{"ipv4-mapped rfc1918 192.168", "::ffff:192.168.1.1", true},
|
||||
{"ipv4-mapped link-local", "::ffff:169.254.0.1", true},
|
||||
{"ipv4-mapped loopback", "::ffff:127.0.0.1", false},
|
||||
|
||||
// Plain IPv6 private/link-local — must be blocked.
|
||||
{"ipv6 unique-local fc00::/7", "fc00::1", true},
|
||||
{"ipv6 link-local fe80::/10", "fe80::1", true},
|
||||
|
||||
// Public addresses — must pass.
|
||||
{"ipv4 public", "8.8.8.8", false},
|
||||
{"ipv4-mapped public", "::ffff:8.8.8.8", false},
|
||||
{"ipv6 public", "2606:4700:4700::1111", false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ip := net.ParseIP(tc.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("net.ParseIP(%q) returned nil", tc.ip)
|
||||
}
|
||||
if got := isPrivateIP(ip); got != tc.want {
|
||||
t.Errorf("isPrivateIP(%s) = %v, want %v", tc.ip, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProxyType defines the proxy type
|
||||
type ProxyType string
|
||||
|
||||
const (
|
||||
ProxyHTTP ProxyType = "http"
|
||||
ProxyHTTPS ProxyType = "https"
|
||||
ProxySOCKS5 ProxyType = "socks5"
|
||||
)
|
||||
|
||||
// ProxyConfig configures the proxy
|
||||
type ProxyConfig struct {
|
||||
// Proxy server URL
|
||||
URL *url.URL
|
||||
|
||||
// Proxy type
|
||||
Type ProxyType
|
||||
|
||||
// Authentication
|
||||
Username string
|
||||
Password string
|
||||
|
||||
// SOCKS5LocalResolve controls DNS resolution behavior for SOCKS5 proxies.
|
||||
// When true (socks5:// scheme), hostnames are resolved locally before being
|
||||
// sent to the proxy. When false (socks5h:// scheme), the hostname is sent
|
||||
// to the proxy for remote resolution.
|
||||
SOCKS5LocalResolve bool
|
||||
}
|
||||
|
||||
// ParseProxyConfig parses proxy URL into configuration
|
||||
func ParseProxyConfig(proxyURL string) (*ProxyConfig, error) {
|
||||
if proxyURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy url: %w", err)
|
||||
}
|
||||
|
||||
var proxyType ProxyType
|
||||
socks5LocalResolve := false
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
proxyType = ProxyHTTP
|
||||
case "socks5":
|
||||
proxyType = ProxySOCKS5
|
||||
socks5LocalResolve = true
|
||||
case "socks5h":
|
||||
proxyType = ProxySOCKS5
|
||||
// socks5LocalResolve stays false
|
||||
default:
|
||||
proxyType = ProxyHTTP
|
||||
}
|
||||
|
||||
username := u.User.Username()
|
||||
password, _ := u.User.Password()
|
||||
|
||||
// Remove authentication from URL for further use
|
||||
u.User = nil
|
||||
|
||||
return &ProxyConfig{
|
||||
URL: u,
|
||||
Type: proxyType,
|
||||
Username: username,
|
||||
Password: password,
|
||||
SOCKS5LocalResolve: socks5LocalResolve,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewProxyTransport creates HTTP transport with proxy
|
||||
func NewProxyTransport(cfg *ProxyConfig, baseTransport *http.Transport) (*http.Transport, error) {
|
||||
if cfg == nil {
|
||||
return baseTransport, nil
|
||||
}
|
||||
|
||||
// SOCKS5 must be implemented as a custom DialContext — Go's stdlib
|
||||
// http.ProxyURL only supports HTTP CONNECT proxies.
|
||||
if cfg.Type == ProxySOCKS5 {
|
||||
return newSOCKS5Transport(cfg, baseTransport)
|
||||
}
|
||||
|
||||
// Create a copy of the transport
|
||||
transport := baseTransport.Clone()
|
||||
|
||||
// Set up the proxy
|
||||
proxyURL := cfg.URL.String()
|
||||
if cfg.Username != "" {
|
||||
// Add authentication back for http.ProxyURL
|
||||
if cfg.Password != "" {
|
||||
proxyURL = fmt.Sprintf("%s://%s:%s@%s",
|
||||
cfg.URL.Scheme,
|
||||
cfg.Username,
|
||||
cfg.Password,
|
||||
cfg.URL.Host)
|
||||
} else {
|
||||
proxyURL = fmt.Sprintf("%s://%s@%s",
|
||||
cfg.URL.Scheme,
|
||||
cfg.Username,
|
||||
cfg.URL.Host)
|
||||
}
|
||||
}
|
||||
|
||||
parsedProxy, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse proxy url: %w", err)
|
||||
}
|
||||
|
||||
transport.Proxy = http.ProxyURL(parsedProxy)
|
||||
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
// newSOCKS5Transport creates an http.Transport that routes connections
|
||||
// through a SOCKS5 proxy by overriding DialContext. The transport's Proxy
|
||||
// field is cleared so the stdlib HTTP proxy machinery does not interfere.
|
||||
func newSOCKS5Transport(cfg *ProxyConfig, baseTransport *http.Transport) (*http.Transport, error) {
|
||||
transport := baseTransport.Clone()
|
||||
transport.Proxy = nil
|
||||
|
||||
// Preserve the existing dialer if one was configured (e.g., bind interface).
|
||||
// SOCKS5 dialing uses net.Dialer directly because the proxy itself handles
|
||||
// connection establishment to the target.
|
||||
prevDial := transport.DialContext
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
// When the caller already provided a custom dialer (e.g., bind-to-device),
|
||||
// use it to reach the SOCKS5 proxy. The proxy then negotiates the
|
||||
// connection to addr.
|
||||
if prevDial != nil {
|
||||
return prevDial(ctx, network, cfg.URL.Host)
|
||||
}
|
||||
return (&net.Dialer{}).DialContext(ctx, network, cfg.URL.Host)
|
||||
}
|
||||
|
||||
// Wrap so the actual target connection happens via SOCKS5 handshake.
|
||||
transport.DialContext = socks5DialContext(transport.DialContext, cfg)
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
// socks5DialContext wraps a base dialer (which connects to the SOCKS5 proxy
|
||||
// host) and returns a DialContext that performs the SOCKS5 handshake to reach
|
||||
// the requested target address.
|
||||
func socks5DialContext(proxyDialer DialContextFunc, cfg *ProxyConfig) DialContextFunc {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
proxyConn, err := proxyDialer(ctx, network, cfg.URL.Host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to socks5 proxy: %w", err)
|
||||
}
|
||||
// Perform SOCKS5 handshake on top of the proxy connection.
|
||||
return socks5Handshake(ctx, proxyConn, network, addr, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// DialContextFunc is the shape of net.Dialer.DialContext — used internally
|
||||
// for wrapping proxy dialers.
|
||||
type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||
|
||||
// DialProxy creates a connection through the proxy
|
||||
func DialProxy(ctx context.Context, network, addr string, cfg *ProxyConfig) (net.Conn, error) {
|
||||
if cfg == nil {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
switch cfg.Type {
|
||||
case ProxyHTTP, ProxyHTTPS:
|
||||
return dialHTTPProxy(ctx, network, addr, cfg)
|
||||
case ProxySOCKS5:
|
||||
return dialSOCKS5Proxy(ctx, network, addr, cfg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy type: %s", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// dialHTTPProxy creates a connection via HTTP CONNECT proxy
|
||||
func dialHTTPProxy(ctx context.Context, network, addr string, cfg *ProxyConfig) (net.Conn, error) {
|
||||
if network != "tcp" && network != "tcp4" && network != "tcp6" {
|
||||
return nil, fmt.Errorf("unsupported network: %s", network)
|
||||
}
|
||||
|
||||
// Connect to the proxy server
|
||||
proxyAddr := cfg.URL.Host
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", proxyAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to proxy: %w", err)
|
||||
}
|
||||
|
||||
// Validate addr for CRLF injection
|
||||
if strings.ContainsAny(addr, "\r\n") {
|
||||
return nil, fmt.Errorf("invalid proxy target address")
|
||||
}
|
||||
|
||||
// Send HTTP CONNECT request
|
||||
connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", addr, addr)
|
||||
|
||||
if cfg.Username != "" {
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(cfg.Username + ":" + cfg.Password))
|
||||
connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", auth)
|
||||
}
|
||||
|
||||
connectReq += "\r\n"
|
||||
|
||||
_, err = conn.Write([]byte(connectReq))
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to send connect request: %w", err)
|
||||
}
|
||||
|
||||
// Read the proxy response
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("failed to read proxy response: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("proxy connect returned %s", resp.Status)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// dialSOCKS5Proxy creates a connection via SOCKS5 proxy
|
||||
func dialSOCKS5Proxy(ctx context.Context, network, addr string, cfg *ProxyConfig) (net.Conn, error) {
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", cfg.URL.Host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to socks5 proxy: %w", err)
|
||||
}
|
||||
return socks5Handshake(ctx, conn, network, addr, cfg)
|
||||
}
|
||||
|
||||
// socks5Handshake performs the SOCKS5 negotiation on top of an already-open
|
||||
// connection to the proxy. It implements RFC 1928 (SOCKS5) and RFC 1929
|
||||
// (username/password auth). If cfg.SOCKS5LocalResolve is true and addr contains
|
||||
// a hostname, the hostname is resolved locally before being sent to the proxy.
|
||||
func socks5Handshake(ctx context.Context, conn net.Conn, network, addr string, cfg *ProxyConfig) (retConn net.Conn, retErr error) {
|
||||
// Ensure the connection is closed on any error path below.
|
||||
defer func() {
|
||||
if retErr != nil {
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// SOCKS5 greeting
|
||||
authMethods := []byte{0x00} // No auth
|
||||
if cfg.Username != "" {
|
||||
authMethods = []byte{0x02} // Username/password auth
|
||||
}
|
||||
greeting := append([]byte{0x05, byte(len(authMethods))}, authMethods...)
|
||||
|
||||
if _, err := conn.Write(greeting); err != nil {
|
||||
return nil, fmt.Errorf("socks5 greeting failed: %w", err)
|
||||
}
|
||||
|
||||
// Read greeting response
|
||||
resp := make([]byte, 2)
|
||||
if _, err := io.ReadFull(conn, resp); err != nil {
|
||||
return nil, fmt.Errorf("socks5 greeting response failed: %w", err)
|
||||
}
|
||||
|
||||
if resp[0] != 0x05 {
|
||||
return nil, fmt.Errorf("socks5: invalid version: %d", resp[0])
|
||||
}
|
||||
|
||||
if resp[1] == 0x02 && cfg.Username != "" {
|
||||
// Username/password auth
|
||||
authMsg := []byte{0x01, byte(len(cfg.Username))}
|
||||
authMsg = append(authMsg, []byte(cfg.Username)...)
|
||||
authMsg = append(authMsg, byte(len(cfg.Password)))
|
||||
authMsg = append(authMsg, []byte(cfg.Password)...)
|
||||
|
||||
if _, err := conn.Write(authMsg); err != nil {
|
||||
return nil, fmt.Errorf("socks5 auth failed: %w", err)
|
||||
}
|
||||
|
||||
authResp := make([]byte, 2)
|
||||
if _, err := io.ReadFull(conn, authResp); err != nil {
|
||||
return nil, fmt.Errorf("socks5 auth response failed: %w", err)
|
||||
}
|
||||
if authResp[1] != 0x00 {
|
||||
return nil, fmt.Errorf("socks5 authentication failed")
|
||||
}
|
||||
} else if resp[1] != 0x00 {
|
||||
return nil, fmt.Errorf("socks5: no acceptable auth method")
|
||||
}
|
||||
|
||||
// SOCKS5 CONNECT request
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid target address: %w", err)
|
||||
}
|
||||
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
var connectReq []byte
|
||||
connectReq = append(connectReq, 0x05, 0x01, 0x00) // VER=5, CMD=CONNECT, RSV=0
|
||||
|
||||
// Decide what to send: IPv4 literal, IPv6 literal, or domain name.
|
||||
// When SOCKS5LocalResolve is true, hostnames are resolved locally and
|
||||
// sent as IPs; otherwise the hostname is passed through to the proxy.
|
||||
target := host
|
||||
if cfg.SOCKS5LocalResolve {
|
||||
if ip := net.ParseIP(host); ip == nil {
|
||||
resolved, err := resolveSOCKS5Hostname(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("socks5: resolve %s: %w", host, err)
|
||||
}
|
||||
target = resolved
|
||||
}
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(target); ip != nil {
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
connectReq = append(connectReq, 0x01) // IPv4
|
||||
connectReq = append(connectReq, ip4...)
|
||||
} else {
|
||||
connectReq = append(connectReq, 0x04) // IPv6
|
||||
connectReq = append(connectReq, ip.To16()...)
|
||||
}
|
||||
} else {
|
||||
connectReq = append(connectReq, 0x03) // Domain name
|
||||
connectReq = append(connectReq, byte(len(target)))
|
||||
connectReq = append(connectReq, []byte(target)...)
|
||||
}
|
||||
|
||||
connectReq = append(connectReq, byte(port>>8), byte(port))
|
||||
|
||||
if _, err := conn.Write(connectReq); err != nil {
|
||||
return nil, fmt.Errorf("socks5 connect request failed: %w", err)
|
||||
}
|
||||
|
||||
// Read CONNECT response
|
||||
resp6 := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, resp6); err != nil {
|
||||
return nil, fmt.Errorf("socks5 connect response failed: %w", err)
|
||||
}
|
||||
|
||||
if resp6[0] != 0x05 || resp6[1] != 0x00 {
|
||||
return nil, fmt.Errorf("socks5 connection rejected: code=%d", resp6[1])
|
||||
}
|
||||
|
||||
// Read remaining address from response (bnd.addr + bnd.port)
|
||||
addrType := resp6[3]
|
||||
switch addrType {
|
||||
case 0x01: // IPv4
|
||||
_, err = io.ReadFull(conn, make([]byte, 4+2))
|
||||
case 0x03: // Domain name
|
||||
var nameLen [1]byte
|
||||
_, err = io.ReadFull(conn, nameLen[:])
|
||||
if err == nil {
|
||||
_, err = io.ReadFull(conn, make([]byte, int(nameLen[0])+2))
|
||||
}
|
||||
case 0x04: // IPv6
|
||||
_, err = io.ReadFull(conn, make([]byte, 16+2))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("socks5: failed to read bind address: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// resolveSOCKS5Hostname resolves a hostname to a single IP literal. It uses
|
||||
// the system resolver — the SOCKS5 proxy is the bottleneck for this path, so
|
||||
// IPv6-first dialer integration is not applied here.
|
||||
func resolveSOCKS5Hostname(ctx context.Context, host string) (string, error) {
|
||||
ips, err := (&net.Resolver{}).LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return "", fmt.Errorf("no addresses for %s", host)
|
||||
}
|
||||
return ips[0].IP.String(), nil
|
||||
}
|
||||
|
||||
// GetProxyFromEnv gets proxy from environment
|
||||
func GetProxyFromEnv() string {
|
||||
// Order: HTTPS_PROXY > HTTP_PROXY > http_proxy
|
||||
for _, env := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"} {
|
||||
if proxy := strings.TrimSpace(os.Getenv(env)); proxy != "" {
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetryConfig configures the retry logic
|
||||
type RetryConfig struct {
|
||||
// Maximum number of attempts
|
||||
MaxRetries int
|
||||
|
||||
// Initial delay between attempts
|
||||
InitialDelay time.Duration
|
||||
|
||||
// Maximum delay between attempts
|
||||
MaxDelay time.Duration
|
||||
|
||||
// Multiplier for exponential backoff
|
||||
Multiplier float64
|
||||
|
||||
// Retry on these HTTP status codes
|
||||
RetryableStatusCodes []int
|
||||
|
||||
// Callback before each retry
|
||||
OnRetry func(attempt int, err error, delay time.Duration)
|
||||
}
|
||||
|
||||
// DefaultRetryConfig returns the default configuration
|
||||
func DefaultRetryConfig() *RetryConfig {
|
||||
return &RetryConfig{
|
||||
MaxRetries: 3,
|
||||
InitialDelay: 1 * time.Second,
|
||||
MaxDelay: 30 * time.Second,
|
||||
Multiplier: 2.0,
|
||||
RetryableStatusCodes: []int{408, 429, 500, 502, 503, 504},
|
||||
}
|
||||
}
|
||||
|
||||
// RetryFunc is a function that can be retried
|
||||
type RetryFunc func(ctx context.Context, attempt int) error
|
||||
|
||||
// Retry performs an operation with retry logic
|
||||
func Retry(ctx context.Context, fn RetryFunc, cfg *RetryConfig) error {
|
||||
if cfg == nil {
|
||||
cfg = DefaultRetryConfig()
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
delay := cfg.InitialDelay
|
||||
|
||||
for attempt := 1; attempt <= cfg.MaxRetries+1; attempt++ {
|
||||
// Check the context
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Perform the operation
|
||||
err := fn(ctx, attempt)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Last attempt - return error
|
||||
if attempt > cfg.MaxRetries {
|
||||
return fmt.Errorf("all %d attempts failed: %w", cfg.MaxRetries+1, err)
|
||||
}
|
||||
|
||||
// Callback before retry
|
||||
if cfg.OnRetry != nil {
|
||||
cfg.OnRetry(attempt, err, delay)
|
||||
}
|
||||
|
||||
// Wait before the next attempt
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
// Exponential backoff
|
||||
delay = time.Duration(float64(delay) * cfg.Multiplier)
|
||||
if delay > cfg.MaxDelay {
|
||||
delay = cfg.MaxDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// IsRetryableError determines whether the error is retryable
|
||||
func IsRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
|
||||
// Network errors
|
||||
retryableErrors := []string{
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"connection timed out",
|
||||
"i/o timeout",
|
||||
"temporary failure",
|
||||
"no route to host",
|
||||
}
|
||||
|
||||
for _, re := range retryableErrors {
|
||||
if containsIgnoreCase(errStr, re) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsRetryableStatusCode determines whether the HTTP status is retryable
|
||||
func IsRetryableStatusCode(status int, cfg *RetryConfig) bool {
|
||||
if cfg == nil {
|
||||
cfg = DefaultRetryConfig()
|
||||
}
|
||||
|
||||
for _, code := range cfg.RetryableStatusCodes {
|
||||
if status == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// WithMaxRetries creates a RetryConfig with the specified maximum retries
|
||||
func WithMaxRetries(maxRetries int) *RetryConfig {
|
||||
cfg := DefaultRetryConfig()
|
||||
if maxRetries > 0 {
|
||||
cfg.MaxRetries = maxRetries
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// containsIgnoreCase helper function
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
s = strings.ToLower(s)
|
||||
substr = strings.ToLower(substr)
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TLSConfig configures TLS
|
||||
type TLSConfig struct {
|
||||
// Minimum TLS version
|
||||
MinVersion uint16
|
||||
|
||||
// Maximum TLS version
|
||||
MaxVersion uint16
|
||||
|
||||
// Path to CA certificates
|
||||
CACertFile string
|
||||
|
||||
// Skip certificate verification
|
||||
InsecureSkipVerify bool
|
||||
|
||||
// Client certificate for mTLS
|
||||
CertFile string
|
||||
|
||||
// Client private key for mTLS
|
||||
KeyFile string
|
||||
|
||||
// Certificate pinning (SHA-256 hash of certificate)
|
||||
PinnedCertHash string
|
||||
|
||||
// TLSServerNameOverride forces the SNI server name and the hostname used
|
||||
// for certificate verification. Leave empty (the default) so that Go's
|
||||
// stdlib derives the server name from the dial target, which is the safe
|
||||
// behavior. Setting this to a value that does not match the host you are
|
||||
// connecting to enables a man-in-the-middle attacker with a valid cert
|
||||
// for the overridden name to impersonate the target. Only set this when
|
||||
// you genuinely need to override SNI (e.g. connecting to an IP address
|
||||
// whose certificate carries a different hostname).
|
||||
TLSServerNameOverride string
|
||||
|
||||
// Path to SSLKEYLOGFILE for Wireshark debugging
|
||||
KeyLogFile string
|
||||
}
|
||||
|
||||
// DefaultTLSConfig returns a safe default configuration
|
||||
func DefaultTLSConfig() *TLSConfig {
|
||||
return &TLSConfig{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
InsecureSkipVerify: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ToTLSConfig converts to tls.Config
|
||||
func (c *TLSConfig) ToTLSConfig() (*tls.Config, error) {
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: c.MinVersion,
|
||||
MaxVersion: c.MaxVersion,
|
||||
InsecureSkipVerify: c.InsecureSkipVerify,
|
||||
}
|
||||
|
||||
// Only apply the override if explicitly set. When empty, Go's net/http
|
||||
// transport derives ServerName from the dial address, which matches the
|
||||
// certificate the server is expected to present.
|
||||
if c.TLSServerNameOverride != "" {
|
||||
tlsCfg.ServerName = c.TLSServerNameOverride
|
||||
}
|
||||
|
||||
// Load custom CA certificates
|
||||
if c.CACertFile != "" {
|
||||
caCert, err := os.ReadFile(c.CACertFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read ca cert: %w", err)
|
||||
}
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
if !caCertPool.AppendCertsFromPEM(caCert) {
|
||||
return nil, fmt.Errorf("failed to parse ca cert")
|
||||
}
|
||||
|
||||
tlsCfg.RootCAs = caCertPool
|
||||
}
|
||||
|
||||
// Certificate pinning: verify SHA-256 hash of server certificate
|
||||
if c.PinnedCertHash != "" {
|
||||
expectedHash := strings.ToLower(strings.TrimSpace(c.PinnedCertHash))
|
||||
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
for _, rawCert := range rawCerts {
|
||||
cert, err := x509.ParseCertificate(rawCert)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hash := sha256.Sum256(cert.Raw)
|
||||
actual := hex.EncodeToString(hash[:])
|
||||
if actual == expectedHash {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("certificate pinning failed: no certificate matches sha-256 %s", expectedHash)
|
||||
}
|
||||
}
|
||||
|
||||
// Load client certificate for mTLS
|
||||
if c.CertFile != "" && c.KeyFile != "" {
|
||||
cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load client certificate: %w", err)
|
||||
}
|
||||
tlsCfg.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
// SSLKEYLOGFILE for Wireshark/TLS debugging
|
||||
if c.KeyLogFile != "" {
|
||||
f, err := os.OpenFile(c.KeyLogFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open keylog file: %w", err)
|
||||
}
|
||||
tlsCfg.KeyLogWriter = f
|
||||
}
|
||||
|
||||
return tlsCfg, nil
|
||||
}
|
||||
|
||||
// GetTLSVersionName returns a human-readable TLS version name
|
||||
func GetTLSVersionName(version uint16) string {
|
||||
switch version {
|
||||
case tls.VersionTLS10:
|
||||
return "TLS 1.0"
|
||||
case tls.VersionTLS11:
|
||||
return "TLS 1.1"
|
||||
case tls.VersionTLS12:
|
||||
return "TLS 1.2"
|
||||
case tls.VersionTLS13:
|
||||
return "TLS 1.3"
|
||||
default:
|
||||
return fmt.Sprintf("TLS 0x%04X", version)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TokenBucket implements the token bucket algorithm
|
||||
type TokenBucket struct {
|
||||
mu sync.Mutex
|
||||
capacity int64
|
||||
tokens float64
|
||||
refillRate float64
|
||||
lastRefill time.Time
|
||||
}
|
||||
|
||||
// NewTokenBucket creates new rate limiter
|
||||
func NewTokenBucket(maxBytesPerSec int64, burstSize int64) *TokenBucket {
|
||||
if maxBytesPerSec <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &TokenBucket{
|
||||
capacity: burstSize,
|
||||
tokens: float64(burstSize),
|
||||
refillRate: float64(maxBytesPerSec),
|
||||
lastRefill: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Allow waits for enough tokens
|
||||
func (tb *TokenBucket) Allow(n int64) {
|
||||
if tb == nil {
|
||||
return
|
||||
}
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(tb.lastRefill).Seconds()
|
||||
tb.tokens += elapsed * tb.refillRate
|
||||
if tb.tokens > float64(tb.capacity) {
|
||||
tb.tokens = float64(tb.capacity)
|
||||
}
|
||||
tb.lastRefill = now
|
||||
|
||||
for tb.tokens < float64(n) {
|
||||
needed := float64(n) - tb.tokens
|
||||
waitTime := time.Duration(needed/tb.refillRate*1e9) * time.Nanosecond
|
||||
tb.mu.Unlock()
|
||||
time.Sleep(waitTime)
|
||||
tb.mu.Lock()
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(tb.lastRefill).Seconds()
|
||||
tb.tokens += elapsed * tb.refillRate
|
||||
if tb.tokens > float64(tb.capacity) {
|
||||
tb.tokens = float64(tb.capacity)
|
||||
}
|
||||
tb.lastRefill = now
|
||||
}
|
||||
tb.tokens -= float64(n)
|
||||
}
|
||||
|
||||
// WrapReader returns a reader with rate limiting
|
||||
func (tb *TokenBucket) WrapReader(r io.Reader) io.Reader {
|
||||
if tb == nil {
|
||||
return r
|
||||
}
|
||||
return &limitedReader{r: r, limiter: tb}
|
||||
}
|
||||
|
||||
type limitedReader struct {
|
||||
r io.Reader
|
||||
limiter *TokenBucket
|
||||
}
|
||||
|
||||
func (lr *limitedReader) Read(p []byte) (int, error) {
|
||||
lr.limiter.Allow(int64(len(p)))
|
||||
return lr.r.Read(p)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user