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).
This commit is contained in:
Shahrad Elahi
2026-01-23 01:41:28 +00:00
parent 6c561eb412
commit d27aef852d
3 changed files with 92 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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"`

View File

@@ -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