Resolve PTR names over DNS-over-HTTPS and cache them for the record TTL (closes #238)
check / check (push) Failing after 1m22s
check / check (push) Failing after 1m22s
`internal/ptr` asks Google, Cloudflare, AdGuard and Quad9 in turn, 2 seconds in all per address. It keeps answers in memory and writes them through to `cache.db`, opened by the new `internal/cachedb`. Judgement calls: - Each resolver gets an equal share of the time left, so a hung resolver does not use up the 2 seconds. - The negative TTL is the smaller of the SOA minimum and the SOA's own TTL (RFC 2308). - Names that are not host names are dropped, as `net.Resolver.LookupAddr` does. Model: opus-5-5
This commit is contained in:
1 parent
cf50d195ad
commit
018372363f
18 files changed
+1608
-53
No files matched your search
@@ -0,0 +1,780 @@
|
||||
package ptr
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/cachedb"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
const (
|
||||
testAddress = "192.0.2.1"
|
||||
testReverse = "1.2.0.192.in-addr.arpa."
|
||||
testName = "host.example.com."
|
||||
otherName = "other.example.com."
|
||||
testV6Address = "2001:db8::1"
|
||||
testV6Reverse = "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0." +
|
||||
"0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa."
|
||||
|
||||
// timingAllowance is how far past its limit a loaded machine may run a
|
||||
// lookup that has a time limit.
|
||||
timingAllowance = 8 * time.Second
|
||||
)
|
||||
|
||||
// classlessOwner is the name that holds the PTR records of a CNAME chain.
|
||||
const classlessOwner = "1.0-25.2.0.192.in-addr.arpa."
|
||||
|
||||
// fixedTime returns the time a test's clock starts at.
|
||||
func fixedTime() time.Time {
|
||||
return time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
}
|
||||
|
||||
// fakeResolver is a DNS-over-HTTPS server. It answers each query with reply's
|
||||
// result, and counts its requests.
|
||||
type fakeResolver struct {
|
||||
server *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
requests int
|
||||
reply func(http.ResponseWriter, *http.Request, *dns.Msg)
|
||||
}
|
||||
|
||||
// newFakeResolver starts a fake resolver that answers with reply.
|
||||
func newFakeResolver(
|
||||
t *testing.T,
|
||||
reply func(http.ResponseWriter, *http.Request, *dns.Msg),
|
||||
) *fakeResolver {
|
||||
t.Helper()
|
||||
|
||||
resolver := &fakeResolver{reply: reply}
|
||||
resolver.server = httptest.NewServer(http.HandlerFunc(resolver.serve))
|
||||
t.Cleanup(resolver.server.Close)
|
||||
|
||||
return resolver
|
||||
}
|
||||
|
||||
// serve checks that the request is RFC 8484 wire format and passes the
|
||||
// question on to the current reply.
|
||||
func (f *fakeResolver) serve(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
f.requests++
|
||||
reply := f.reply
|
||||
f.mu.Unlock()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var query dns.Msg
|
||||
|
||||
err = query.Unpack(body)
|
||||
if err != nil || r.Header.Get("Content-Type") != dnsMessageType ||
|
||||
r.Header.Get("Accept") != dnsMessageType || r.Method != http.MethodPost {
|
||||
http.Error(w, "not an RFC 8484 request", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reply(w, r, &query)
|
||||
}
|
||||
|
||||
func (f *fakeResolver) setReply(
|
||||
reply func(http.ResponseWriter, *http.Request, *dns.Msg),
|
||||
) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
f.reply = reply
|
||||
}
|
||||
|
||||
func (f *fakeResolver) requestCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
return f.requests
|
||||
}
|
||||
|
||||
// writeReply packs reply and writes it as the answer to query.
|
||||
func writeReply(w http.ResponseWriter, query, reply *dns.Msg) {
|
||||
reply.SetReply(query)
|
||||
|
||||
wire, err := reply.Pack()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", dnsMessageType)
|
||||
_, _ = w.Write(wire)
|
||||
}
|
||||
|
||||
// ptrReply answers with one PTR record for each name, each with the TTL ttl.
|
||||
func ptrReply(
|
||||
ttl uint32, names ...string,
|
||||
) func(http.ResponseWriter, *http.Request, *dns.Msg) {
|
||||
return func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) {
|
||||
reply := new(dns.Msg)
|
||||
|
||||
for _, name := range names {
|
||||
reply.Answer = append(reply.Answer, &dns.PTR{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: query.Question[0].Name, Rrtype: dns.TypePTR,
|
||||
Class: dns.ClassINET, Ttl: ttl,
|
||||
},
|
||||
Ptr: name,
|
||||
})
|
||||
}
|
||||
|
||||
writeReply(w, query, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// rcodeReply answers with the response code and nothing else.
|
||||
func rcodeReply(rcode int) func(http.ResponseWriter, *http.Request, *dns.Msg) {
|
||||
return func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) {
|
||||
reply := new(dns.Msg)
|
||||
reply.SetRcode(query, rcode)
|
||||
|
||||
wire, err := reply.Pack()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write(wire)
|
||||
}
|
||||
}
|
||||
|
||||
// soaReply answers with the response code and a SOA record with these TTLs.
|
||||
func soaReply(
|
||||
rcode int, soaTTL, minimum uint32,
|
||||
) func(http.ResponseWriter, *http.Request, *dns.Msg) {
|
||||
return func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) {
|
||||
reply := new(dns.Msg)
|
||||
reply.SetRcode(query, rcode)
|
||||
reply.Ns = []dns.RR{&dns.SOA{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: "arpa.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: soaTTL,
|
||||
},
|
||||
Ns: "ns.example.com.", Mbox: "hostmaster.example.com.", Minttl: minimum,
|
||||
}}
|
||||
|
||||
wire, err := reply.Pack()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write(wire)
|
||||
}
|
||||
}
|
||||
|
||||
// hangingReply never answers: it waits until the client gives up.
|
||||
func hangingReply(_ http.ResponseWriter, r *http.Request, _ *dns.Msg) {
|
||||
<-r.Context().Done()
|
||||
}
|
||||
|
||||
// statusReply answers with an HTTP status.
|
||||
func statusReply(status int) func(http.ResponseWriter, *http.Request, *dns.Msg) {
|
||||
return func(w http.ResponseWriter, _ *http.Request, _ *dns.Msg) {
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
}
|
||||
|
||||
// openTestDB opens a cache.db in dir.
|
||||
func openTestDB(t *testing.T, dir string) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := cachedb.Open(t.Context(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("opening the cache database: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// testClock is a clock a test moves by hand.
|
||||
type testClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *testClock) time() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *testClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// newTestCache returns a Cache that asks the resolvers in order, reads the
|
||||
// time from a clock the test holds, and is not started.
|
||||
func newTestCache(
|
||||
t *testing.T, db *sql.DB, resolvers ...*fakeResolver,
|
||||
) (*Cache, *testClock) {
|
||||
t.Helper()
|
||||
|
||||
urls := make([]string, 0, len(resolvers))
|
||||
for _, resolver := range resolvers {
|
||||
urls = append(urls, resolver.server.URL)
|
||||
}
|
||||
|
||||
cache := New(Params{DB: db, Resolvers: urls, Logger: slog.Default()})
|
||||
clock := &testClock{now: fixedTime()}
|
||||
cache.now = clock.time
|
||||
|
||||
return cache, clock
|
||||
}
|
||||
|
||||
// lookupOK looks up address and fails the test on an error.
|
||||
func lookupOK(t *testing.T, cache *Cache, address string) []string {
|
||||
t.Helper()
|
||||
|
||||
names, err := cache.LookupAddr(t.Context(), address)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupAddr(%s): %v", address, err)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, db *sql.DB) int {
|
||||
t.Helper()
|
||||
|
||||
var rows int
|
||||
|
||||
err := db.QueryRowContext(t.Context(),
|
||||
"SELECT COUNT(*) FROM hostnames").Scan(&rows)
|
||||
if err != nil {
|
||||
t.Fatalf("counting rows: %v", err)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
func TestLookupAsksForTheReverseName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
address string
|
||||
want string
|
||||
}{
|
||||
{testAddress, testReverse},
|
||||
{"::ffff:" + testAddress, testReverse},
|
||||
{testV6Address, testV6Reverse},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
var asked string
|
||||
|
||||
resolver := newFakeResolver(t,
|
||||
func(w http.ResponseWriter, r *http.Request, query *dns.Msg) {
|
||||
asked = query.Question[0].Name
|
||||
if query.Question[0].Qtype != dns.TypePTR {
|
||||
t.Errorf("query type %d, want PTR", query.Question[0].Qtype)
|
||||
}
|
||||
|
||||
ptrReply(300, testName)(w, r, query)
|
||||
})
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
|
||||
|
||||
names := lookupOK(t, cache, tt.address)
|
||||
if !slices.Equal(names, []string{testName}) {
|
||||
t.Errorf("%s: names %v, want %s", tt.address, names, testName)
|
||||
}
|
||||
|
||||
if asked != tt.want {
|
||||
t.Errorf("%s: asked for %s, want %s", tt.address, asked, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolversAreTriedInOrderUntilOneAnswers has the first two resolvers fail,
|
||||
// in different ways, and the third answer. The fourth is not asked.
|
||||
func TestResolversAreTriedInOrderUntilOneAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
order []int
|
||||
)
|
||||
|
||||
asked := func(
|
||||
index int, reply func(http.ResponseWriter, *http.Request, *dns.Msg),
|
||||
) func(http.ResponseWriter, *http.Request, *dns.Msg) {
|
||||
return func(w http.ResponseWriter, r *http.Request, query *dns.Msg) {
|
||||
mu.Lock()
|
||||
|
||||
order = append(order, index)
|
||||
|
||||
mu.Unlock()
|
||||
|
||||
reply(w, r, query)
|
||||
}
|
||||
}
|
||||
|
||||
resolvers := []*fakeResolver{
|
||||
newFakeResolver(t, asked(0, statusReply(http.StatusServiceUnavailable))),
|
||||
newFakeResolver(t, asked(1, rcodeReply(dns.RcodeServerFailure))),
|
||||
newFakeResolver(t, asked(2, ptrReply(300, testName))),
|
||||
newFakeResolver(t, asked(3, ptrReply(300, otherName))),
|
||||
}
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolvers...)
|
||||
|
||||
names := lookupOK(t, cache, testAddress)
|
||||
if !slices.Equal(names, []string{testName}) {
|
||||
t.Errorf("names %v, want %s", names, testName)
|
||||
}
|
||||
|
||||
if !slices.Equal(order, []int{0, 1, 2}) {
|
||||
t.Errorf("resolvers asked %v, want [0 1 2]", order)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolverThatNeverAnswersLeavesTimeForTheNext would fail if the first
|
||||
// resolver could take the whole time limit.
|
||||
func TestResolverThatNeverAnswersLeavesTimeForTheNext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()),
|
||||
newFakeResolver(t, hangingReply),
|
||||
newFakeResolver(t, ptrReply(300, testName)))
|
||||
|
||||
names := lookupOK(t, cache, testAddress)
|
||||
if !slices.Equal(names, []string{testName}) {
|
||||
t.Errorf("names %v, want %s", names, testName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupStopsAtTheTimeLimit has no resolver answer. The lookup fails once
|
||||
// LookupTimeout has passed, having asked every resolver.
|
||||
func TestLookupStopsAtTheTimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolvers := []*fakeResolver{
|
||||
newFakeResolver(t, hangingReply),
|
||||
newFakeResolver(t, hangingReply),
|
||||
newFakeResolver(t, hangingReply),
|
||||
newFakeResolver(t, hangingReply),
|
||||
}
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolvers...)
|
||||
|
||||
start := time.Now()
|
||||
_, err := cache.LookupAddr(t.Context(), testAddress)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Error("LookupAddr succeeded with no resolver answering")
|
||||
}
|
||||
|
||||
if elapsed < LookupTimeout || elapsed > LookupTimeout+timingAllowance {
|
||||
t.Errorf("took %s, want about %s", elapsed, LookupTimeout)
|
||||
}
|
||||
|
||||
for i, resolver := range resolvers {
|
||||
if resolver.requestCount() != 1 {
|
||||
t.Errorf("resolver %d asked %d times, want once",
|
||||
i, resolver.requestCount())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnswerIsServedFromMemoryUntilItExpires makes a second lookup within the
|
||||
// TTL, which asks nobody, and a third after it, which asks again.
|
||||
func TestAnswerIsServedFromMemoryUntilItExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newFakeResolver(t, ptrReply(300, testName))
|
||||
cache, clock := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
|
||||
|
||||
lookupOK(t, cache, testAddress)
|
||||
|
||||
names := lookupOK(t, cache, testAddress)
|
||||
if !slices.Equal(names, []string{testName}) {
|
||||
t.Errorf("cached names %v, want %s", names, testName)
|
||||
}
|
||||
|
||||
if got := resolver.requestCount(); got != 1 {
|
||||
t.Errorf("%d requests after the second lookup, want 1", got)
|
||||
}
|
||||
|
||||
clock.advance(300 * time.Second)
|
||||
lookupOK(t, cache, testAddress)
|
||||
|
||||
if got := resolver.requestCount(); got != 2 {
|
||||
t.Errorf("%d requests after the TTL passed, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnAddressHasOneCacheEntryHoweverItIsWritten looks up an IPv4 address in
|
||||
// its IPv4-mapped IPv6 form, then in its usual form.
|
||||
func TestAnAddressHasOneCacheEntryHoweverItIsWritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newFakeResolver(t, ptrReply(300, testName))
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
|
||||
|
||||
lookupOK(t, cache, "::ffff:"+testAddress)
|
||||
lookupOK(t, cache, testAddress)
|
||||
|
||||
if got := resolver.requestCount(); got != 1 {
|
||||
t.Errorf("%d requests, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallerCannotChangeACachedAnswer changes the list a lookup returned, as
|
||||
// the server does when it removes the trailing dots.
|
||||
func TestCallerCannotChangeACachedAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newFakeResolver(t, ptrReply(300, testName))
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
|
||||
|
||||
for range 2 {
|
||||
names := lookupOK(t, cache, testAddress)
|
||||
if !slices.Equal(names, []string{testName}) {
|
||||
t.Errorf("names %v, want %s", names, testName)
|
||||
}
|
||||
|
||||
names[0] = "changed"
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTLIsClamped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ttl uint32
|
||||
want time.Duration
|
||||
}{
|
||||
{"below the minimum", 1, time.Minute},
|
||||
{"zero", 0, time.Minute},
|
||||
{"at the minimum", 60, time.Minute},
|
||||
{"in range", 300, 300 * time.Second},
|
||||
{"at the maximum", 86400, 24 * time.Hour},
|
||||
{"above the maximum", 604800, 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
resolver := newFakeResolver(t, ptrReply(tt.ttl, testName))
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
|
||||
|
||||
lookupOK(t, cache, testAddress)
|
||||
|
||||
got := cache.entries[testAddress].expires.Sub(fixedTime())
|
||||
if got != tt.want {
|
||||
t.Errorf("%s: kept for %s, want %s", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSmallestTTLOfTheChainCounts answers with a CNAME record and two PTR
|
||||
// records, whose TTLs all differ.
|
||||
func TestSmallestTTLOfTheChainCounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reply := func(w http.ResponseWriter, _ *http.Request, query *dns.Msg) {
|
||||
owner := query.Question[0].Name
|
||||
answer := new(dns.Msg)
|
||||
answer.Answer = []dns.RR{
|
||||
&dns.CNAME{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: owner, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 200,
|
||||
},
|
||||
Target: classlessOwner,
|
||||
},
|
||||
&dns.PTR{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: classlessOwner, Rrtype: dns.TypePTR,
|
||||
Class: dns.ClassINET, Ttl: 900,
|
||||
},
|
||||
Ptr: testName,
|
||||
},
|
||||
&dns.PTR{
|
||||
Hdr: dns.RR_Header{
|
||||
Name: classlessOwner, Rrtype: dns.TypePTR,
|
||||
Class: dns.ClassINET, Ttl: 600,
|
||||
},
|
||||
Ptr: otherName,
|
||||
},
|
||||
}
|
||||
|
||||
writeReply(w, query, answer)
|
||||
}
|
||||
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), newFakeResolver(t, reply))
|
||||
|
||||
names := lookupOK(t, cache, testAddress)
|
||||
if !slices.Equal(names, []string{testName, otherName}) {
|
||||
t.Errorf("names %v, want both PTR names", names)
|
||||
}
|
||||
|
||||
got := cache.entries[testAddress].expires.Sub(fixedTime())
|
||||
if got != 200*time.Second {
|
||||
t.Errorf("kept for %s, want 200s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegativeTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rcode int
|
||||
soaTTL uint32
|
||||
minimum uint32
|
||||
want time.Duration
|
||||
}{
|
||||
{"name does not exist", dns.RcodeNameError, 3600, 300, 300 * time.Second},
|
||||
{"no PTR record", dns.RcodeSuccess, 3600, 300, 300 * time.Second},
|
||||
{"limited to an hour", dns.RcodeNameError, 86400, 7200, time.Hour},
|
||||
{"SOA's own TTL is smaller", dns.RcodeNameError, 100, 300, 100 * time.Second},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
resolver := newFakeResolver(t, soaReply(tt.rcode, tt.soaTTL, tt.minimum))
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
|
||||
|
||||
names := lookupOK(t, cache, testAddress)
|
||||
if len(names) != 0 {
|
||||
t.Errorf("%s: names %v, want none", tt.name, names)
|
||||
}
|
||||
|
||||
got := cache.entries[testAddress].expires.Sub(fixedTime())
|
||||
if got != tt.want {
|
||||
t.Errorf("%s: kept for %s, want %s", tt.name, got, tt.want)
|
||||
}
|
||||
|
||||
lookupOK(t, cache, testAddress)
|
||||
|
||||
if count := resolver.requestCount(); count != 1 {
|
||||
t.Errorf("%s: %d requests, want 1", tt.name, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnswerWithoutNamesOrSOAIsNotCached has nothing to say how long the
|
||||
// answer may be kept.
|
||||
func TestAnswerWithoutNamesOrSOAIsNotCached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newFakeResolver(t, rcodeReply(dns.RcodeNameError))
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
|
||||
|
||||
lookupOK(t, cache, testAddress)
|
||||
lookupOK(t, cache, testAddress)
|
||||
|
||||
if got := resolver.requestCount(); got != 2 {
|
||||
t.Errorf("%d requests, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedQueryIsNotCached has the only resolver fail, and then recover.
|
||||
func TestFailedQueryIsNotCached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newFakeResolver(t, rcodeReply(dns.RcodeServerFailure))
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
|
||||
|
||||
_, err := cache.LookupAddr(t.Context(), testAddress)
|
||||
if !errors.Is(err, errBadResponse) {
|
||||
t.Fatalf("error %v, want %v", err, errBadResponse)
|
||||
}
|
||||
|
||||
if len(cache.entries)+len(cache.pending) != 0 {
|
||||
t.Error("a failed query left an entry in the cache")
|
||||
}
|
||||
|
||||
resolver.setReply(ptrReply(300, testName))
|
||||
|
||||
names := lookupOK(t, cache, testAddress)
|
||||
if !slices.Equal(names, []string{testName}) {
|
||||
t.Errorf("names %v, want %s", names, testName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNamesThatAreNotHostNamesAreDropped answers with a name that holds markup
|
||||
// beside a good one, as a PTR record can.
|
||||
func TestNamesThatAreNotHostNamesAreDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newFakeResolver(t,
|
||||
ptrReply(300, "bad<script>.example.com.", testName, "."))
|
||||
cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver)
|
||||
|
||||
names := lookupOK(t, cache, testAddress)
|
||||
if !slices.Equal(names, []string{testName}) {
|
||||
t.Errorf("names %v, want only %s", names, testName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheSurvivesARestart stops a cache that holds a positive and a negative
|
||||
// answer, and starts a new one on the reopened file. It makes no request.
|
||||
func TestCacheSurvivesARestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t, dir)
|
||||
|
||||
const noNamesAddress = "192.0.2.2"
|
||||
|
||||
resolver := newFakeResolver(t,
|
||||
func(w http.ResponseWriter, r *http.Request, query *dns.Msg) {
|
||||
if query.Question[0].Name == testReverse {
|
||||
ptrReply(300, testName, otherName)(w, r, query)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
soaReply(dns.RcodeNameError, 3600, 300)(w, r, query)
|
||||
})
|
||||
|
||||
cache, _ := newTestCache(t, db, resolver)
|
||||
|
||||
err := cache.Start(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
lookupOK(t, cache, testAddress)
|
||||
lookupOK(t, cache, noNamesAddress)
|
||||
cache.Stop(t.Context())
|
||||
|
||||
err = db.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("closing the database: %v", err)
|
||||
}
|
||||
|
||||
restarted, _ := newTestCache(t, openTestDB(t, dir), resolver)
|
||||
|
||||
err = restarted.Start(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Start after the restart: %v", err)
|
||||
}
|
||||
|
||||
defer restarted.Stop(t.Context())
|
||||
|
||||
requestsBefore := resolver.requestCount()
|
||||
|
||||
names := lookupOK(t, restarted, testAddress)
|
||||
if !slices.Equal(names, []string{testName, otherName}) {
|
||||
t.Errorf("names after restart %v", names)
|
||||
}
|
||||
|
||||
if names := lookupOK(t, restarted, noNamesAddress); len(names) != 0 {
|
||||
t.Errorf("names of the address with none after restart: %v", names)
|
||||
}
|
||||
|
||||
if got := resolver.requestCount(); got != requestsBefore {
|
||||
t.Errorf("%d requests after the restart, want none",
|
||||
got-requestsBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepDeletesExpiredRows also checks that a restart does not load a row
|
||||
// that has expired since it was written.
|
||||
func TestSweepDeletesExpiredRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := openTestDB(t, t.TempDir())
|
||||
resolver := newFakeResolver(t,
|
||||
func(w http.ResponseWriter, r *http.Request, query *dns.Msg) {
|
||||
ttl := uint32(300)
|
||||
if query.Question[0].Name == testReverse {
|
||||
ttl = 60
|
||||
}
|
||||
|
||||
ptrReply(ttl, testName)(w, r, query)
|
||||
})
|
||||
cache, clock := newTestCache(t, db, resolver)
|
||||
|
||||
lookupOK(t, cache, testAddress)
|
||||
lookupOK(t, cache, "192.0.2.2")
|
||||
cache.flush(t.Context())
|
||||
|
||||
if got := countRows(t, db); got != 2 {
|
||||
t.Fatalf("%d rows before the sweep, want 2", got)
|
||||
}
|
||||
|
||||
clock.advance(90 * time.Second)
|
||||
|
||||
loaded, loadedClock := newTestCache(t, db, resolver)
|
||||
loadedClock.advance(90 * time.Second)
|
||||
|
||||
err := loaded.load(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
|
||||
if len(loaded.entries) != 1 {
|
||||
t.Errorf("loaded %d entries, want the 1 not expired", len(loaded.entries))
|
||||
}
|
||||
|
||||
cache.sweep(t.Context())
|
||||
|
||||
if got := countRows(t, db); got != 1 {
|
||||
t.Errorf("%d rows after the sweep, want 1", got)
|
||||
}
|
||||
|
||||
if len(cache.entries) != 1 {
|
||||
t.Errorf("%d entries in memory after the sweep, want 1", len(cache.entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlushKeepsAnswersItCouldNotWrite makes one write fail by hiding the
|
||||
// table. The next write, once the table is back, stores the answer.
|
||||
func TestFlushKeepsAnswersItCouldNotWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := openTestDB(t, t.TempDir())
|
||||
resolver := newFakeResolver(t, ptrReply(300, testName))
|
||||
cache, _ := newTestCache(t, db, resolver)
|
||||
|
||||
lookupOK(t, cache, testAddress)
|
||||
|
||||
_, err := db.ExecContext(t.Context(), "ALTER TABLE hostnames RENAME TO hidden")
|
||||
if err != nil {
|
||||
t.Fatalf("renaming the table: %v", err)
|
||||
}
|
||||
|
||||
cache.flush(t.Context())
|
||||
|
||||
_, err = db.ExecContext(t.Context(), "ALTER TABLE hidden RENAME TO hostnames")
|
||||
if err != nil {
|
||||
t.Fatalf("renaming the table back: %v", err)
|
||||
}
|
||||
|
||||
if got := countRows(t, db); got != 0 {
|
||||
t.Fatalf("%d rows after the failed write, want 0", got)
|
||||
}
|
||||
|
||||
cache.flush(t.Context())
|
||||
|
||||
if got := countRows(t, db); got != 1 {
|
||||
t.Errorf("%d rows after the second write, want 1", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user