feat(forward): add source_address directive (#8011)

* fix(dnssec): avoid caching empty signing results (#7996)

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* save only

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* do #8008

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Fix Address used if tcp

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Fix bad using of dialer type

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* 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 <yong.tang.github@outlook.com>

* Fix

Signed-off-by: Yong Tang <yong.tang.github@outlook.com>

* Fix

Signed-off-by: Yong Tang <yong.tang.github@outlook.com>

---------

Signed-off-by: Yong Tang <yong.tang.github@outlook.com>
Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* 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 <yong.tang.github@outlook.com>

* Fix

Signed-off-by: Yong Tang <yong.tang.github@outlook.com>

---------

Signed-off-by: Yong Tang <yong.tang.github@outlook.com>
Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* fix(test): deduplicate TSIG test helpers (#8009)

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* fix(dnssec): return nil sigs on sign error (#7999)

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* fix(dnssec): return nil from ParseKeyFile on error (#8000)

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* 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 <filippo.ferrazini@gmail.com>

* fix(doh): use per-connection local address for PROXY protocol (#8005)

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* fix(transfer): batch AXFR records by message size instead of count (#8002)

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* fix(tls): use temp dir for keylog test path (#8010)

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Rename local_address option to source_address

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Rename local_address also in readme
Add test

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Resolve change request in pr

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* fix ci lint

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Improve doc on source_address routing needs

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Remove added timeout

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* add use of source address also for health check query

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* remove untrailing newline from health_test.go

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* fix file format

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Update plugin/forward/setup_test.go

Co-authored-by: Ville Vesilehto <ville@vesilehto.fi>
Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Remove dead code in TestHealthLocalAddress

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Fix misspelling

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* Try to set default timeout

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

* fix format in health.go

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>

---------

Signed-off-by: Filippo <filippo.ferrazini@gmail.com>
Signed-off-by: Yong Tang <yong.tang.github@outlook.com>
Signed-off-by: umut-polat <52835619+umut-polat@users.noreply.github.com>
Co-authored-by: Ville Vesilehto <ville@vesilehto.fi>
Co-authored-by: Yong Tang <yong.tang.github@outlook.com>
Co-authored-by: Umut Polat <52835619+umut-polat@users.noreply.github.com>
Co-authored-by: Cedric Wang <wangzongqi@msn.com>
This commit is contained in:
Filippo125
2026-07-10 02:38:22 +02:00
committed by GitHub
parent adba3b3703
commit e4990abfa3
9 changed files with 197 additions and 7 deletions

View File

@@ -59,6 +59,7 @@ forward FROM TO... {
next RCODE_1 [RCODE_2] [RCODE_3...] next RCODE_1 [RCODE_2] [RCODE_3...]
failfast_all_unhealthy_upstreams failfast_all_unhealthy_upstreams
failover RCODE_1 [RCODE_2] [RCODE_3...] failover RCODE_1 [RCODE_2] [RCODE_3...]
source_address IP
resolver IP[:PORT] [IP[:PORT]...] 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. * `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. * `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. * `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. * `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 Also note the TLS config is "global" for the whole forwarding proxy if you need a different

View File

@@ -61,6 +61,7 @@ type Forward struct {
failfastUnhealthyUpstreams bool failfastUnhealthyUpstreams bool
failoverRcodes []int failoverRcodes []int
maxConnectAttempts uint32 maxConnectAttempts uint32
sourceAddress net.IP
// Hostname resolution fields // Hostname resolution fields
resolver []string // custom resolver IPs for hostname TO resolution resolver []string // custom resolver IPs for hostname TO resolution

View File

@@ -257,6 +257,10 @@ func parseStanza(c *caddy.Controller) (*Forward, error) {
f.proxies[i].GetHealthchecker().SetTCPTransport() f.proxies[i].GetHealthchecker().SetTCPTransport()
} }
f.proxies[i].GetHealthchecker().SetDomain(f.opts.HCDomain) 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 return f, nil
@@ -499,6 +503,15 @@ func parseBlock(c *caddy.Controller, f *Forward) error {
} }
} }
f.resolver = args 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: default:
return c.Errf("unknown property '%s'", c.Val()) return c.Errf("unknown property '%s'", c.Val())
} }

View File

@@ -3,6 +3,7 @@ package forward
import ( import (
"context" "context"
"fmt" "fmt"
"net"
"os" "os"
"reflect" "reflect"
"strings" "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) { func TestSplitZone(t *testing.T) {
tests := []struct { tests := []struct {
input string input string

View File

@@ -97,12 +97,21 @@ func (t *Transport) Dial(proto string) (*persistConn, bool, error) {
reqTime := time.Now() reqTime := time.Now()
timeout := t.dialTimeout() timeout := t.dialTimeout()
if proto == "tcp-tls" { dialer := &net.Dialer{Timeout: timeout}
conn, err := dns.DialTimeoutWithTLS("tcp", t.addr, t.tlsConfig, timeout)
t.updateDialTimeout(time.Since(reqTime)) if t.localAddress != nil {
return &persistConn{c: conn, created: time.Now()}, false, err 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)) t.updateDialTimeout(time.Since(reqTime))
return &persistConn{c: conn, created: time.Now()}, false, err return &persistConn{c: conn, created: time.Now()}, false, err
} }

View File

@@ -3,6 +3,7 @@ package proxy
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"net"
"net/http" "net/http"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -28,6 +29,8 @@ type HealthChecker interface {
SetReadTimeout(time.Duration) SetReadTimeout(time.Duration)
GetWriteTimeout() time.Duration GetWriteTimeout() time.Duration
SetWriteTimeout(time.Duration) SetWriteTimeout(time.Duration)
SetLocalAddress(net.IP)
GetLocalAddress() net.IP
} }
// dnsHc is a health checker for a DNS endpoint (DNS, and DoT). // dnsHc is a health checker for a DNS endpoint (DNS, and DoT).
@@ -37,6 +40,8 @@ type dnsHc struct {
domain string domain string
proxyName string proxyName string
localAddress net.IP
} }
const defaultTimeout = 1 * time.Second const defaultTimeout = 1 * time.Second
@@ -47,8 +52,7 @@ func NewHealthChecker(proxyName, protocol string, recursionDesired bool, domain
case transport.DNS, transport.TLS: case transport.DNS, transport.TLS:
c := new(dns.Client) c := new(dns.Client)
c.Net = "udp" c.Net = "udp"
c.ReadTimeout = defaultTimeout setDefaultTimeout(c)
c.WriteTimeout = defaultTimeout
return &dnsHc{ return &dnsHc{
c: c, c: c,
@@ -78,6 +82,8 @@ func NewHealthChecker(proxyName, protocol string, recursionDesired bool, domain
func (h *dnsHc) SetTLSConfig(cfg *tls.Config) { func (h *dnsHc) SetTLSConfig(cfg *tls.Config) {
h.c.Net = "tcp-tls" h.c.Net = "tcp-tls"
h.c.TLSConfig = cfg h.c.TLSConfig = cfg
// update the dialer accordingly with the protocol changed
h.setDialer()
} }
func (h *dnsHc) GetTLSConfig() *tls.Config { func (h *dnsHc) GetTLSConfig() *tls.Config {
@@ -100,6 +106,8 @@ func (h *dnsHc) GetDomain() string {
func (h *dnsHc) SetTCPTransport() { func (h *dnsHc) SetTCPTransport() {
h.c.Net = "tcp" h.c.Net = "tcp"
// update the dialer accordingly with the protocol changed
h.setDialer()
} }
func (h *dnsHc) GetReadTimeout() time.Duration { func (h *dnsHc) GetReadTimeout() time.Duration {
@@ -151,12 +159,49 @@ func (h *dnsHc) send(addr string) error {
return err 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. // dohHc is a health checker for a DNS-over-HTTPS (DoH) endpoint.
type dohHc struct { type dohHc struct {
client *http.Client client *http.Client
recursionDesired bool recursionDesired bool
domain string domain string
proxyName string proxyName string
localAddress net.IP
} }
func (h *dohHc) Check(p *Proxy) error { func (h *dohHc) Check(p *Proxy) error {
@@ -244,3 +289,18 @@ func (h *dohHc) GetWriteTimeout() time.Duration {
func (h *dohHc) SetWriteTimeout(t time.Duration) { func (h *dohHc) SetWriteTimeout(t time.Duration) {
h.client.Timeout = t 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
}

View File

@@ -1,6 +1,8 @@
package proxy package proxy
import ( import (
"crypto/tls"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync/atomic" "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) 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")
}
}

View File

@@ -2,6 +2,7 @@ package proxy
import ( import (
"crypto/tls" "crypto/tls"
"net"
"net/http" "net/http"
"sort" "sort"
"sync" "sync"
@@ -28,6 +29,7 @@ type Transport struct {
tlsConfig *tls.Config tlsConfig *tls.Config
httpClient *http.Client httpClient *http.Client
proxyName string proxyName string
localAddress net.IP
mu sync.Mutex mu sync.Mutex
stop chan struct{} stop chan struct{}
@@ -171,6 +173,11 @@ func (t *Transport) SetTLSConfig(cfg *tls.Config) { t.tlsConfig = cfg }
// GetTLSConfig returns the TLS config in transport. // GetTLSConfig returns the TLS config in transport.
func (t *Transport) GetTLSConfig() *tls.Config { return t.tlsConfig } 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 ( const (
defaultExpire = 10 * time.Second defaultExpire = 10 * time.Second
minDialTimeout = 1 * time.Second minDialTimeout = 1 * time.Second

View File

@@ -2,6 +2,7 @@ package proxy
import ( import (
"crypto/tls" "crypto/tls"
"net"
"net/http" "net/http"
"runtime" "runtime"
"sync/atomic" "sync/atomic"
@@ -135,6 +136,20 @@ func (p *Proxy) incrementFails() {
atomic.AddUint32(&p.fails, 1) 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 ( const (
maxTimeout = 2 * time.Second maxTimeout = 2 * time.Second
) )