From d27aef852d3894502994533a8d74ca550bb7df4f Mon Sep 17 00:00:00 2001 From: Shahrad Elahi Date: Fri, 23 Jan 2026 01:41:28 +0000 Subject: [PATCH 1/2] feat: add option to bind outgoing connections to a specific interface This adds a new `interface` configuration option to `doh-client` that allows users to specify a network interface for all outgoing DNS queries (including bootstrap and passthrough traffic). --- doh-client/client.go | 86 +++++++++++++++++++++++++++++++++++++ doh-client/config/config.go | 1 + doh-client/doh-client.conf | 5 +++ 3 files changed, 92 insertions(+) diff --git a/doh-client/client.go b/doh-client/client.go index e306d2ec..bad5a506 100644 --- a/doh-client/client.go +++ b/doh-client/client.go @@ -90,6 +90,29 @@ func NewClient(conf *config.Config) (c *Client, err error) { Net: "tcp", Timeout: time.Duration(conf.Other.Timeout) * time.Second, } + + if c.conf.Other.Interface != "" { + // Setup UDP Dialer + udpLocalAddr, err := c.bindToInterface("udp") + if err != nil { + return nil, fmt.Errorf("failed to bind passthrough UDP to interface %s: %v", c.conf.Other.Interface, err) + } + c.udpClient.Dialer = &net.Dialer{ + Timeout: time.Duration(conf.Other.Timeout) * time.Second, + LocalAddr: udpLocalAddr, + } + + // Setup TCP Dialer + tcpLocalAddr, err := c.bindToInterface("tcp") + if err != nil { + return nil, fmt.Errorf("failed to bind passthrough TCP to interface %s: %v", c.conf.Other.Interface, err) + } + c.tcpClient.Dialer = &net.Dialer{ + Timeout: time.Duration(conf.Other.Timeout) * time.Second, + LocalAddr: tcpLocalAddr, + } + } + for _, addr := range conf.Listen { c.udpServers = append(c.udpServers, &dns.Server{ Addr: addr, @@ -120,6 +143,14 @@ func NewClient(conf *config.Config) (c *Client, err error) { PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { var d net.Dialer + if c.conf.Other.Interface != "" { + localAddr, err := c.bindToInterface(network) + if err != nil { + log.Printf("Bootstrap dial warning: %v", err) + } else { + d.LocalAddr = localAddr + } + } numServers := len(c.bootstrap) bootstrap := c.bootstrap[rand.Intn(numServers)] conn, err := d.DialContext(ctx, network, bootstrap) @@ -241,6 +272,14 @@ func (c *Client) newHTTPClient() error { // DualStack: true, Resolver: c.bootstrapResolver, } + if c.conf.Other.Interface != "" { + localAddr, err := c.bindToInterface("tcp") + if err != nil { + log.Printf("Failed to resolve interface %s: %v", c.conf.Other.Interface, err) + return err + } + dialer.LocalAddr = localAddr + } c.httpTransport = &http.Transport{ DialContext: dialer.DialContext, ExpectContinueTimeout: 1 * time.Second, @@ -485,3 +524,50 @@ func (c *Client) findClientIP(w dns.ResponseWriter, r *dns.Msg) (ednsClientAddre } return } + +func (c *Client) bindToInterface(network string) (net.Addr, error) { + if c.conf.Other.Interface == "" { + return nil, nil + } + ifi, err := net.InterfaceByName(c.conf.Other.Interface) + if err != nil { + return nil, err + } + addrs, err := ifi.Addrs() + if err != nil { + return nil, err + } + + // Determine if we need IPv4 or IPv6 based on the network string (e.g., "tcp4", "udp6") + wantIPv6 := strings.Contains(network, "6") + wantIPv4 := strings.Contains(network, "4") || !wantIPv6 // Default to 4 if not specified, or if generic "tcp"/"udp" + + for _, addr := range addrs { + ip, _, err := net.ParseCIDR(addr.String()) + if err != nil { + continue + } + + // Skip if we want IPv4 but got IPv6 + if ip.To4() == nil && wantIPv4 && !wantIPv6 { + continue + } + // Skip if we want IPv6 but got IPv4 + if ip.To4() != nil && wantIPv6 { + continue + } + // Skip IPv6 if disabled in config + if ip.To4() == nil && c.conf.Other.NoIPv6 { + continue + } + + // Return the appropriate address type + if strings.HasPrefix(network, "tcp") { + return &net.TCPAddr{IP: ip}, nil + } + if strings.HasPrefix(network, "udp") { + return &net.UDPAddr{IP: ip}, nil + } + } + return nil, fmt.Errorf("no suitable address found on interface %s for network %s", c.conf.Other.Interface, network) +} diff --git a/doh-client/config/config.go b/doh-client/config/config.go index e57e22b3..78207a3e 100644 --- a/doh-client/config/config.go +++ b/doh-client/config/config.go @@ -50,6 +50,7 @@ type others struct { Bootstrap []string `toml:"bootstrap"` Passthrough []string `toml:"passthrough"` Timeout uint `toml:"timeout"` + Interface string `toml:"interface"` NoCookies bool `toml:"no_cookies"` NoECS bool `toml:"no_ecs"` NoIPv6 bool `toml:"no_ipv6"` diff --git a/doh-client/doh-client.conf b/doh-client/doh-client.conf index 01d12912..20b2f41e 100644 --- a/doh-client/doh-client.conf +++ b/doh-client/doh-client.conf @@ -97,6 +97,11 @@ passthrough = [ # Timeout for upstream request in seconds timeout = 30 +# Interface to bind to for outgoing connections. +# If empty, the system default route is used (usually eth0 or wlan0). +# Example: "eth1", "wlan0" +interface = "" + # Disable HTTP Cookies # # Cookies may be useful if your upstream resolver is protected by some From 06e3d67f79ead3177dc4992a2e06baed6d8c8973 Mon Sep 17 00:00:00 2001 From: Shahrad Elahi Date: Fri, 23 Jan 2026 16:04:57 +0000 Subject: [PATCH 2/2] feat: support dual-stack for interface binding --- doh-client/client.go | 177 +++++++++++++++++++++++++++++-------------- 1 file changed, 121 insertions(+), 56 deletions(-) diff --git a/doh-client/client.go b/doh-client/client.go index bad5a506..f6e15810 100644 --- a/doh-client/client.go +++ b/doh-client/client.go @@ -92,24 +92,24 @@ func NewClient(conf *config.Config) (c *Client, err error) { } if c.conf.Other.Interface != "" { - // Setup UDP Dialer - udpLocalAddr, err := c.bindToInterface("udp") + localV4, localV6, err := c.getInterfaceIPs() if err != nil { - return nil, fmt.Errorf("failed to bind passthrough UDP to interface %s: %v", c.conf.Other.Interface, err) + return nil, fmt.Errorf("failed to get interface IPs for %s: %v", c.conf.Other.Interface, err) } - c.udpClient.Dialer = &net.Dialer{ - Timeout: time.Duration(conf.Other.Timeout) * time.Second, - LocalAddr: udpLocalAddr, + var localAddr net.IP + if localV4 != nil { + localAddr = localV4 + } else { + localAddr = localV6 } - // Setup TCP Dialer - tcpLocalAddr, err := c.bindToInterface("tcp") - if err != nil { - return nil, fmt.Errorf("failed to bind passthrough TCP to interface %s: %v", c.conf.Other.Interface, err) + c.udpClient.Dialer = &net.Dialer{ + Timeout: time.Duration(conf.Other.Timeout) * time.Second, + LocalAddr: &net.UDPAddr{IP: localAddr}, } c.tcpClient.Dialer = &net.Dialer{ Timeout: time.Duration(conf.Other.Timeout) * time.Second, - LocalAddr: tcpLocalAddr, + LocalAddr: &net.TCPAddr{IP: localAddr}, } } @@ -144,11 +144,35 @@ func NewClient(conf *config.Config) (c *Client, err error) { Dial: func(ctx context.Context, network, address string) (net.Conn, error) { var d net.Dialer if c.conf.Other.Interface != "" { - localAddr, err := c.bindToInterface(network) + localV4, localV6, err := c.getInterfaceIPs() if err != nil { log.Printf("Bootstrap dial warning: %v", err) } else { - d.LocalAddr = localAddr + numServers := len(c.bootstrap) + bootstrap := c.bootstrap[rand.Intn(numServers)] + host, _, _ := net.SplitHostPort(bootstrap) + ip := net.ParseIP(host) + if ip != nil { + if ip.To4() != nil { + if localV4 != nil { + if strings.HasPrefix(network, "udp") { + d.LocalAddr = &net.UDPAddr{IP: localV4} + } else { + d.LocalAddr = &net.TCPAddr{IP: localV4} + } + } + } else { + if localV6 != nil { + if strings.HasPrefix(network, "udp") { + d.LocalAddr = &net.UDPAddr{IP: localV6} + } else { + d.LocalAddr = &net.TCPAddr{IP: localV6} + } + } + } + } + conn, err := d.DialContext(ctx, network, bootstrap) + return conn, err } } numServers := len(c.bootstrap) @@ -266,22 +290,72 @@ func (c *Client) newHTTPClient() error { if c.httpTransport != nil { c.httpTransport.CloseIdleConnections() } - dialer := &net.Dialer{ + + localV4, localV6, err := c.getInterfaceIPs() + if err != nil { + log.Printf("Interface binding error: %v", err) + return err + } + + baseDialer := &net.Dialer{ Timeout: time.Duration(c.conf.Other.Timeout) * time.Second, KeepAlive: 30 * time.Second, - // DualStack: true, - Resolver: c.bootstrapResolver, - } - if c.conf.Other.Interface != "" { - localAddr, err := c.bindToInterface("tcp") - if err != nil { - log.Printf("Failed to resolve interface %s: %v", c.conf.Other.Interface, err) - return err - } - dialer.LocalAddr = localAddr + Resolver: c.bootstrapResolver, } + c.httpTransport = &http.Transport{ - DialContext: dialer.DialContext, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + if c.conf.Other.Interface == "" { + return baseDialer.DialContext(ctx, network, addr) + } + + if network == "tcp4" && localV4 != nil { + d := *baseDialer + d.LocalAddr = &net.TCPAddr{IP: localV4} + return d.DialContext(ctx, network, addr) + } + if network == "tcp6" && localV6 != nil { + d := *baseDialer + d.LocalAddr = &net.TCPAddr{IP: localV6} + return d.DialContext(ctx, network, addr) + } + + // Manual Dual-Stack: Resolve host and try compatible families sequentially + host, port, _ := net.SplitHostPort(addr) + ips, err := c.bootstrapResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + + var lastErr error + for _, ip := range ips { + d := *baseDialer + targetAddr := net.JoinHostPort(ip.String(), port) + + if ip.IP.To4() != nil { + if localV4 == nil { + continue + } + d.LocalAddr = &net.TCPAddr{IP: localV4} + } else { + if localV6 == nil { + continue + } + d.LocalAddr = &net.TCPAddr{IP: localV6} + } + + conn, err := d.DialContext(ctx, "tcp", targetAddr) + if err == nil { + return conn, nil + } + lastErr = err + } + + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("connection to %s failed: no matching local/remote IP families on interface %s", addr, c.conf.Other.Interface) + }, ExpectContinueTimeout: 1 * time.Second, IdleConnTimeout: 90 * time.Second, MaxIdleConns: 100, @@ -290,15 +364,18 @@ func (c *Client) newHTTPClient() error { TLSHandshakeTimeout: time.Duration(c.conf.Other.Timeout) * time.Second, TLSClientConfig: &tls.Config{InsecureSkipVerify: c.conf.Other.TLSInsecureSkipVerify}, } + if c.conf.Other.NoIPv6 { + originalDial := c.httpTransport.DialContext c.httpTransport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { if strings.HasPrefix(network, "tcp") { network = "tcp4" } - return dialer.DialContext(ctx, network, address) + return originalDial(ctx, network, address) } } - err := http2.ConfigureTransport(c.httpTransport) + + err = http2.ConfigureTransport(c.httpTransport) if err != nil { return err } @@ -525,49 +602,37 @@ func (c *Client) findClientIP(w dns.ResponseWriter, r *dns.Msg) (ednsClientAddre return } -func (c *Client) bindToInterface(network string) (net.Addr, error) { +// getInterfaceIPs returns the first valid IPv4 and IPv6 addresses found on the interface +func (c *Client) getInterfaceIPs() (v4, v6 net.IP, err error) { if c.conf.Other.Interface == "" { - return nil, nil + return nil, nil, nil } ifi, err := net.InterfaceByName(c.conf.Other.Interface) if err != nil { - return nil, err + return nil, nil, err } addrs, err := ifi.Addrs() if err != nil { - return nil, err + return nil, nil, err } - // Determine if we need IPv4 or IPv6 based on the network string (e.g., "tcp4", "udp6") - wantIPv6 := strings.Contains(network, "6") - wantIPv4 := strings.Contains(network, "4") || !wantIPv6 // Default to 4 if not specified, or if generic "tcp"/"udp" - for _, addr := range addrs { ip, _, err := net.ParseCIDR(addr.String()) if err != nil { continue } - - // Skip if we want IPv4 but got IPv6 - if ip.To4() == nil && wantIPv4 && !wantIPv6 { - continue - } - // Skip if we want IPv6 but got IPv4 - if ip.To4() != nil && wantIPv6 { - continue - } - // Skip IPv6 if disabled in config - if ip.To4() == nil && c.conf.Other.NoIPv6 { - continue - } - - // Return the appropriate address type - if strings.HasPrefix(network, "tcp") { - return &net.TCPAddr{IP: ip}, nil - } - if strings.HasPrefix(network, "udp") { - return &net.UDPAddr{IP: ip}, nil + if ip4 := ip.To4(); ip4 != nil { + if v4 == nil { + v4 = ip4 + } + } else { + if v6 == nil && !c.conf.Other.NoIPv6 { + v6 = ip + } } } - return nil, fmt.Errorf("no suitable address found on interface %s for network %s", c.conf.Other.Interface, network) + if v4 == nil && v6 == nil { + return nil, nil, fmt.Errorf("no valid IP addresses found on interface %s", c.conf.Other.Interface) + } + return v4, v6, nil }