286 lines
6.5 KiB
Go
286 lines
6.5 KiB
Go
//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
|
|
}
|