From 614779dd5e404cc67d9d82c8acba630e7463316a Mon Sep 17 00:00:00 2001 From: Book Moons <35854232+bookmoons@users.noreply.github.com> Date: Tue, 25 Jun 2019 22:25:20 -0400 Subject: [PATCH 1/6] cache: Support expiration --- cache.go | 36 ++++++++++++++++++++++++++++-------- cache_test.go | 23 ++++++++++++++++++++++- resolver.go | 2 +- root_cache.go | 2 +- rr.go | 23 +++++++++++++---------- 5 files changed, 65 insertions(+), 21 deletions(-) diff --git a/cache.go b/cache.go index 6154b80..2dd7e4b 100644 --- a/cache.go +++ b/cache.go @@ -1,9 +1,13 @@ package dnsr -import "sync" +import ( + "sync" + "time" +) type cache struct { capacity int + expire bool m sync.RWMutex entries map[string]entry } @@ -14,13 +18,14 @@ const MinCacheCapacity = 1000 // newCache initializes and returns a new cache instance. // Cache capacity defaults to MinCacheCapacity if <= 0. -func newCache(capacity int) *cache { +func newCache(capacity int, expire bool) *cache { if capacity <= 0 { capacity = MinCacheCapacity } return &cache{ capacity: capacity, entries: make(map[string]entry), + expire: expire, } } @@ -91,11 +96,26 @@ func (c *cache) get(qname string) RRs { if len(e) == 0 { return emptyRRs } - i := 0 - rrs := make(RRs, len(e)) - for rr, _ := range e { - rrs[i] = rr - i++ + if c.expire { + i := 0 + rrs := make(RRs, len(e)) + now := time.Now() + for rr, _ := range e { + if rr.Expiry != nil && now.After(*rr.Expiry) { + delete(e, rr) + } else { + rrs[i] = rr + i++ + } + } + return rrs[:i] + } else { + i := 0 + rrs := make(RRs, len(e)) + for rr, _ := range e { + rrs[i] = rr + i++ + } + return rrs } - return rrs } diff --git a/cache_test.go b/cache_test.go index 08adb49..0d13dd5 100644 --- a/cache_test.go +++ b/cache_test.go @@ -2,15 +2,36 @@ package dnsr import ( "testing" + "time" "github.com/nbio/st" ) func TestCache(t *testing.T) { - c := newCache(100) + c := newCache(100, false) c.addNX("hello.") rr := RR{Name: "hello.", Type: "A", Value: "1.2.3.4"} c.add("hello.", rr) rrs := c.get("hello.") st.Expect(t, len(rrs), 1) } + +func TestLiveCacheEntry(t *testing.T) { + c := newCache(100, true) + c.addNX("alive.") + alive := time.Now().Add(time.Minute) + rr := RR{Name: "alive.", Type: "A", Value: "1.2.3.4", Expiry: &alive} + c.add("alive.", rr) + rrs := c.get("alive.") + st.Expect(t, len(rrs), 1) +} + +func TestExpiredCacheEntry(t *testing.T) { + c := newCache(100, true) + c.addNX("expired.") + expired := time.Now().Add(-time.Minute) + rr := RR{Name: "expired.", Type: "A", Value: "1.2.3.4", Expiry: &expired} + c.add("expired.", rr) + rrs := c.get("expired.") + st.Expect(t, len(rrs), 0) +} diff --git a/resolver.go b/resolver.go index 655c417..9369fe7 100644 --- a/resolver.go +++ b/resolver.go @@ -43,7 +43,7 @@ func New(capacity int) *Resolver { // NewWithTimeout initializes a Resolver with the specified cache size and resolution timeout. func NewWithTimeout(capacity int, timeout time.Duration) *Resolver { r := &Resolver{ - cache: newCache(capacity), + cache: newCache(capacity, false), timeout: timeout, } return r diff --git a/root_cache.go b/root_cache.go index 6dd956b..25b3e4d 100644 --- a/root_cache.go +++ b/root_cache.go @@ -13,7 +13,7 @@ var ( ) func init() { - rootCache = newCache(strings.Count(root, "\n")) + rootCache = newCache(strings.Count(root, "\n"), false) for t := range dns.ParseZone(strings.NewReader(root), "", "") { if t.Error != nil { continue diff --git a/rr.go b/rr.go index 51c7c37..e9ad43b 100644 --- a/rr.go +++ b/rr.go @@ -2,15 +2,17 @@ package dnsr import ( "strings" + "time" "github.com/miekg/dns" ) // RR represents a DNS resource record. type RR struct { - Name string - Type string - Value string + Name string + Type string + Value string + Expiry *time.Time } // RRs represents a slice of DNS resource records. @@ -30,6 +32,7 @@ const NameCollision = "127.0.53.53" // String returns a string representation of an RR in zone-file format. func (rr *RR) String() string { + // TODO: Add TTL to RR string return rr.Name + "\t 3600\tIN\t" + rr.Type + "\t" + rr.Value } @@ -40,21 +43,21 @@ func (rr *RR) String() string { func convertRR(drr dns.RR) (RR, bool) { switch t := drr.(type) { case *dns.SOA: - return RR{toLowerFQDN(t.Hdr.Name), "SOA", toLowerFQDN(t.Ns)}, true + return RR{toLowerFQDN(t.Hdr.Name), "SOA", toLowerFQDN(t.Ns), nil}, true case *dns.NS: - return RR{toLowerFQDN(t.Hdr.Name), "NS", toLowerFQDN(t.Ns)}, true + return RR{toLowerFQDN(t.Hdr.Name), "NS", toLowerFQDN(t.Ns), nil}, true case *dns.CNAME: - return RR{toLowerFQDN(t.Hdr.Name), "CNAME", toLowerFQDN(t.Target)}, true + return RR{toLowerFQDN(t.Hdr.Name), "CNAME", toLowerFQDN(t.Target), nil}, true case *dns.A: - return RR{toLowerFQDN(t.Hdr.Name), "A", t.A.String()}, true + return RR{toLowerFQDN(t.Hdr.Name), "A", t.A.String(), nil}, true case *dns.AAAA: - return RR{toLowerFQDN(t.Hdr.Name), "AAAA", t.AAAA.String()}, true + return RR{toLowerFQDN(t.Hdr.Name), "AAAA", t.AAAA.String(), nil}, true case *dns.TXT: - return RR{toLowerFQDN(t.Hdr.Name), "TXT", strings.Join(t.Txt, "\t")}, true + return RR{toLowerFQDN(t.Hdr.Name), "TXT", strings.Join(t.Txt, "\t"), nil}, true default: fields := strings.Fields(drr.String()) if len(fields) >= 4 { - return RR{toLowerFQDN(fields[0]), fields[3], strings.Join(fields[4:], "\t")}, true + return RR{toLowerFQDN(fields[0]), fields[3], strings.Join(fields[4:], "\t"), nil}, true } } return RR{}, false From fe4a7e2c459305e7ab3707de60274777132ea145 Mon Sep 17 00:00:00 2001 From: Book Moons <35854232+bookmoons@users.noreply.github.com> Date: Tue, 25 Jun 2019 23:23:35 -0400 Subject: [PATCH 2/6] Respect TTL --- resolver.go | 21 +++++++++++++++++++-- resolver_test.go | 9 +++++++++ root_cache.go | 2 +- rr.go | 44 ++++++++++++++++++++++++++++++++++---------- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/resolver.go b/resolver.go index 9369fe7..ef58412 100644 --- a/resolver.go +++ b/resolver.go @@ -32,6 +32,7 @@ var ( // Resolver implements a primitive, non-recursive, caching DNS resolver. type Resolver struct { cache *cache + expire bool timeout time.Duration } @@ -44,6 +45,22 @@ func New(capacity int) *Resolver { func NewWithTimeout(capacity int, timeout time.Duration) *Resolver { r := &Resolver{ cache: newCache(capacity, false), + expire: false, + timeout: timeout, + } + return r +} + +// NewExpiring initializes an expiring Resolver with the specified cache size. +func NewExpiring(capacity int) *Resolver { + return NewExpiringWithTimeout(capacity, Timeout) +} + +// NewExpiringWithTimeout initializes an expiring Resolved with the specified cache size and resolution timeout. +func NewExpiringWithTimeout(capacity int, timeout time.Duration) *Resolver { + r := &Resolver{ + cache: newCache(capacity, true), + expire: true, timeout: timeout, } return r @@ -243,7 +260,7 @@ func (r *Resolver) exchange(ctx context.Context, host, qname, qtype string, dept var hasSOA bool if qtype == "NS" { for _, drr := range rmsg.Ns { - rr, ok := convertRR(drr) + rr, ok := convertRR(drr, r.expire) if !ok { continue } @@ -293,7 +310,7 @@ func (r *Resolver) saveDNSRR(host, qname string, drrs []dns.RR) RRs { var rrs RRs cl := dns.CountLabel(qname) for _, drr := range drrs { - rr, ok := convertRR(drr) + rr, ok := convertRR(drr, r.expire) if !ok { continue } diff --git a/resolver_test.go b/resolver_test.go index 9bbde24..bf30ae8 100644 --- a/resolver_test.go +++ b/resolver_test.go @@ -187,6 +187,15 @@ func TestBazCoUKAny(t *testing.T) { st.Expect(t, count(rrs, func(rr RR) bool { return rr.Type == "NS" }) >= 2, true) } +func TestTTL(t *testing.T) { + r := NewExpiring(0) + rrs, err := r.ResolveErr("google.com", "A") + st.Expect(t, err, nil) + st.Expect(t, len(rrs) >= 4, true) + rr := rrs[0] + st.Expect(t, rr.Expiry != nil, true) +} + var testResolver *Resolver func BenchmarkResolve(b *testing.B) { diff --git a/root_cache.go b/root_cache.go index 25b3e4d..25a3cee 100644 --- a/root_cache.go +++ b/root_cache.go @@ -18,7 +18,7 @@ func init() { if t.Error != nil { continue } - rr, ok := convertRR(t.RR) + rr, ok := convertRR(t.RR, false) if ok { rootCache.add(rr.Name, rr) } diff --git a/rr.go b/rr.go index e9ad43b..eb258bd 100644 --- a/rr.go +++ b/rr.go @@ -1,6 +1,7 @@ package dnsr import ( + "fmt" "strings" "time" @@ -12,6 +13,7 @@ type RR struct { Name string Type string Value string + Ttl *time.Duration Expiry *time.Time } @@ -32,33 +34,55 @@ const NameCollision = "127.0.53.53" // String returns a string representation of an RR in zone-file format. func (rr *RR) String() string { - // TODO: Add TTL to RR string - return rr.Name + "\t 3600\tIN\t" + rr.Type + "\t" + rr.Value + if rr.Ttl == nil { + return rr.Name + "\t 3600\tIN\t" + rr.Type + "\t" + rr.Value + } else { + ttl := ttlString(rr.Ttl) + return rr.Name + "\t" + ttl + "\t" + rr.Type + "\t" + rr.Value + } +} + +// ttlString constructs the TTL field of an RR string. +func ttlString(ttl *time.Duration) string { + seconds := int(ttl.Seconds()) + return fmt.Sprintf("%10d", seconds) } // convertRR converts a dns.RR to an RR. // If the RR is not a type that this package uses, // It will attempt to translate this if there are enough parameters // Should all translation fail, it returns an undefined RR and false. -func convertRR(drr dns.RR) (RR, bool) { +func convertRR(drr dns.RR, expire bool) (RR, bool) { + var ttl *time.Duration + var expiry *time.Time + if expire { + ttl, expiry = calculateExpiry(drr) + } switch t := drr.(type) { case *dns.SOA: - return RR{toLowerFQDN(t.Hdr.Name), "SOA", toLowerFQDN(t.Ns), nil}, true + return RR{toLowerFQDN(t.Hdr.Name), "SOA", toLowerFQDN(t.Ns), ttl, expiry}, true case *dns.NS: - return RR{toLowerFQDN(t.Hdr.Name), "NS", toLowerFQDN(t.Ns), nil}, true + return RR{toLowerFQDN(t.Hdr.Name), "NS", toLowerFQDN(t.Ns), ttl, expiry}, true case *dns.CNAME: - return RR{toLowerFQDN(t.Hdr.Name), "CNAME", toLowerFQDN(t.Target), nil}, true + return RR{toLowerFQDN(t.Hdr.Name), "CNAME", toLowerFQDN(t.Target), ttl, expiry}, true case *dns.A: - return RR{toLowerFQDN(t.Hdr.Name), "A", t.A.String(), nil}, true + return RR{toLowerFQDN(t.Hdr.Name), "A", t.A.String(), ttl, expiry}, true case *dns.AAAA: - return RR{toLowerFQDN(t.Hdr.Name), "AAAA", t.AAAA.String(), nil}, true + return RR{toLowerFQDN(t.Hdr.Name), "AAAA", t.AAAA.String(), ttl, expiry}, true case *dns.TXT: - return RR{toLowerFQDN(t.Hdr.Name), "TXT", strings.Join(t.Txt, "\t"), nil}, true + return RR{toLowerFQDN(t.Hdr.Name), "TXT", strings.Join(t.Txt, "\t"), ttl, expiry}, true default: fields := strings.Fields(drr.String()) if len(fields) >= 4 { - return RR{toLowerFQDN(fields[0]), fields[3], strings.Join(fields[4:], "\t"), nil}, true + return RR{toLowerFQDN(fields[0]), fields[3], strings.Join(fields[4:], "\t"), ttl, expiry}, true } } return RR{}, false } + +// calculateExpiry calculates the expiry time of an RR. +func calculateExpiry(drr dns.RR) (*time.Duration, *time.Time) { + ttl := time.Second * time.Duration(drr.Header().Ttl) + expiry := time.Now().Add(ttl) + return &ttl, &expiry +} From e91bc650e7759e611c3896b3a05d15f7419fb94f Mon Sep 17 00:00:00 2001 From: Book Moons <35854232+bookmoons@users.noreply.github.com> Date: Tue, 25 Jun 2019 23:26:57 -0400 Subject: [PATCH 3/6] Document expiring resolver construction --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ab621d0..836dfec 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ func main() { } ``` +Or construct with `dnsr.NewExpiring()` to expire cache entries based on TTL. + [Documentation](https://godoc.org/github.com/domainr/dnsr) ## Development From 6762c5ba09a79af651a5f1b1211d2f54e0502018 Mon Sep 17 00:00:00 2001 From: Book Moons <35854232+bookmoons@users.noreply.github.com> Date: Thu, 27 Jun 2019 22:32:46 -0400 Subject: [PATCH 4/6] Capitalize TTL --- rr.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rr.go b/rr.go index eb258bd..ff326e9 100644 --- a/rr.go +++ b/rr.go @@ -13,7 +13,7 @@ type RR struct { Name string Type string Value string - Ttl *time.Duration + TTL *time.Duration Expiry *time.Time } @@ -34,10 +34,10 @@ const NameCollision = "127.0.53.53" // String returns a string representation of an RR in zone-file format. func (rr *RR) String() string { - if rr.Ttl == nil { + if rr.TTL == nil { return rr.Name + "\t 3600\tIN\t" + rr.Type + "\t" + rr.Value } else { - ttl := ttlString(rr.Ttl) + ttl := ttlString(rr.TTL) return rr.Name + "\t" + ttl + "\t" + rr.Type + "\t" + rr.Value } } From 0a6811618f63effc987545815743882d8bb4cd45 Mon Sep 17 00:00:00 2001 From: Book Moons <35854232+bookmoons@users.noreply.github.com> Date: Thu, 27 Jun 2019 23:16:23 -0400 Subject: [PATCH 5/6] Prefer pass by value --- cache.go | 2 +- cache_test.go | 4 ++-- resolver_test.go | 2 +- rr.go | 19 +++++++++++-------- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/cache.go b/cache.go index 2dd7e4b..c390ed9 100644 --- a/cache.go +++ b/cache.go @@ -101,7 +101,7 @@ func (c *cache) get(qname string) RRs { rrs := make(RRs, len(e)) now := time.Now() for rr, _ := range e { - if rr.Expiry != nil && now.After(*rr.Expiry) { + if rr.Expiry != emptyTime && now.After(rr.Expiry) { delete(e, rr) } else { rrs[i] = rr diff --git a/cache_test.go b/cache_test.go index 0d13dd5..f5d39c5 100644 --- a/cache_test.go +++ b/cache_test.go @@ -20,7 +20,7 @@ func TestLiveCacheEntry(t *testing.T) { c := newCache(100, true) c.addNX("alive.") alive := time.Now().Add(time.Minute) - rr := RR{Name: "alive.", Type: "A", Value: "1.2.3.4", Expiry: &alive} + rr := RR{Name: "alive.", Type: "A", Value: "1.2.3.4", Expiry: alive} c.add("alive.", rr) rrs := c.get("alive.") st.Expect(t, len(rrs), 1) @@ -30,7 +30,7 @@ func TestExpiredCacheEntry(t *testing.T) { c := newCache(100, true) c.addNX("expired.") expired := time.Now().Add(-time.Minute) - rr := RR{Name: "expired.", Type: "A", Value: "1.2.3.4", Expiry: &expired} + rr := RR{Name: "expired.", Type: "A", Value: "1.2.3.4", Expiry: expired} c.add("expired.", rr) rrs := c.get("expired.") st.Expect(t, len(rrs), 0) diff --git a/resolver_test.go b/resolver_test.go index bf30ae8..2256a5b 100644 --- a/resolver_test.go +++ b/resolver_test.go @@ -193,7 +193,7 @@ func TestTTL(t *testing.T) { st.Expect(t, err, nil) st.Expect(t, len(rrs) >= 4, true) rr := rrs[0] - st.Expect(t, rr.Expiry != nil, true) + st.Expect(t, rr.Expiry != emptyTime, true) } var testResolver *Resolver diff --git a/rr.go b/rr.go index ff326e9..75ea5b4 100644 --- a/rr.go +++ b/rr.go @@ -13,8 +13,8 @@ type RR struct { Name string Type string Value string - TTL *time.Duration - Expiry *time.Time + TTL time.Duration + Expiry time.Time } // RRs represents a slice of DNS resource records. @@ -24,6 +24,9 @@ type RRs []RR // It is used to save allocations at runtime. var emptyRRs = RRs{} +// emptyTime is used to detect an empty expiry time. +var emptyTime = time.Time{} + // ICANN specifies that DNS servers should return the special value 127.0.53.53 // for A record queries of TLDs that have recently entered the root zone, // that have a high likelyhood of colliding with private DNS names. @@ -34,7 +37,7 @@ const NameCollision = "127.0.53.53" // String returns a string representation of an RR in zone-file format. func (rr *RR) String() string { - if rr.TTL == nil { + if rr.Expiry == emptyTime { return rr.Name + "\t 3600\tIN\t" + rr.Type + "\t" + rr.Value } else { ttl := ttlString(rr.TTL) @@ -43,7 +46,7 @@ func (rr *RR) String() string { } // ttlString constructs the TTL field of an RR string. -func ttlString(ttl *time.Duration) string { +func ttlString(ttl time.Duration) string { seconds := int(ttl.Seconds()) return fmt.Sprintf("%10d", seconds) } @@ -53,8 +56,8 @@ func ttlString(ttl *time.Duration) string { // It will attempt to translate this if there are enough parameters // Should all translation fail, it returns an undefined RR and false. func convertRR(drr dns.RR, expire bool) (RR, bool) { - var ttl *time.Duration - var expiry *time.Time + var ttl time.Duration + var expiry time.Time if expire { ttl, expiry = calculateExpiry(drr) } @@ -81,8 +84,8 @@ func convertRR(drr dns.RR, expire bool) (RR, bool) { } // calculateExpiry calculates the expiry time of an RR. -func calculateExpiry(drr dns.RR) (*time.Duration, *time.Time) { +func calculateExpiry(drr dns.RR) (time.Duration, time.Time) { ttl := time.Second * time.Duration(drr.Header().Ttl) expiry := time.Now().Add(ttl) - return &ttl, &expiry + return ttl, expiry } From 5dd4216cffabc6b1ac5ae2e6f9dd59c00a90dc5b Mon Sep 17 00:00:00 2001 From: Book Moons <35854232+bookmoons@users.noreply.github.com> Date: Fri, 28 Jun 2019 01:43:54 -0400 Subject: [PATCH 6/6] Detect empty expiry with IsZero() --- cache.go | 2 +- resolver_test.go | 2 +- rr.go | 5 +---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/cache.go b/cache.go index c390ed9..36f6e1f 100644 --- a/cache.go +++ b/cache.go @@ -101,7 +101,7 @@ func (c *cache) get(qname string) RRs { rrs := make(RRs, len(e)) now := time.Now() for rr, _ := range e { - if rr.Expiry != emptyTime && now.After(rr.Expiry) { + if !rr.Expiry.IsZero() && now.After(rr.Expiry) { delete(e, rr) } else { rrs[i] = rr diff --git a/resolver_test.go b/resolver_test.go index 2256a5b..8884e52 100644 --- a/resolver_test.go +++ b/resolver_test.go @@ -193,7 +193,7 @@ func TestTTL(t *testing.T) { st.Expect(t, err, nil) st.Expect(t, len(rrs) >= 4, true) rr := rrs[0] - st.Expect(t, rr.Expiry != emptyTime, true) + st.Expect(t, !rr.Expiry.IsZero(), true) } var testResolver *Resolver diff --git a/rr.go b/rr.go index 75ea5b4..9e57fdb 100644 --- a/rr.go +++ b/rr.go @@ -24,9 +24,6 @@ type RRs []RR // It is used to save allocations at runtime. var emptyRRs = RRs{} -// emptyTime is used to detect an empty expiry time. -var emptyTime = time.Time{} - // ICANN specifies that DNS servers should return the special value 127.0.53.53 // for A record queries of TLDs that have recently entered the root zone, // that have a high likelyhood of colliding with private DNS names. @@ -37,7 +34,7 @@ const NameCollision = "127.0.53.53" // String returns a string representation of an RR in zone-file format. func (rr *RR) String() string { - if rr.Expiry == emptyTime { + if rr.Expiry.IsZero() { return rr.Name + "\t 3600\tIN\t" + rr.Type + "\t" + rr.Value } else { ttl := ttlString(rr.TTL)