//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 }