diff --git a/README.md b/README.md index 3e9c99104..f2669a916 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,7 @@ If one or more domains are specified, that upstream (`upstreamString`) is used o 1. An empty domain specification, `//` has the special meaning of "unqualified names only", which will be used to resolve names with a single label in them, or with exactly two labels in case of `DS` requests. 1. More specific domains take precedence over less specific domains, so: `--upstream=[/host.com/]1.2.3.4 --upstream=[/www.host.com/]2.3.4.5` will send queries for `*.host.com` to `1.2.3.4`, except `*.www.host.com`, which will go to `2.3.4.5`. 1. The special server address `#` means, "use the common servers", so: `--upstream=[/host.com/]1.2.3.4 --upstream=[/www.host.com/]#` will send queries for `*.host.com` to `1.2.3.4`, except `*.www.host.com` which will be forwarded as usual. +1. The special server address `-` means "skip, do not proxy the request, return NXDOMAIN", so: `--upstream=1.2.3.4 --upstream=[/host.com/]-` will respond with NXDOMAIN for any queries for `host.com` or its subdomains (and will not forward the request to any upstream), but all other requests will be forwarded to the nameserver at `1.2.3.4` as usual. 1. The wildcard `*` has special meaning of "any sub-domain", so: `--upstream=[/*.host.com/]1.2.3.4` will send queries for `*.host.com` to `1.2.3.4`, but `host.com` will be forwarded to default upstreams. Sends requests for `*.local` domains to `192.168.0.1:53`. Other requests are sent to `8.8.8.8:53`: @@ -348,13 +349,14 @@ Sends requests for `*.host.com` to `1.1.1.1:53` except for `host.com` which is s ; ``` -Sends requests for `com` (and its subdomains) to `1.2.3.4:53`, requests for other top-level domains to `1.1.1.1:53`, and all other requests to `8.8.8.8:53`: +Respond with NXDOMAIN for requests for `mydomain.com` (and its subdomains), send requests for `com` (and its subdomains) to `1.2.3.4:53`, requests for other top-level domains to `1.1.1.1:53`, and all other requests to `8.8.8.8:53`: ```shell ./dnsproxy \ -u "8.8.8.8:53" \ -u "[//]1.1.1.1:53" \ -u "[/com/]1.2.3.4:53" \ + -u "[/mydomain.com/]-" \ ; ``` diff --git a/proxy/proxy.go b/proxy/proxy.go index 6c22e0e46..c5dfd8312 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -529,10 +529,14 @@ func (p *Proxy) Addr(proto Proto) (addr net.Addr) { // selectUpstreams returns the upstreams to use for the specified host. It // firstly considers custom upstreams if those aren't empty and then the // configured ones. The returned slice may be empty or nil. -func (p *Proxy) selectUpstreams(d *DNSContext) (upstreams []upstream.Upstream, isPrivate bool) { +func (p *Proxy) selectUpstreams(d *DNSContext) (upstreams []upstream.Upstream, isPrivate bool, isSkipped bool) { q := d.Req.Question[0] host := q.Name + if p.UpstreamConfig.checkSkipped(host) { + return nil, false, true + } + if d.RequestedPrivateRDNS != (netip.Prefix{}) || p.shouldStripDNS64(d.Req) { // Use private upstreams. private := p.PrivateRDNSUpstreamConfig @@ -541,7 +545,7 @@ func (p *Proxy) selectUpstreams(d *DNSContext) (upstreams []upstream.Upstream, i upstreams = private.getUpstreamsForDomain(host) } - return upstreams, true + return upstreams, true, false } getUpstreams := (*UpstreamConfig).getUpstreamsForDomain @@ -553,12 +557,12 @@ func (p *Proxy) selectUpstreams(d *DNSContext) (upstreams []upstream.Upstream, i // Try to use custom. upstreams = getUpstreams(custom.upstream, host) if len(upstreams) > 0 { - return upstreams, false + return upstreams, false, false } } // Use configured. - return getUpstreams(p.UpstreamConfig, host), false + return getUpstreams(p.UpstreamConfig, host), false, false } // replyFromUpstream tries to resolve the request via configured upstream @@ -566,7 +570,14 @@ func (p *Proxy) selectUpstreams(d *DNSContext) (upstreams []upstream.Upstream, i func (p *Proxy) replyFromUpstream(d *DNSContext) (ok bool, err error) { req := d.Req - upstreams, isPrivate := p.selectUpstreams(d) + upstreams, isPrivate, isSkipped := p.selectUpstreams(d) + + if isSkipped { + p.logger.Debug("skipping domain", "domain", d.Req.Question[0].Name) + d.Res = p.messages.NewMsgNXDOMAIN(req) + return true, nil + } + if len(upstreams) == 0 { d.Res = p.messages.NewMsgNXDOMAIN(req) diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index 4eb2c79ad..06b7c0227 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -928,6 +928,45 @@ func TestRefuseAny(t *testing.T) { assert.Equal(t, dns.RcodeNotImplemented, r.Rcode) } +func TestSkippedDomain(t *testing.T) { + dnsProxy := mustNew(t, &Config{ + Logger: slogutil.NewDiscardLogger(), + UDPListenAddr: []*net.UDPAddr{net.UDPAddrFromAddrPort(localhostAnyPort)}, + TCPListenAddr: []*net.TCPAddr{net.TCPAddrFromAddrPort(localhostAnyPort)}, + UpstreamConfig: newTestUpstreamConfig(t, defaultTimeout, testDefaultUpstreamAddr, "[/google.com/]-"), + TrustedProxies: defaultTrustedProxies, + RatelimitSubnetLenIPv4: 24, + RatelimitSubnetLenIPv6: 64, + RefuseAny: false, + }) + + // Start listening + ctx := context.Background() + err := dnsProxy.Start(ctx) + require.NoError(t, err) + testutil.CleanupAndRequireSuccess(t, func() (err error) { return dnsProxy.Shutdown(ctx) }) + + // Create a DNS-over-UDP client connection + addr := dnsProxy.Addr(ProtoUDP) + client := &dns.Client{ + Net: string(ProtoUDP), + Timeout: testTimeout, + } + + // Create a DNS request + request := (&dns.Msg{ + MsgHdr: dns.MsgHdr{ + Id: dns.Id(), + RecursionDesired: true, + }, + }).SetQuestion("google.com.", dns.TypeANY) + + r, _, err := client.Exchange(request, addr.String()) + require.NoError(t, err) + + assert.Equal(t, dns.RcodeNameError, r.Rcode) +} + func TestInvalidDNSRequest(t *testing.T) { dnsProxy := mustNew(t, &Config{ Logger: slogutil.NewDiscardLogger(), diff --git a/proxy/upstreams.go b/proxy/upstreams.go index c618e7de7..f8491b957 100644 --- a/proxy/upstreams.go +++ b/proxy/upstreams.go @@ -27,6 +27,18 @@ type UpstreamConfig struct { // SpecifiedDomainUpstreams maps the specific domain names to the upstreams. SpecifiedDomainUpstreams map[string][]upstream.Upstream + // SkippedDomains is a set of domains which will never be proxied to any + // upstream server. The dnsproxy will return NXDOMAIN for any of these + // domains or their subdomains. + SkippedDomains *container.MapSet[string] + + // SkippedDomainsExclusions is a set of domains which will be proxied to an + // upstream server, regardless of a match in SkippedDomains. This is mainly + // used when a wildcard subdomain is used in the upstream definition (e.g. + // *.my.domain), where my.domain would be proxied to an upstream, but any + // subdomain of it would be skipped. + SkippedDomainsExclusions *container.MapSet[string] + // SubdomainExclusions is set of domains with subdomains exclusions. SubdomainExclusions *container.MapSet[string] @@ -63,16 +75,24 @@ var _ io.Closer = (*UpstreamConfig)(nil) // // [/domain1/../domainN/]# // +// Using a hyphen "-" as the will ensure that the matched +// domain(s) will never be recursed to upstream nameservers. The dnsproxy will +// respond with NXDOMAIN for any matched domain or subdomain. For example: +// +// [/domain1/../domainN/]- +// // So the following config: // // [/host.com/]1.2.3.4 // [/www.host.com/]2.3.4.5" +// [/domain.local/]- // [/maps.host.com/news.host.com/]# // 3.4.5.6 // // will send queries for *.host.com to 1.2.3.4. Except for *.www.host.com, -// which will go to 2.3.4.5. And *.maps.host.com or *.news.host.com, which -// will go to default server 3.4.5.6 with all other domains. +// which will go to 2.3.4.5. Any requests to *.domain.local or domain.local +// will be answered with NXDOMAIN. And *.maps.host.com or *.news.host.com, +// which will go to default server 3.4.5.6 with all other domains. // // To exclude top level domain from reserved upstreams querying you could use // the following: @@ -108,6 +128,8 @@ func ParseUpstreamsConfig( domainReservedUpstreams: map[string][]upstream.Upstream{}, specifiedDomainUpstreams: map[string][]upstream.Upstream{}, subdomainsOnlyUpstreams: map[string][]upstream.Upstream{}, + skippedDomains: container.NewMapSet[string](), + skippedDomainsExclusions: container.NewMapSet[string](), subdomainsOnlyExclusions: container.NewMapSet[string](), } @@ -161,6 +183,17 @@ type configParser struct { // corresponding upstreams. subdomainsOnlyUpstreams map[string][]upstream.Upstream + // skippedDomains is a set of domains which should never be looked up on + // any upstream server. + skippedDomains *container.MapSet[string] + + // skippedDomainsExclusions is a set of domains which should be looked up on + // any upstream server, even though it matches an entry in skippedDomains. + // This is mainly used when a wildcard subdomain is used in the upstream + // definition (e.g. *.my.domain), where my.domain would be proxied to an + // upstream, but any subdomain of it would be skipped. + skippedDomainsExclusions *container.MapSet[string] + // subdomainsOnlyExclusions is set of domains with subdomains exclusions. subdomainsOnlyExclusions *container.MapSet[string] @@ -188,6 +221,8 @@ func (p *configParser) parse(lines []string) (c *UpstreamConfig, err error) { DomainReservedUpstreams: p.domainReservedUpstreams, SpecifiedDomainUpstreams: p.specifiedDomainUpstreams, SubdomainExclusions: p.subdomainsOnlyExclusions, + SkippedDomains: p.skippedDomains, + SkippedDomainsExclusions: p.skippedDomainsExclusions, }, errors.Join(errs...) } @@ -203,6 +238,12 @@ func (p *configParser) parseLine(idx int, confLine string) (err error) { return err } + if upstreams[0] == "-" && len(domains) > 0 { + p.specifySkipped(domains) + + return nil + } + if upstreams[0] == "#" && len(domains) > 0 { p.excludeFromReserved(domains) @@ -253,6 +294,16 @@ func splitConfigLine(confLine string) (upstreams, domains []string, err error) { return strings.Fields(upstreamsLine), domains, nil } +func (p *configParser) specifySkipped(domains []string) { + for _, domain := range domains { + if strings.HasPrefix(domain, "*.") { + domain = strings.TrimPrefix(domain, "*.") + p.skippedDomainsExclusions.Add(domain) + } + p.skippedDomains.Add(domain) + } +} + // specifyUpstream specifies the upstream for domains. func (p *configParser) specifyUpstream(domains []string, u string, idx int) (err error) { dnsUpstream, ok := p.upstreamsIndex[u] @@ -373,7 +424,7 @@ func ValidatePrivateConfig(uc *UpstreamConfig, privateSubnets netutil.SubnetSet) // getUpstreamsForDomain returns the upstreams specified for resolving fqdn. It // always returns the default set of upstreams if the domain is not reserved for -// any other upstreams. +// any other upstreams. If the domain is skipped, it returns nil. // // More specific domains take priority over less specific ones. For example, if // the upstreams specified for the following domains: @@ -384,6 +435,10 @@ func ValidatePrivateConfig(uc *UpstreamConfig, privateSubnets netutil.SubnetSet) // The request for mail.host.com will be resolved using the upstreams specified // for host.com. func (uc *UpstreamConfig) getUpstreamsForDomain(fqdn string) (ups []upstream.Upstream) { + if uc.checkSkipped(fqdn) { + return nil + } + if len(uc.DomainReservedUpstreams) == 0 { return uc.Upstreams } @@ -413,6 +468,19 @@ func (uc *UpstreamConfig) getUpstreamsForDomain(fqdn string) (ups []upstream.Ups return uc.Upstreams } +func (uc *UpstreamConfig) checkSkipped(host string) bool { + if uc.SkippedDomainsExclusions.Has(host) { + return false + } + for host != "" { + if uc.SkippedDomains.Has(host) { + return true + } + _, host, _ = strings.Cut(host, ".") + } + return false +} + // getUpstreamsForDS is like [getUpstreamsForDomain], but intended for DS // queries only, so that it matches fqdn without the first label. // diff --git a/proxy/upstreams_internal_test.go b/proxy/upstreams_internal_test.go index a8df057e8..fcaa2f5db 100644 --- a/proxy/upstreams_internal_test.go +++ b/proxy/upstreams_internal_test.go @@ -34,6 +34,9 @@ const ( wildcardDomain = "*." + firstLevelDomain anotherSubFQDN = "another." + firstLevelDomain + "." + + skippedDomain = "skipped.domain" + skippedFQDN = skippedDomain + "." ) // Upstream URLs used in tests of [UpstreamConfig]. @@ -57,6 +60,7 @@ var testUpstreamConfigLines = []string{ "[/" + wildcardDomain + "/]" + wildcardUpstream, "[/" + generalDomain + "/]#", "[/" + subDomain + "/]" + subdomainUpstream, + "[/" + skippedDomain + "/]-", } func TestUpstreamConfig_GetUpstreamsForDomain(t *testing.T) { @@ -101,6 +105,10 @@ func TestUpstreamConfig_GetUpstreamsForDomain(t *testing.T) { name: "subdomain", in: subFQDN, want: []string{subdomainUpstream}, + }, { + name: "skipped", + in: skippedFQDN, + want: nil, }} for _, tc := range testCases { @@ -155,6 +163,10 @@ func TestUpstreamConfig_GetUpstreamsForDS(t *testing.T) { name: "subdomain", in: "label." + subFQDN, want: []string{subdomainUpstream}, + }, { + name: "skipped", + in: "label." + skippedFQDN, + want: nil, }} for _, tc := range testCases { @@ -298,6 +310,7 @@ func TestGetUpstreamsForDomain_wildcards(t *testing.T) { "[/b.a.x/]0.0.0.4", "[/*.b.a.x/]0.0.0.5", "[/*.x.z/]0.0.0.6", + "[/*.w.x.z/]-", "[/c.b.a.x/]#", } @@ -344,6 +357,14 @@ func TestGetUpstreamsForDomain_wildcards(t *testing.T) { name: "unspecified_wildcard_sub", in: "a.x.z.", want: []string{"0.0.0.6:53"}, + }, { + name: "skipped", + in: "a.w.x.z.", + want: nil, + }, { + name: "skipped_sub", + in: "a.b.w.x.z.", + want: nil, }} for _, tc := range testCases { @@ -402,6 +423,8 @@ func TestGetUpstreamsForDomain_default_wildcards(t *testing.T) { "[/*.example.org/]127.0.0.1:5303", "[/www.example.org/]127.0.0.1:5304", "[/*.www.example.org/]#", + "[/skipped.www.example.org/]-", + "[/*.skipped.example.org/]-", } uconf, err := ParseUpstreamsConfig(conf, nil) @@ -427,6 +450,22 @@ func TestGetUpstreamsForDomain_default_wildcards(t *testing.T) { name: "def_wildcard", in: "abc.www.example.org.", want: []string{"127.0.0.1:5301"}, + }, { + name: "skipped", + in: "skipped.www.example.org.", + want: nil, + }, { + name: "skipped_sub", + in: "sub.skipped.www.example.org.", + want: nil, + }, { + name: "skipped_wildcard", + in: "sub.skipped.example.org.", + want: nil, + }, { + name: "skipped_wildcard_parent", + in: "skipped.example.org.", + want: []string{"127.0.0.1:5303"}, }} for _, tc := range testCases { @@ -442,6 +481,7 @@ func BenchmarkGetUpstreamsForDomain(b *testing.B) { "[/google.com/local/]4.3.2.1", "[/www.google.com//]1.2.3.4", "[/maps.google.com/]#", + "[/skipped.google.com/]-", "[/www.google.com/]tls://1.1.1.1", "192.0.2.1", } @@ -459,6 +499,7 @@ func BenchmarkGetUpstreamsForDomain(b *testing.B) { "internal.local.", "google.", "maps.google.com.", + "skipped.google.com.", } var upstreams []upstream.Upstream