diff --git a/plugin/forward/README.md b/plugin/forward/README.md index a7ca9a84b..eb6f8fe13 100644 --- a/plugin/forward/README.md +++ b/plugin/forward/README.md @@ -59,6 +59,7 @@ forward FROM TO... { next RCODE_1 [RCODE_2] [RCODE_3...] failfast_all_unhealthy_upstreams failover RCODE_1 [RCODE_2] [RCODE_3...] + source_address IP resolver IP[:PORT] [IP[:PORT]...] } ~~~ @@ -128,6 +129,7 @@ key exchange mechanisms use the Go `crypto/tls` defaults. * `next_on_nodata` If `NOERROR` is returned by the remote, but an empty answer section (`NODATA`) was provided, execute the next `forward` plugin, if configured. * `failfast_all_unhealthy_upstreams` - determines the handling of requests when all upstream servers are unhealthy and unresponsive to health checks. Enabling this option will immediately return SERVFAIL responses for all requests. By default, requests are sent to a random upstream. * `failover` - By default when a DNS lookup fails to return a DNS response (e.g. timeout), _forward_ will attempt a lookup on the next upstream server. The `failover` option will make _forward_ do the same for any response with a response code matching an `RCODE` ( e.g. `SERVFAIL`、`REFUSED`). `NOERROR` cannot be used. If all upstreams have been tried, the response from the last attempt is returned. +* `source_address` **IP** - set the address to use for all outgoing requests as source address (also health check query). This works reliably when upstream servers are reachable from that address. However, if upstream servers belong to different networks, care must be taken. The selected source address may not be valid for all upstreams, and responses may fail if return routing is not properly configured. In such cases, make sure that upstream servers have a route back to the configured source address. * `resolver` **IP[:PORT] [IP[:PORT]...]** specifies one or more DNS resolver addresses used to resolve hostname-based **TO** endpoints at startup. If not specified, the system resolver (`/etc/resolv.conf`) is used. Each address is either a bare IP (IPv4 or IPv6, port 53 assumed) or `IP:port`. Multiple addresses can be specified for redundancy. Also note the TLS config is "global" for the whole forwarding proxy if you need a different diff --git a/plugin/forward/forward.go b/plugin/forward/forward.go index 760175d2b..f95c77bdb 100644 --- a/plugin/forward/forward.go +++ b/plugin/forward/forward.go @@ -61,6 +61,7 @@ type Forward struct { failfastUnhealthyUpstreams bool failoverRcodes []int maxConnectAttempts uint32 + sourceAddress net.IP // Hostname resolution fields resolver []string // custom resolver IPs for hostname TO resolution diff --git a/plugin/forward/setup.go b/plugin/forward/setup.go index 86b078f91..b661b300e 100644 --- a/plugin/forward/setup.go +++ b/plugin/forward/setup.go @@ -257,6 +257,10 @@ func parseStanza(c *caddy.Controller) (*Forward, error) { f.proxies[i].GetHealthchecker().SetTCPTransport() } f.proxies[i].GetHealthchecker().SetDomain(f.opts.HCDomain) + if f.sourceAddress != nil { + f.proxies[i].SetLocalAddress(f.sourceAddress) + f.proxies[i].GetHealthchecker().SetLocalAddress(f.sourceAddress) + } } return f, nil @@ -499,6 +503,15 @@ func parseBlock(c *caddy.Controller, f *Forward) error { } } f.resolver = args + case "source_address": + if !c.NextArg() { + return c.ArgErr() + } + addr := net.ParseIP(c.Val()) + if addr == nil { + return c.Errf("invalid IP address: %s", c.Val()) + } + f.sourceAddress = addr default: return c.Errf("unknown property '%s'", c.Val()) } diff --git a/plugin/forward/setup_test.go b/plugin/forward/setup_test.go index 86dee82ca..b0b9d0f87 100644 --- a/plugin/forward/setup_test.go +++ b/plugin/forward/setup_test.go @@ -3,6 +3,7 @@ package forward import ( "context" "fmt" + "net" "os" "reflect" "strings" @@ -92,6 +93,45 @@ func TestSetup(t *testing.T) { } } +func TestSourceAddress(t *testing.T) { + tests := []struct { + input string + expectedSourceAddress net.IP + expectedErr string + }{ + + {"forward . 127.0.0.1 {\nsource_address 192.0.2.1\n}\n", net.ParseIP("192.0.2.1"), ""}, + {"forward . 127.0.0.1 {\nsource_address not-an-ip\n}\n", nil, "invalid IP address"}, + {"forward . 127.0.0.1 {\nsource_address 2001:0db8:85a3:0000:1319:8a2e:0370:7344\n}\n", net.ParseIP("2001:0db8:85a3:0000:1319:8a2e:0370:7344"), ""}, + {"forward . 127.0.0.1 {\nsource_address ::ffff:192.0.2.1\n}\n", net.ParseIP("192.0.2.1"), ""}, + {"forward . 127.0.0.1 {\nsource_address \n}\n", nil, "Error during parsing: Wrong argument count or unexpected line ending after 'source_address'"}, + } + + for i, test := range tests { + c := caddy.NewTestController("dns", test.input) + fs, err := parseForward(c) + + if test.expectedErr != "" && err == nil { + t.Errorf("Test %d: expected error but found %s for input %s", i, err, test.input) + } + if err != nil { + if test.expectedErr == "" { + t.Errorf("Test %d: expected no error but found one for input %s, got: %v", i, test.input, err) + } + + if !strings.Contains(err.Error(), test.expectedErr) { + t.Errorf("Test %d: expected error to contain: %v, found error: %v, input: %s", i, test.expectedErr, err, test.input) + } + } + if test.expectedErr == "" { + f := fs[0] + if !test.expectedSourceAddress.Equal(f.sourceAddress) { + t.Errorf("Test %d: expected: %v, got: %v", i, test.expectedSourceAddress, f.sourceAddress) + } + } + } +} + func TestSplitZone(t *testing.T) { tests := []struct { input string diff --git a/plugin/pkg/proxy/connect.go b/plugin/pkg/proxy/connect.go index e340c784d..27d57828a 100644 --- a/plugin/pkg/proxy/connect.go +++ b/plugin/pkg/proxy/connect.go @@ -97,12 +97,21 @@ func (t *Transport) Dial(proto string) (*persistConn, bool, error) { reqTime := time.Now() timeout := t.dialTimeout() - if proto == "tcp-tls" { - conn, err := dns.DialTimeoutWithTLS("tcp", t.addr, t.tlsConfig, timeout) - t.updateDialTimeout(time.Since(reqTime)) - return &persistConn{c: conn, created: time.Now()}, false, err + dialer := &net.Dialer{Timeout: timeout} + + if t.localAddress != nil { + if proto == "udp" { + dialer.LocalAddr = &net.UDPAddr{IP: t.localAddress} + } else { + dialer.LocalAddr = &net.TCPAddr{IP: t.localAddress} + } } - conn, err := dns.DialTimeout(proto, t.addr, timeout) + + // pass nil tlsConfig to use system default + client := dns.Client{Net: proto, Dialer: dialer, TLSConfig: t.tlsConfig} + + conn, err := client.Dial(t.addr) + t.updateDialTimeout(time.Since(reqTime)) return &persistConn{c: conn, created: time.Now()}, false, err } diff --git a/plugin/pkg/proxy/health.go b/plugin/pkg/proxy/health.go index b8f9fb887..83e39e866 100644 --- a/plugin/pkg/proxy/health.go +++ b/plugin/pkg/proxy/health.go @@ -3,6 +3,7 @@ package proxy import ( "context" "crypto/tls" + "net" "net/http" "sync/atomic" "time" @@ -28,6 +29,8 @@ type HealthChecker interface { SetReadTimeout(time.Duration) GetWriteTimeout() time.Duration SetWriteTimeout(time.Duration) + SetLocalAddress(net.IP) + GetLocalAddress() net.IP } // dnsHc is a health checker for a DNS endpoint (DNS, and DoT). @@ -37,6 +40,8 @@ type dnsHc struct { domain string proxyName string + + localAddress net.IP } const defaultTimeout = 1 * time.Second @@ -47,8 +52,7 @@ func NewHealthChecker(proxyName, protocol string, recursionDesired bool, domain case transport.DNS, transport.TLS: c := new(dns.Client) c.Net = "udp" - c.ReadTimeout = defaultTimeout - c.WriteTimeout = defaultTimeout + setDefaultTimeout(c) return &dnsHc{ c: c, @@ -78,6 +82,8 @@ func NewHealthChecker(proxyName, protocol string, recursionDesired bool, domain func (h *dnsHc) SetTLSConfig(cfg *tls.Config) { h.c.Net = "tcp-tls" h.c.TLSConfig = cfg + // update the dialer accordingly with the protocol changed + h.setDialer() } func (h *dnsHc) GetTLSConfig() *tls.Config { @@ -100,6 +106,8 @@ func (h *dnsHc) GetDomain() string { func (h *dnsHc) SetTCPTransport() { h.c.Net = "tcp" + // update the dialer accordingly with the protocol changed + h.setDialer() } func (h *dnsHc) GetReadTimeout() time.Duration { @@ -151,12 +159,49 @@ func (h *dnsHc) send(addr string) error { return err } +// SetLocalAddress sets the local address in transport. +func (h *dnsHc) SetLocalAddress(localAddr net.IP) { + h.localAddress = localAddr + h.setDialer() +} + +// GetLocalAddress returns the local address in transport. +func (h *dnsHc) GetLocalAddress() net.IP { + return h.localAddress +} + +// setDialer sets the local address in the underlying dialer +func (h *dnsHc) setDialer() { + if h.localAddress == nil { + if h.c.Dialer != nil { + h.c.Dialer.LocalAddr = nil + } + return + } + if h.c.Dialer == nil { + h.c.Dialer = new(net.Dialer) + setDefaultTimeout(h.c) + } + if h.c.Net == "udp" { + h.c.Dialer.LocalAddr = &net.UDPAddr{IP: h.localAddress} + } else { + h.c.Dialer.LocalAddr = &net.TCPAddr{IP: h.localAddress} + } +} + +// setDefaultTimeout sets the default read and write timeout values for the DNS client to 1 second. +func setDefaultTimeout(c *dns.Client) { + c.ReadTimeout = 1 * time.Second + c.WriteTimeout = 1 * time.Second +} + // dohHc is a health checker for a DNS-over-HTTPS (DoH) endpoint. type dohHc struct { client *http.Client recursionDesired bool domain string proxyName string + localAddress net.IP } func (h *dohHc) Check(p *Proxy) error { @@ -244,3 +289,18 @@ func (h *dohHc) GetWriteTimeout() time.Duration { func (h *dohHc) SetWriteTimeout(t time.Duration) { h.client.Timeout = t } + +func (h *dohHc) SetLocalAddress(localAddr net.IP) { + h.localAddress = localAddr + httpTransport := h.client.Transport.(*http.Transport) + if localAddr == nil { + httpTransport.DialContext = nil + return + } + dialer := &net.Dialer{LocalAddr: &net.TCPAddr{IP: localAddr}} + httpTransport.DialContext = dialer.DialContext +} + +func (h *dohHc) GetLocalAddress() net.IP { + return h.localAddress +} diff --git a/plugin/pkg/proxy/health_test.go b/plugin/pkg/proxy/health_test.go index 1a2d9f295..652da3708 100644 --- a/plugin/pkg/proxy/health_test.go +++ b/plugin/pkg/proxy/health_test.go @@ -1,6 +1,8 @@ package proxy import ( + "crypto/tls" + "net" "net/http" "net/http/httptest" "sync/atomic" @@ -200,3 +202,44 @@ func TestHealthDomain(t *testing.T) { t.Errorf("Expected number of health checks with Domain==%s to be %d, got %d", hcDomain, 1, i1) } } + +func TestHealthLocalAddress(t *testing.T) { + hc := NewHealthChecker("TestHealthLocalAddress", transport.DNS, true, ".") + hc.SetReadTimeout(10 * time.Millisecond) + hc.SetWriteTimeout(10 * time.Millisecond) + currLocalAddress := hc.GetLocalAddress() + if currLocalAddress != nil { + t.Errorf("Expected local address to be nil, got %s", currLocalAddress.String()) + } + + hc.SetLocalAddress(net.ParseIP("127.0.0.1")) + dnsClient := hc.(*dnsHc).c + if dnsClient.Dialer.LocalAddr.String() != "127.0.0.1:0" { + t.Errorf("Expected local address to be 127.0.0.1:0, got %s", dnsClient.Dialer.LocalAddr.String()) + } + + // check type of underlying transport + _, ok := dnsClient.Dialer.LocalAddr.(*net.UDPAddr) + if !ok { + t.Error("Expected local address to be udp") + } + + // set TCP transport + hc.SetTCPTransport() + + // check update of underlying transport + _, ok = dnsClient.Dialer.LocalAddr.(*net.TCPAddr) + if !ok { + t.Error("Expected local address to be tcp") + } + + tlsConfig := new(tls.Config) + // set TLS transport + hc.SetTLSConfig(tlsConfig) + + // check update of underlying transport + _, ok = dnsClient.Dialer.LocalAddr.(*net.TCPAddr) + if !ok { + t.Error("Expected local address to be tcp") + } +} diff --git a/plugin/pkg/proxy/persistent.go b/plugin/pkg/proxy/persistent.go index 64501654b..424891bd1 100644 --- a/plugin/pkg/proxy/persistent.go +++ b/plugin/pkg/proxy/persistent.go @@ -2,6 +2,7 @@ package proxy import ( "crypto/tls" + "net" "net/http" "sort" "sync" @@ -28,6 +29,7 @@ type Transport struct { tlsConfig *tls.Config httpClient *http.Client proxyName string + localAddress net.IP mu sync.Mutex stop chan struct{} @@ -171,6 +173,11 @@ func (t *Transport) SetTLSConfig(cfg *tls.Config) { t.tlsConfig = cfg } // GetTLSConfig returns the TLS config in transport. func (t *Transport) GetTLSConfig() *tls.Config { return t.tlsConfig } +// SetLocalAddress sets the local address in transport. +func (t *Transport) SetLocalAddress(addr net.IP) { + t.localAddress = addr +} + const ( defaultExpire = 10 * time.Second minDialTimeout = 1 * time.Second diff --git a/plugin/pkg/proxy/proxy.go b/plugin/pkg/proxy/proxy.go index 676786068..81e745244 100644 --- a/plugin/pkg/proxy/proxy.go +++ b/plugin/pkg/proxy/proxy.go @@ -2,6 +2,7 @@ package proxy import ( "crypto/tls" + "net" "net/http" "runtime" "sync/atomic" @@ -135,6 +136,20 @@ func (p *Proxy) incrementFails() { atomic.AddUint32(&p.fails, 1) } +// SetLocalAddress sets the local address for the proxy, used as the source address for outbound connections. +func (p *Proxy) SetLocalAddress(addr net.IP) { + p.transport.SetLocalAddress(addr) + if p.transport.httpClient != nil { + httpTransport := p.transport.httpClient.Transport.(*http.Transport) + if addr == nil { + httpTransport.DialContext = nil + return + } + dialer := &net.Dialer{LocalAddr: &net.TCPAddr{IP: addr}} + httpTransport.DialContext = dialer.DialContext + } +} + const ( maxTimeout = 2 * time.Second )