diff --git a/cache.go b/cache.go index 30c9d3f..4684287 100644 --- a/cache.go +++ b/cache.go @@ -5,6 +5,7 @@ import ( "expvar" "math" "math/rand" + "strings" "sync" "time" @@ -48,6 +49,12 @@ type CacheOptions struct { // Allows control over the order of answer RRs in cached responses. Default is to keep // the order if nil. ShuffleAnswerFunc AnswerShuffleFunc + + // If enabled, will return NXDOMAIN for every name query under another name that is + // already cached as NXDOMAIN. For example, if example.com is in the cache with + // NXDOMAIN, a query for www.example.com will also immediately return NXDOMAIN. + // See RFC8020. + HardenBelowNXDOMAIN bool } // NewCache returns a new instance of a Cache resolver. @@ -128,6 +135,26 @@ func (r *Cache) answerFromCache(q *dns.Msg) (*dns.Msg, bool) { } r.mu.Unlock() + // We couldn't find it in the cache, but a parent domain may already be with NXDOMAIN. + // Return that instead if enabled. + if answer == nil && r.HardenBelowNXDOMAIN { + name := q.Question[0].Name + newQ := q.Copy() + fragments := strings.Split(name, ".") + r.mu.Lock() + for i := 1; i < len(fragments)-1; i++ { + newQ.Question[0].Name = strings.Join(fragments[i:], ".") + if a := r.lru.get(newQ); a != nil { + if a.Rcode == dns.RcodeNameError { + r.mu.Unlock() + return nxdomain(q), true + } + break + } + } + r.mu.Unlock() + } + // Return a cache-miss if there's no answer record in the map if answer == nil { return nil, false diff --git a/cache_test.go b/cache_test.go index 8bf0c9e..9bbb7c0 100644 --- a/cache_test.go +++ b/cache_test.go @@ -99,6 +99,39 @@ func TestCacheNXDOMAIN(t *testing.T) { require.Equal(t, 1, r.HitCount()) } +func TestCacheHardenBelowNXDOMAIN(t *testing.T) { + var ci ClientInfo + q := new(dns.Msg) + r := &TestResolver{ + ResolveFunc: func(q *dns.Msg, ci ClientInfo) (*dns.Msg, error) { + a := new(dns.Msg) + a.SetReply(q) + a.SetRcode(q, dns.RcodeNameError) + return a, nil + }, + } + + opt := CacheOptions{ + GCPeriod: time.Minute, + HardenBelowNXDOMAIN: true, + } + c := NewCache("test-cache", r, opt) + + // Cache an NXDOMAIN for the parent domain + q.SetQuestion("test.com.", dns.TypeA) + _, err := c.Resolve(q, ci) + require.NoError(t, err) + require.Equal(t, 1, r.HitCount()) + + // A sub-domain query should also return NXDOMAIN based on the cached + // record for the parent if HardenBelowNXDOMAIN is enabled. + q.SetQuestion("not.exist.test.com.", dns.TypeA) + a, err := c.Resolve(q, ci) + require.NoError(t, err) + require.Equal(t, 1, r.HitCount()) + require.Equal(t, dns.RcodeNameError, a.Rcode) +} + func TestRoundRobinShuffle(t *testing.T) { msg := &dns.Msg{ Answer: []dns.RR{ diff --git a/cmd/routedns/config.go b/cmd/routedns/config.go index 405e95f..773b0b9 100644 --- a/cmd/routedns/config.go +++ b/cmd/routedns/config.go @@ -70,9 +70,10 @@ type group struct { EDNS0Data []byte `toml:"edns0-data"` // EDNS0 modifier option data // Cache options - CacheSize int `toml:"cache-size"` // Max number of items to keep in the cache. Default 0 == unlimited - CacheNegativeTTL uint32 `toml:"cache-negative-ttl"` // TTL to apply to negative responses, default 60. - CacheAnswerShuffle string `toml:"cache-answer-shuffle"` // Algorithm to use for modifying the response order of cached items + CacheSize int `toml:"cache-size"` // Max number of items to keep in the cache. Default 0 == unlimited + CacheNegativeTTL uint32 `toml:"cache-negative-ttl"` // TTL to apply to negative responses, default 60. + CacheAnswerShuffle string `toml:"cache-answer-shuffle"` // Algorithm to use for modifying the response order of cached items + CacheHardenBelowNXDOMAIN bool `toml:"cache-harden-below-nxdomain"` // Return NXDOMAIN if an NXDOMAIN is cached for a parent domain // Blocklist options Blocklist []string // Blocklist rules, only used by "blocklist" type diff --git a/cmd/routedns/main.go b/cmd/routedns/main.go index f3ae24e..c3dfdb2 100644 --- a/cmd/routedns/main.go +++ b/cmd/routedns/main.go @@ -428,10 +428,11 @@ func instantiateGroup(id string, g group, resolvers map[string]rdns.Resolver) er return fmt.Errorf("unsupported shuffle function %q", g.CacheAnswerShuffle) } opt := rdns.CacheOptions{ - GCPeriod: time.Duration(g.GCPeriod) * time.Second, - Capacity: g.CacheSize, - NegativeTTL: g.CacheNegativeTTL, - ShuffleAnswerFunc: shuffleFunc, + GCPeriod: time.Duration(g.GCPeriod) * time.Second, + Capacity: g.CacheSize, + NegativeTTL: g.CacheNegativeTTL, + ShuffleAnswerFunc: shuffleFunc, + HardenBelowNXDOMAIN: g.CacheHardenBelowNXDOMAIN, } resolvers[id] = rdns.NewCache(id, gr[0], opt) case "response-blocklist-ip", "response-blocklist-cidr": // "response-blocklist-cidr" has been retired/renamed to "response-blocklist-ip" diff --git a/doc/configuration.md b/doc/configuration.md index 2f13c67..2e1b573 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -293,6 +293,7 @@ Options: - `cache-size` - Max number of responses to cache. Defaults to 0 which means no limit. Optional - `cache-negative-ttl` - TTL (in seconds) to apply to responses without a SOA. Default: 60. Optional - `cache-answer-shuffle` - Specifies a method for changing the order of cached A/AAAA answer records. Possible values `random` or `round-robin`. Defaults to static responses if not set. +- `cache-harden-below-nxdomain` - Return NXDOMAIN for sudomain queries if the parent domain has a cached NXDOMAIN. See [RFC8020](https://tools.ietf.org/html/rfc8020). #### Examples