From e4990abfa32261c3acf10aa375122b0c7d4c61cb Mon Sep 17 00:00:00 2001 From: Filippo125 Date: Fri, 10 Jul 2026 02:38:22 +0200 Subject: [PATCH] feat(forward): add source_address directive (#8011) * fix(dnssec): avoid caching empty signing results (#7996) Signed-off-by: Filippo * save only Signed-off-by: Filippo * do #8008 Signed-off-by: Filippo * Fix Address used if tcp Signed-off-by: Filippo * Fix bad using of dialer type Signed-off-by: Filippo * core: Add full TSIG verification in gRPC transport (#8006) * core: Add full TSIG verification in gRPC transport This PR add full TSIG verification in gRPC using dns.TsigVerify() so invalid signatures and timestamps are correctly detected instead of only checking key presence. Signed-off-by: Yong Tang * Fix Signed-off-by: Yong Tang * Fix Signed-off-by: Yong Tang --------- Signed-off-by: Yong Tang Signed-off-by: Filippo * core: Add full TSIG verification in QUIC transport (#8007) * core: Add full TSIG verification in QUIC transport This PR add full TSIG verification in QUIC using dns.TsigVerify() Signed-off-by: Yong Tang * Fix Signed-off-by: Yong Tang --------- Signed-off-by: Yong Tang Signed-off-by: Filippo * fix(test): deduplicate TSIG test helpers (#8009) Signed-off-by: Filippo * fix(dnssec): return nil sigs on sign error (#7999) Signed-off-by: Filippo * fix(dnssec): return nil from ParseKeyFile on error (#8000) Signed-off-by: Filippo * fix(dnsserver): allow view server blocks in any declaration order (#8001) When using the view plugin, filtered and unfiltered server blocks can share the same zone and port. The zone overlap validation rejected this configuration when the unfiltered block was not declared last, because filtered configs treated an already-registered zone as an error. Skip the 'already defined' check for configs that have filter functions, since they are expected to coexist with an unfiltered catch-all block on the same zone/port. Fixes #7733 Signed-off-by: umut-polat <52835619+umut-polat@users.noreply.github.com> Signed-off-by: Filippo * fix(doh): use per-connection local address for PROXY protocol (#8005) Signed-off-by: Filippo * fix(transfer): batch AXFR records by message size instead of count (#8002) Signed-off-by: Filippo * fix(tls): use temp dir for keylog test path (#8010) Signed-off-by: Filippo * Rename local_address option to source_address Signed-off-by: Filippo * Rename local_address also in readme Add test Signed-off-by: Filippo * Resolve change request in pr Signed-off-by: Filippo * fix ci lint Signed-off-by: Filippo * Improve doc on source_address routing needs Signed-off-by: Filippo * Remove added timeout Signed-off-by: Filippo * add use of source address also for health check query Signed-off-by: Filippo * remove untrailing newline from health_test.go Signed-off-by: Filippo * fix file format Signed-off-by: Filippo * Update plugin/forward/setup_test.go Co-authored-by: Ville Vesilehto Signed-off-by: Filippo * Remove dead code in TestHealthLocalAddress Signed-off-by: Filippo * Fix misspelling Signed-off-by: Filippo * Try to set default timeout Signed-off-by: Filippo * fix format in health.go Signed-off-by: Filippo --------- Signed-off-by: Filippo Signed-off-by: Yong Tang Signed-off-by: umut-polat <52835619+umut-polat@users.noreply.github.com> Co-authored-by: Ville Vesilehto Co-authored-by: Yong Tang Co-authored-by: Umut Polat <52835619+umut-polat@users.noreply.github.com> Co-authored-by: Cedric Wang --- plugin/forward/README.md | 2 ++ plugin/forward/forward.go | 1 + plugin/forward/setup.go | 13 +++++++ plugin/forward/setup_test.go | 40 +++++++++++++++++++++ plugin/pkg/proxy/connect.go | 19 +++++++--- plugin/pkg/proxy/health.go | 64 +++++++++++++++++++++++++++++++-- plugin/pkg/proxy/health_test.go | 43 ++++++++++++++++++++++ plugin/pkg/proxy/persistent.go | 7 ++++ plugin/pkg/proxy/proxy.go | 15 ++++++++ 9 files changed, 197 insertions(+), 7 deletions(-) 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 )