332 lines
9.4 KiB
Go
332 lines
9.4 KiB
Go
package cache
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
func testConfig() Config {
|
|
return Config{
|
|
Enabled: true, MaxEntries: 1000, MinTTL: 0, MaxTTL: 86400,
|
|
NegativeTTL: 300, ServeStale: false, StaleTTL: 0,
|
|
}
|
|
}
|
|
|
|
func reply(name string, ttl uint32, ip string) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
|
|
m = m.SetReply(m)
|
|
m.Answer = []dns.RR{&dns.A{
|
|
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
|
|
A: []byte{192, 0, 2, 1},
|
|
}}
|
|
return m
|
|
}
|
|
|
|
func keyFor(name string) Key {
|
|
return Key{Name: dns.Fqdn(name), Type: dns.TypeA, Class: dns.ClassINET}
|
|
}
|
|
|
|
func request(name string) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
|
|
return m
|
|
}
|
|
|
|
func TestPutAndGet(t *testing.T) {
|
|
c := New(testConfig())
|
|
k := keyFor("example.com")
|
|
|
|
if res := c.Get(k, request("example.com")); res.Hit {
|
|
t.Fatal("expected a miss on an empty cache")
|
|
}
|
|
if ttl := c.Put(k, reply("example.com", 300, "192.0.2.1")); ttl != 300 {
|
|
t.Fatalf("stored TTL = %d, want 300", ttl)
|
|
}
|
|
res := c.Get(k, request("example.com"))
|
|
if !res.Hit {
|
|
t.Fatal("expected a hit after storing")
|
|
}
|
|
if len(res.Msg.Answer) != 1 {
|
|
t.Fatalf("answer count = %d, want 1", len(res.Msg.Answer))
|
|
}
|
|
|
|
s := c.Stats()
|
|
if s.Hits != 1 || s.Misses != 1 {
|
|
t.Errorf("hits/misses = %d/%d, want 1/1", s.Hits, s.Misses)
|
|
}
|
|
}
|
|
|
|
func TestTTLIsClamped(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
min, max uint32
|
|
ttl uint32
|
|
want uint32
|
|
}{
|
|
{"below minimum is raised", 60, 86400, 5, 60},
|
|
{"above maximum is capped", 0, 3600, 100000, 3600},
|
|
{"within bounds is unchanged", 60, 3600, 300, 300},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := testConfig()
|
|
cfg.MinTTL, cfg.MaxTTL = tc.min, tc.max
|
|
c := New(cfg)
|
|
if got := c.Put(keyFor("example.com"), reply("example.com", tc.ttl, "192.0.2.1")); got != tc.want {
|
|
t.Errorf("stored TTL = %d, want %d", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTTLDecays is the property that matters most: a cached answer must not
|
|
// hand out a TTL that stands still, or downstream caches never expire it.
|
|
func TestTTLDecays(t *testing.T) {
|
|
c := New(testConfig())
|
|
k := keyFor("example.com")
|
|
c.Put(k, reply("example.com", 300, "192.0.2.1"))
|
|
|
|
// Reach in and age the entry rather than sleeping for real.
|
|
sh := c.shardFor(k)
|
|
sh.mu.Lock()
|
|
sh.entries[k].stored = time.Now().Add(-100 * time.Second)
|
|
sh.mu.Unlock()
|
|
|
|
res := c.Get(k, request("example.com"))
|
|
if !res.Hit {
|
|
t.Fatal("expected a hit while still fresh")
|
|
}
|
|
got := res.Msg.Answer[0].Header().Ttl
|
|
if got > 201 || got < 199 {
|
|
t.Errorf("served TTL = %d, want about 200 after 100 seconds", got)
|
|
}
|
|
}
|
|
|
|
func TestExpiredEntryIsAMiss(t *testing.T) {
|
|
c := New(testConfig())
|
|
k := keyFor("example.com")
|
|
c.Put(k, reply("example.com", 10, "192.0.2.1"))
|
|
|
|
sh := c.shardFor(k)
|
|
sh.mu.Lock()
|
|
sh.entries[k].stored = time.Now().Add(-30 * time.Second)
|
|
sh.mu.Unlock()
|
|
|
|
if res := c.Get(k, request("example.com")); res.Hit {
|
|
t.Error("an expired entry must not be served when stale serving is off")
|
|
}
|
|
}
|
|
|
|
func TestServeStale(t *testing.T) {
|
|
cfg := testConfig()
|
|
cfg.ServeStale = true
|
|
cfg.StaleTTL = 3600
|
|
c := New(cfg)
|
|
|
|
k := keyFor("example.com")
|
|
c.Put(k, reply("example.com", 10, "192.0.2.1"))
|
|
|
|
sh := c.shardFor(k)
|
|
sh.mu.Lock()
|
|
sh.entries[k].stored = time.Now().Add(-60 * time.Second)
|
|
sh.mu.Unlock()
|
|
|
|
res := c.Get(k, request("example.com"))
|
|
if !res.Hit || !res.Stale {
|
|
t.Fatalf("expected a stale hit, got hit=%v stale=%v", res.Hit, res.Stale)
|
|
}
|
|
if got := res.Msg.Answer[0].Header().Ttl; got == 0 || got > 60 {
|
|
t.Errorf("stale TTL = %d, want a short positive value", got)
|
|
}
|
|
|
|
// Past the stale window it must miss.
|
|
sh.mu.Lock()
|
|
sh.entries[k].stored = time.Now().Add(-7200 * time.Second)
|
|
sh.mu.Unlock()
|
|
if res := c.Get(k, request("example.com")); res.Hit {
|
|
t.Error("an entry past the stale window must not be served")
|
|
}
|
|
}
|
|
|
|
func TestNegativeCachingUsesSOAMinimum(t *testing.T) {
|
|
c := New(testConfig())
|
|
|
|
m := new(dns.Msg)
|
|
m.SetQuestion("missing.example.com.", dns.TypeA)
|
|
m = m.SetReply(m)
|
|
m.Rcode = dns.RcodeNameError
|
|
m.Ns = []dns.RR{&dns.SOA{
|
|
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 3600},
|
|
Ns: "ns1.example.com.",
|
|
Mbox: "hostmaster.example.com.",
|
|
Minttl: 120,
|
|
}}
|
|
|
|
k := Key{Name: "missing.example.com.", Type: dns.TypeA, Class: dns.ClassINET}
|
|
// RFC 2308: the negative TTL is the lesser of the SOA TTL and its MINIMUM.
|
|
if ttl := c.Put(k, m); ttl != 120 {
|
|
t.Errorf("negative TTL = %d, want the SOA minimum of 120", ttl)
|
|
}
|
|
if res := c.Get(k, request("missing.example.com")); !res.Hit {
|
|
t.Error("a negative answer should be cached")
|
|
}
|
|
}
|
|
|
|
func TestUncacheableResponses(t *testing.T) {
|
|
c := New(testConfig())
|
|
|
|
t.Run("servfail is not cached", func(t *testing.T) {
|
|
m := reply("fail.example.com", 300, "192.0.2.1")
|
|
m.Rcode = dns.RcodeServerFailure
|
|
if ttl := c.Put(keyFor("fail.example.com"), m); ttl != 0 {
|
|
t.Errorf("stored a SERVFAIL with TTL %d; transient failures must not be cached", ttl)
|
|
}
|
|
})
|
|
|
|
t.Run("truncated is not cached", func(t *testing.T) {
|
|
m := reply("trunc.example.com", 300, "192.0.2.1")
|
|
m.Truncated = true
|
|
if ttl := c.Put(keyFor("trunc.example.com"), m); ttl != 0 {
|
|
t.Errorf("stored a truncated response with TTL %d", ttl)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestOPTIsNotReplayed guards a subtle correctness bug: the OPT record
|
|
// describes one client's transport, not the data, so replaying it to another
|
|
// client would advertise the wrong buffer size.
|
|
func TestOPTIsNotReplayed(t *testing.T) {
|
|
c := New(testConfig())
|
|
m := reply("example.com", 300, "192.0.2.1")
|
|
m.SetEdns0(4096, true)
|
|
|
|
k := keyFor("example.com")
|
|
c.Put(k, m)
|
|
|
|
res := c.Get(k, request("example.com"))
|
|
if !res.Hit {
|
|
t.Fatal("expected a hit")
|
|
}
|
|
if res.Msg.IsEdns0() != nil {
|
|
t.Error("the cached response still carries an OPT record from the original exchange")
|
|
}
|
|
}
|
|
|
|
// TestDOBitSeparatesEntries: a DNSSEC answer carries RRSIGs that a non-DO
|
|
// client must never receive, so the two must not share a cache entry.
|
|
func TestDOBitSeparatesEntries(t *testing.T) {
|
|
c := New(testConfig())
|
|
plain := Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET, DO: false}
|
|
signed := Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET, DO: true}
|
|
|
|
c.Put(plain, reply("example.com", 300, "192.0.2.1"))
|
|
if res := c.Get(signed, request("example.com")); res.Hit {
|
|
t.Error("a DO query was served from the non-DO cache entry")
|
|
}
|
|
}
|
|
|
|
func TestFlushAndDelete(t *testing.T) {
|
|
c := New(testConfig())
|
|
for _, n := range []string{"a.example.com", "b.example.com", "c.example.com"} {
|
|
c.Put(keyFor(n), reply(n, 300, "192.0.2.1"))
|
|
}
|
|
if got := c.Stats().Entries; got != 3 {
|
|
t.Fatalf("entries = %d, want 3", got)
|
|
}
|
|
|
|
if !c.Delete(keyFor("a.example.com")) {
|
|
t.Error("Delete reported the entry was absent")
|
|
}
|
|
if got := c.Stats().Entries; got != 2 {
|
|
t.Errorf("entries after delete = %d, want 2", got)
|
|
}
|
|
|
|
if n := c.Flush(); n != 2 {
|
|
t.Errorf("Flush removed %d, want 2", n)
|
|
}
|
|
if got := c.Stats().Entries; got != 0 {
|
|
t.Errorf("entries after flush = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestFlushName(t *testing.T) {
|
|
c := New(testConfig())
|
|
c.Put(Key{Name: "example.com.", Type: dns.TypeA, Class: dns.ClassINET}, reply("example.com", 300, "192.0.2.1"))
|
|
c.Put(Key{Name: "example.com.", Type: dns.TypeAAAA, Class: dns.ClassINET}, reply("example.com", 300, "192.0.2.1"))
|
|
c.Put(Key{Name: "other.com.", Type: dns.TypeA, Class: dns.ClassINET}, reply("other.com", 300, "192.0.2.1"))
|
|
|
|
if n := c.FlushName("example.com"); n != 2 {
|
|
t.Errorf("FlushName removed %d entries, want 2 (both types)", n)
|
|
}
|
|
if got := c.Stats().Entries; got != 1 {
|
|
t.Errorf("entries remaining = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestDisabledCacheStoresNothing(t *testing.T) {
|
|
cfg := testConfig()
|
|
cfg.Enabled = false
|
|
c := New(cfg)
|
|
|
|
if ttl := c.Put(keyFor("example.com"), reply("example.com", 300, "192.0.2.1")); ttl != 0 {
|
|
t.Error("a disabled cache must not store entries")
|
|
}
|
|
if res := c.Get(keyFor("example.com"), request("example.com")); res.Hit {
|
|
t.Error("a disabled cache must not report hits")
|
|
}
|
|
}
|
|
|
|
func TestEvictionRespectsBound(t *testing.T) {
|
|
cfg := testConfig()
|
|
// One entry per shard; the bound is applied per shard.
|
|
cfg.MaxEntries = shardCount
|
|
c := New(cfg)
|
|
|
|
for i := 0; i < shardCount*20; i++ {
|
|
name := dns.Fqdn("host" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + ".example.com")
|
|
c.Put(Key{Name: name, Type: dns.TypeA, Class: dns.ClassINET}, reply(name, 300, "192.0.2.1"))
|
|
}
|
|
s := c.Stats()
|
|
if s.Entries > shardCount {
|
|
t.Errorf("entries = %d, want at most %d after eviction", s.Entries, shardCount)
|
|
}
|
|
if s.Evictions == 0 {
|
|
t.Error("expected evictions to be recorded")
|
|
}
|
|
}
|
|
|
|
func TestCleanupRemovesExpired(t *testing.T) {
|
|
c := New(testConfig())
|
|
k := keyFor("example.com")
|
|
c.Put(k, reply("example.com", 10, "192.0.2.1"))
|
|
|
|
sh := c.shardFor(k)
|
|
sh.mu.Lock()
|
|
sh.entries[k].stored = time.Now().Add(-time.Hour)
|
|
sh.mu.Unlock()
|
|
|
|
if n := c.Cleanup(); n != 1 {
|
|
t.Errorf("Cleanup removed %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestEntriesBrowsing(t *testing.T) {
|
|
c := New(testConfig())
|
|
for _, n := range []string{"alpha.example.com", "beta.example.com", "gamma.test"} {
|
|
c.Put(keyFor(n), reply(n, 300, "192.0.2.1"))
|
|
}
|
|
|
|
all, total := c.Entries("", 10, 0)
|
|
if total != 3 || len(all) != 3 {
|
|
t.Errorf("browse all: got %d of %d, want 3 of 3", len(all), total)
|
|
}
|
|
|
|
filtered, total := c.Entries("example.com", 10, 0)
|
|
if total != 2 || len(filtered) != 2 {
|
|
t.Errorf("browse filtered: got %d of %d, want 2 of 2", len(filtered), total)
|
|
}
|
|
}
|