Skip to content

Commit d27aef8

Browse files
committed
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).
1 parent 6c561eb commit d27aef8

File tree

3 files changed

+92
-0
lines changed

3 files changed

+92
-0
lines changed

doh-client/client.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,29 @@ func NewClient(conf *config.Config) (c *Client, err error) {
9090
Net: "tcp",
9191
Timeout: time.Duration(conf.Other.Timeout) * time.Second,
9292
}
93+
94+
if c.conf.Other.Interface != "" {
95+
// Setup UDP Dialer
96+
udpLocalAddr, err := c.bindToInterface("udp")
97+
if err != nil {
98+
return nil, fmt.Errorf("failed to bind passthrough UDP to interface %s: %v", c.conf.Other.Interface, err)
99+
}
100+
c.udpClient.Dialer = &net.Dialer{
101+
Timeout: time.Duration(conf.Other.Timeout) * time.Second,
102+
LocalAddr: udpLocalAddr,
103+
}
104+
105+
// Setup TCP Dialer
106+
tcpLocalAddr, err := c.bindToInterface("tcp")
107+
if err != nil {
108+
return nil, fmt.Errorf("failed to bind passthrough TCP to interface %s: %v", c.conf.Other.Interface, err)
109+
}
110+
c.tcpClient.Dialer = &net.Dialer{
111+
Timeout: time.Duration(conf.Other.Timeout) * time.Second,
112+
LocalAddr: tcpLocalAddr,
113+
}
114+
}
115+
93116
for _, addr := range conf.Listen {
94117
c.udpServers = append(c.udpServers, &dns.Server{
95118
Addr: addr,
@@ -120,6 +143,14 @@ func NewClient(conf *config.Config) (c *Client, err error) {
120143
PreferGo: true,
121144
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
122145
var d net.Dialer
146+
if c.conf.Other.Interface != "" {
147+
localAddr, err := c.bindToInterface(network)
148+
if err != nil {
149+
log.Printf("Bootstrap dial warning: %v", err)
150+
} else {
151+
d.LocalAddr = localAddr
152+
}
153+
}
123154
numServers := len(c.bootstrap)
124155
bootstrap := c.bootstrap[rand.Intn(numServers)]
125156
conn, err := d.DialContext(ctx, network, bootstrap)
@@ -241,6 +272,14 @@ func (c *Client) newHTTPClient() error {
241272
// DualStack: true,
242273
Resolver: c.bootstrapResolver,
243274
}
275+
if c.conf.Other.Interface != "" {
276+
localAddr, err := c.bindToInterface("tcp")
277+
if err != nil {
278+
log.Printf("Failed to resolve interface %s: %v", c.conf.Other.Interface, err)
279+
return err
280+
}
281+
dialer.LocalAddr = localAddr
282+
}
244283
c.httpTransport = &http.Transport{
245284
DialContext: dialer.DialContext,
246285
ExpectContinueTimeout: 1 * time.Second,
@@ -485,3 +524,50 @@ func (c *Client) findClientIP(w dns.ResponseWriter, r *dns.Msg) (ednsClientAddre
485524
}
486525
return
487526
}
527+
528+
func (c *Client) bindToInterface(network string) (net.Addr, error) {
529+
if c.conf.Other.Interface == "" {
530+
return nil, nil
531+
}
532+
ifi, err := net.InterfaceByName(c.conf.Other.Interface)
533+
if err != nil {
534+
return nil, err
535+
}
536+
addrs, err := ifi.Addrs()
537+
if err != nil {
538+
return nil, err
539+
}
540+
541+
// Determine if we need IPv4 or IPv6 based on the network string (e.g., "tcp4", "udp6")
542+
wantIPv6 := strings.Contains(network, "6")
543+
wantIPv4 := strings.Contains(network, "4") || !wantIPv6 // Default to 4 if not specified, or if generic "tcp"/"udp"
544+
545+
for _, addr := range addrs {
546+
ip, _, err := net.ParseCIDR(addr.String())
547+
if err != nil {
548+
continue
549+
}
550+
551+
// Skip if we want IPv4 but got IPv6
552+
if ip.To4() == nil && wantIPv4 && !wantIPv6 {
553+
continue
554+
}
555+
// Skip if we want IPv6 but got IPv4
556+
if ip.To4() != nil && wantIPv6 {
557+
continue
558+
}
559+
// Skip IPv6 if disabled in config
560+
if ip.To4() == nil && c.conf.Other.NoIPv6 {
561+
continue
562+
}
563+
564+
// Return the appropriate address type
565+
if strings.HasPrefix(network, "tcp") {
566+
return &net.TCPAddr{IP: ip}, nil
567+
}
568+
if strings.HasPrefix(network, "udp") {
569+
return &net.UDPAddr{IP: ip}, nil
570+
}
571+
}
572+
return nil, fmt.Errorf("no suitable address found on interface %s for network %s", c.conf.Other.Interface, network)
573+
}

doh-client/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type others struct {
5050
Bootstrap []string `toml:"bootstrap"`
5151
Passthrough []string `toml:"passthrough"`
5252
Timeout uint `toml:"timeout"`
53+
Interface string `toml:"interface"`
5354
NoCookies bool `toml:"no_cookies"`
5455
NoECS bool `toml:"no_ecs"`
5556
NoIPv6 bool `toml:"no_ipv6"`

doh-client/doh-client.conf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ passthrough = [
9797
# Timeout for upstream request in seconds
9898
timeout = 30
9999

100+
# Interface to bind to for outgoing connections.
101+
# If empty, the system default route is used (usually eth0 or wlan0).
102+
# Example: "eth1", "wlan0"
103+
interface = ""
104+
100105
# Disable HTTP Cookies
101106
#
102107
# Cookies may be useful if your upstream resolver is protected by some

0 commit comments

Comments
 (0)