diff --git a/.dockerignore b/.dockerignore index 875a9f5..4d55721 100644 --- a/.dockerignore +++ b/.dockerignore @@ -93,11 +93,12 @@ **/*.dylib # Local databases. The image carries a source archive of the whole build -# context, so these would otherwise ship inside it. The daemon's database -# is db.sqlite, with its -wal and -shm files while it runs. +# context, so these would otherwise ship inside it. The daemon's databases +# are db.sqlite and cache.db, with their -wal and -shm files while it runs. **/*.db **/*.db-journal **/*.db-wal +**/*.db-shm **/*.sqlite **/*.sqlite-wal **/*.sqlite-shm diff --git a/.gitignore b/.gitignore index b42a480..03c5ad5 100644 --- a/.gitignore +++ b/.gitignore @@ -68,10 +68,12 @@ go.work go.work.sum # Local databases, temporary files and debug output. The daemon's -# database is db.sqlite, with its -wal and -shm files while it runs. +# databases are db.sqlite and cache.db, with their -wal and -shm files +# while it runs. *.db *.db-journal *.db-wal +*.db-shm *.sqlite *.sqlite-wal *.sqlite-shm diff --git a/README.md b/README.md index 6336f23..8e453ae 100644 --- a/README.md +++ b/README.md @@ -281,8 +281,8 @@ answer holds `query`, the text looked up, and `results`, with one entry for each address. Every entry has the same fields: - `ip`: the address -- `ptr`: the address's PTR names, the host names that DNS gives for it, or an - empty list when it has none +- `ptr`: the address's PTR names, the host names that DNS gives for it (see PTR + lookups), or an empty list when it has none - `flags`: yes-or-no facts about the address: - `is_tor`: true when the address is on the `tor_exits` list - `is_bogon`: true when the address is reserved, so that no network should @@ -330,9 +330,6 @@ address, such as one that resolves to a private address, is reported as - `{ip}` comes to more than 10 addresses once its names are resolved - nothing in `{ip}` is an IP address or a host name with a public address -Each PTR lookup gets 2 seconds. An address whose lookup takes longer is answered -without PTR names. - A successful answer carries `Cache-Control` with a `max-age` of 15 minutes, the shortest IP list interval. The answer for the caller's own address (`/ip/`) is `private`, and the others are `public`. @@ -448,6 +445,43 @@ How RouteWatch keeps the lists: - When a download fails, or its file does not parse, RouteWatch keeps the previous copy and logs the failure. It tries again one interval later. +## PTR lookups + +An IP lookup finds each address's PTR names over DNS-over-HTTPS. It asks these +public resolvers in order, until one answers: + +- Google: `https://dns.google/dns-query` +- Cloudflare: `https://cloudflare-dns.com/dns-query` +- AdGuard: `https://unfiltered.adguard-dns.com/dns-query` +- Quad9: `https://dns.quad9.net/dns-query` + +One address's lookup gets 2 seconds across all four. Each resolver gets an equal +share of the time left, so one that does not answer leaves time for the next. +When none answers in time, the address's `ptr` is an empty list. + +A reserved address, one whose `is_bogon` flag is true, is not sent to the +resolvers. Its `ptr` is an empty list. + +What goes outside: each resolver asked receives the address being looked up. For +`/ip/` and the home page, that address is the visitor's own. A lookup answered +from the cache sends nothing. + +How RouteWatch keeps the answers: + +- It holds answers in memory. An answer that has not expired is served from + there. +- It writes new answers every 5 seconds to `cache.db` in the state directory + (see Configuration), and loads them from there at startup. +- An answer expires after the TTL of its records, the shortest one when there + are several. RouteWatch keeps it for at least 60 seconds and at most 24 hours. +- When an address has no PTR names, that answer expires after the SOA record's + minimum field, or the record's own TTL when that is shorter. RouteWatch keeps + it for at most 1 hour. +- An answer with no PTR names and no SOA record is not kept. Neither is one + whose SOA record has a TTL or minimum field of 0. +- A failed lookup is not kept, so the next lookup of the address asks again. +- Every 10 minutes it deletes expired answers from memory and from `cache.db`. + ## Configuration RouteWatch reads its settings from environment variables. It takes no @@ -496,7 +530,13 @@ by the client. For a request from any other address, they use the address the request came from, whatever headers it carries. `X-Real-IP` and `True-Client-IP` are not read. -The state directory holds the database, `db.sqlite`, and the IP list copies: +The state directory holds: + +- `db.sqlite`: the database +- `cache.db`: the answers of PTR lookups +- `iplists`: the IP list copies + +It is at: - macOS: `~/Library/Application Support/berlin.sneak.app.routewatch/` - Linux and the BSDs, as root: `/var/lib/berlin.sneak.app.routewatch/` @@ -653,6 +693,8 @@ The parts: with old or missing details asks the fetcher for it at once, without the wait. - The IP lists part downloads the IP lists and answers which lists hold an address. +- The PTR cache looks up the PTR names of addresses and keeps the answers (see + PTR lookups). - The database maintainer checkpoints the database every 5 seconds: it folds the write-ahead log, where SQLite first writes changes, into the database file. It reclaims free space every 10 minutes. Every hour it refreshes the statistics @@ -762,6 +804,7 @@ On `SIGTERM` or `SIGINT` the daemon stops its parts in this order: 4. the database maintainer 5. the IP list downloads 6. the HTTP server +7. the PTR cache, which writes what it holds Last, the daemon checkpoints and closes the database. It gives all this 8 seconds and then exits with status 0. A stop that is still running after 9 @@ -782,6 +825,8 @@ What a stop can lose: - When the 8 seconds run out, the streamer and the handlers log what they could not hand over or write. The database is then not closed. When it next opens, SQLite rolls back any write the exit cut off. +- When the 8 seconds run out, the PTR answers not yet in `cache.db` are lost. + RouteWatch asks the resolvers for them again the next time they are needed. ### Code layout @@ -795,11 +840,13 @@ routewatch/ │ └── message-examples.json # Examples of RIS Live messages ├── internal/ │ ├── bogon/ # Reserved prefixes and AS numbers +│ ├── cachedb/ # cache.db, the caches of outside answers │ ├── config/ # Settings from the environment │ ├── database/ # SQLite storage │ ├── iplist/ # Public IP lists: downloads and lookups │ ├── logger/ # Structured logging │ ├── metrics/ # Feed and route update figures +│ ├── ptr/ # PTR lookups over DNS-over-HTTPS, and their cache │ ├── ristypes/ # The RIS Live message types │ ├── routetable/ # The whole routing table in memory │ ├── routewatch/ # The daemon: startup, handlers, WHOIS fetcher @@ -848,6 +895,13 @@ announcements(id PRIMARY KEY, prefix_id, peer_asn, origin_asn, path, The indexes are in `internal/database/schema.sql`. +`cache.db` is a separate file, with one table: + +```sql +-- The answers of PTR lookups. names is empty for an address with no PTR names. +hostnames(address PRIMARY KEY, names, expires_at) +``` + ## Development The `make` targets: diff --git a/TODO.md b/TODO.md index 38ff071..8cc175a 100644 --- a/TODO.md +++ b/TODO.md @@ -53,6 +53,9 @@ problem the run finds. # Completed Steps +- 2026-11-10: the IP lookup finds PTR names over DNS-over-HTTPS and caches them + for the record's TTL, in memory and in `cache.db` (closes + https://git.eeqj.de/sneak/routewatch/issues/238). - 2026-11-10: `tor_exits` comes from Onionoo, so it holds IPv6 exits and `is_tor` is true for them (closes https://git.eeqj.de/sneak/routewatch/issues/234). diff --git a/go.mod b/go.mod index b10f623..fe4c33b 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,11 @@ require ( github.com/google/btree v1.1.3 github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.29 + github.com/miekg/dns v1.1.73 github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 go.uber.org/fx v1.24.0 go4.org/netipx v0.0.0-20260823151212-3075585bcbeb - golang.org/x/term v0.33.0 + golang.org/x/term v0.45.0 golang.org/x/text v0.43.0 ) @@ -20,5 +21,6 @@ require ( go.uber.org/dig v1.19.0 // indirect go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.26.0 // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 244a36b..3eb4d33 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg= @@ -30,10 +32,14 @@ go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs= go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/bogon/bogon.go b/internal/bogon/bogon.go index f97ebe4..f956778 100644 --- a/internal/bogon/bogon.go +++ b/internal/bogon/bogon.go @@ -1,5 +1,5 @@ -// Package bogon tells whether a prefix or an AS number is reserved: set aside -// for a special purpose, so that no network should announce it on the +// Package bogon tells whether a prefix, an address or an AS number is reserved: +// set aside for a special purpose, so that no network should announce it on the // internet. A route is a bogon announcement when its prefix is reserved or its // origin AS is. // @@ -42,6 +42,15 @@ func Prefix(prefix netip.Prefix) (string, bool) { return holder.name + " (" + holder.prefix.String() + ")", true } +// Addr returns why addr is reserved, and true, when it lies inside a reserved +// block, as Prefix decides. An IPv4 address written in IPv6 form counts as the +// IPv4 address it holds. It returns "" and false when addr is not reserved. +func Addr(addr netip.Addr) (string, bool) { + addr = addr.Unmap() + + return Prefix(netip.PrefixFrom(addr, addr.BitLen())) +} + // ASN returns why asn is reserved, and true, when it is. It returns "" and // false when asn is not reserved. func ASN(asn int) (string, bool) { diff --git a/internal/bogon/bogon_test.go b/internal/bogon/bogon_test.go index 3de2d10..f34a065 100644 --- a/internal/bogon/bogon_test.go +++ b/internal/bogon/bogon_test.go @@ -56,6 +56,39 @@ func TestPrefix(t *testing.T) { } } +func TestAddr(t *testing.T) { + t.Parallel() + + tests := []struct { + addr string + // want is why the address is reserved, or "" when it is not. + want string + }{ + {"10.0.0.1", "Private-Use (10.0.0.0/8)"}, + {"fd00::1", "Unique-Local (fc00::/7)"}, + // An IPv4 address written in IPv6 form counts as the IPv4 address. + {"::ffff:10.0.0.1", "Private-Use (10.0.0.0/8)"}, + {"::ffff:8.8.8.8", ""}, + // An address that may be announced, inside a reserved block. + {"192.0.0.9", ""}, + // Addresses anyone may announce. + {"8.8.8.8", ""}, + {"2001:4860:4860::8888", ""}, + } + + for _, tc := range tests { + t.Run(tc.addr, func(t *testing.T) { + t.Parallel() + + reason, reserved := bogon.Addr(netip.MustParseAddr(tc.addr)) + if reason != tc.want || reserved != (tc.want != "") { + t.Errorf("Addr(%s) = %q, %v; want %q", tc.addr, reason, + reserved, tc.want) + } + }) + } +} + func TestASN(t *testing.T) { t.Parallel() diff --git a/internal/cachedb/cachedb.go b/internal/cachedb/cachedb.go new file mode 100644 index 0000000..1ffb71d --- /dev/null +++ b/internal/cachedb/cachedb.go @@ -0,0 +1,51 @@ +// Package cachedb opens cache.db, the SQLite file in the state directory that +// holds RouteWatch's caches of answers from outside services. It is separate +// from the routes database so that a cache write never queues behind a write +// to that database. +package cachedb + +import ( + "context" + "database/sql" + _ "embed" // for schema.sql + "fmt" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" // CGO SQLite driver +) + +const ( + // fileName is the database's file in the state directory. + fileName = "cache.db" + + // busyTimeoutMs is how long a write waits for another write to finish. + busyTimeoutMs = 5000 +) + +//go:embed schema.sql +var schema string + +// Open opens cache.db in stateDir, creating the file and its tables when they +// do not exist. The caller closes the database. +func Open(ctx context.Context, stateDir string) (*sql.DB, error) { + dsn := fmt.Sprintf( + "file:%s?_journal_mode=WAL&_synchronous=NORMAL&_busy_timeout=%d"+ + "&_txlock=immediate", + filepath.Join(stateDir, fileName), + busyTimeoutMs, + ) + + db, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("opening the cache database: %w", err) + } + + _, err = db.ExecContext(ctx, schema) + if err != nil { + _ = db.Close() + + return nil, fmt.Errorf("creating the cache database's tables: %w", err) + } + + return db, nil +} diff --git a/internal/cachedb/cachedb_internal_test.go b/internal/cachedb/cachedb_internal_test.go new file mode 100644 index 0000000..07c2fa0 --- /dev/null +++ b/internal/cachedb/cachedb_internal_test.go @@ -0,0 +1,64 @@ +package cachedb + +import ( + "os" + "path/filepath" + "testing" +) + +// TestOpenCreatesTheFileInWALMode opens a new database, and then opens it +// again, as a restart does. +func TestOpenCreatesTheFileInWALMode(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + for range 2 { + db, err := Open(t.Context(), dir) + if err != nil { + t.Fatalf("Open: %v", err) + } + + var mode string + + err = db.QueryRowContext(t.Context(), "PRAGMA journal_mode").Scan(&mode) + if err != nil { + t.Fatalf("reading the journal mode: %v", err) + } + + if mode != "wal" { + t.Errorf("journal mode %q, want wal", mode) + } + + var rows int + + err = db.QueryRowContext(t.Context(), + "SELECT COUNT(*) FROM hostnames").Scan(&rows) + if err != nil { + t.Fatalf("reading the hostnames table: %v", err) + } + + err = db.Close() + if err != nil { + t.Fatalf("Close: %v", err) + } + } + + _, err := os.Stat(filepath.Join(dir, fileName)) + if err != nil { + t.Errorf("the file is not in the state directory: %v", err) + } +} + +// TestOpenFailsWhenTheDirectoryDoesNotExist checks that a state directory that +// is not there is reported, not ignored. +func TestOpenFailsWhenTheDirectoryDoesNotExist(t *testing.T) { + t.Parallel() + + db, err := Open(t.Context(), filepath.Join(t.TempDir(), "missing")) + if err == nil { + _ = db.Close() + + t.Fatal("Open succeeded in a directory that does not exist") + } +} diff --git a/internal/cachedb/schema.sql b/internal/cachedb/schema.sql new file mode 100644 index 0000000..4d5d8bc --- /dev/null +++ b/internal/cachedb/schema.sql @@ -0,0 +1,8 @@ +-- hostnames holds the PTR names of addresses. names has the names separated by +-- spaces, and is empty for an address with no PTR names. expires_at is in +-- seconds since 1970. +CREATE TABLE IF NOT EXISTS hostnames ( + address TEXT PRIMARY KEY, + names TEXT NOT NULL, + expires_at INTEGER NOT NULL +); diff --git a/internal/ptr/cache.go b/internal/ptr/cache.go new file mode 100644 index 0000000..d198128 --- /dev/null +++ b/internal/ptr/cache.go @@ -0,0 +1,311 @@ +// Package ptr looks up the PTR names of addresses over DNS-over-HTTPS. It +// keeps each answer in memory for as long as DNS allows, and in cache.db so +// that the answers survive a restart. +package ptr + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "maps" + "net/http" + "net/netip" + "slices" + "strings" + "sync" + "time" + + "git.eeqj.de/sneak/routewatch/internal/bogon" + "github.com/miekg/dns" +) + +const ( + // flushInterval is how often new answers are written to cache.db. + flushInterval = 5 * time.Second + + // sweepInterval is how often expired answers are deleted. + sweepInterval = 10 * time.Minute + + // nameSeparator separates the names in a row of the hostnames table. + nameSeparator = " " +) + +// Params is what New needs. +type Params struct { + // DB is the open cache.db. + DB *sql.DB + + // Resolvers are the URLs of the DNS-over-HTTPS resolvers to ask, in order. + Resolvers []string + + Logger *slog.Logger +} + +// entry is one address's cached answer. +type entry struct { + // names are the address's PTR names, nil when it has none. + names []string + expires time.Time +} + +// Cache answers PTR lookups from memory, and asks the resolvers for an address +// it holds no current answer for. Start loads it from cache.db. +type Cache struct { + db *sql.DB + resolvers []string + timeLimit time.Duration + logger *slog.Logger + client *http.Client + now func() time.Time + + // entries holds every answer by address. pending holds those that + // cache.db does not have yet. + mu sync.RWMutex + entries map[string]entry + pending map[string]entry + + // stop ends the goroutine Start runs, and done is closed when it returns. + stop context.CancelFunc + done chan struct{} +} + +// New returns an empty Cache. Start loads it. +func New(params Params) *Cache { + return &Cache{ + db: params.DB, + resolvers: params.Resolvers, + timeLimit: LookupTimeout, + logger: params.Logger.With("component", "ptr"), + client: &http.Client{}, + now: time.Now, + entries: make(map[string]entry), + pending: make(map[string]entry), + done: make(chan struct{}), + } +} + +// LookupAddr returns the PTR names of addr, fully qualified, or none when it +// has none. A reserved address has none, and is not sent to the resolvers. A +// lookup takes at most LookupTimeout. A cached answer makes no outside request. +// It returns an error when no resolver answered, and nothing is cached then. +func (c *Cache) LookupAddr(ctx context.Context, addr string) ([]string, error) { + parsed, err := netip.ParseAddr(addr) + if err != nil { + return nil, fmt.Errorf("parsing the address: %w", err) + } + + _, reserved := bogon.Addr(parsed) + if reserved { + return []string{}, nil + } + + address := parsed.Unmap().String() + + cached, ok := c.current(address) + if ok { + return slices.Clone(cached.names), nil + } + + reverseName, err := dns.ReverseAddr(address) + if err != nil { + return nil, fmt.Errorf("building the reverse name: %w", err) + } + + result, err := c.lookup(ctx, reverseName) + if err != nil { + return nil, err + } + + if result.ttl > 0 { + c.store(address, entry{names: result.names, expires: c.now().Add(result.ttl)}) + } + + return slices.Clone(result.names), nil +} + +// Start loads the answers that have not expired from cache.db. It then starts +// the goroutine that writes new answers there and deletes expired ones. The +// goroutine runs until Stop: the end of ctx does not stop it. +func (c *Cache) Start(ctx context.Context) error { + err := c.load(ctx) + if err != nil { + return err + } + + c.logger.Info("PTR cache loaded", "entries", len(c.entries)) + + ctx, c.stop = context.WithCancel(context.WithoutCancel(ctx)) + + go c.run(ctx) + + return nil +} + +// Stop ends the goroutine, which first writes the answers cache.db does not +// have yet, and waits until it has returned or ctx ends. +func (c *Cache) Stop(ctx context.Context) { + c.stop() + + select { + case <-c.done: + c.logger.Info("PTR cache stopped") + case <-ctx.Done(): + c.logger.Warn("Stop deadline passed before the PTR cache was written") + } +} + +// current returns the answer held for address, unless it has expired. +func (c *Cache) current(address string) (entry, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + found, ok := c.entries[address] + if !ok || !c.now().Before(found.expires) { + return entry{}, false + } + + return found, true +} + +// store keeps an answer in memory and queues it for cache.db. +func (c *Cache) store(address string, answer entry) { + c.mu.Lock() + defer c.mu.Unlock() + + c.entries[address] = answer + c.pending[address] = answer +} + +// run writes new answers and deletes expired ones until ctx ends. +func (c *Cache) run(ctx context.Context) { + defer close(c.done) + + flushes := time.NewTicker(flushInterval) + defer flushes.Stop() + + sweeps := time.NewTicker(sweepInterval) + defer sweeps.Stop() + + for { + select { + case <-ctx.Done(): + c.flush(context.WithoutCancel(ctx)) + + return + case <-flushes.C: + c.flush(ctx) + case <-sweeps.C: + c.sweep(ctx) + } + } +} + +// load reads the rows that have not expired into memory. +func (c *Cache) load(ctx context.Context) error { + rows, err := c.db.QueryContext(ctx, + "SELECT address, names, expires_at FROM hostnames WHERE expires_at > ?", + c.now().Unix()) + if err != nil { + return fmt.Errorf("reading the PTR cache: %w", err) + } + + defer func() { _ = rows.Close() }() + + for rows.Next() { + var ( + address, names string + expires int64 + ) + + err = rows.Scan(&address, &names, &expires) + if err != nil { + return fmt.Errorf("reading a PTR cache row: %w", err) + } + + c.entries[address] = entry{ + names: strings.Fields(names), + expires: time.Unix(expires, 0).UTC(), + } + } + + err = rows.Err() + if err != nil { + return fmt.Errorf("reading the PTR cache: %w", err) + } + + return nil +} + +// flush writes the pending answers to cache.db. Those it cannot write stay +// pending, unless a newer answer for the address has arrived meanwhile. +func (c *Cache) flush(ctx context.Context) { + c.mu.Lock() + batch := c.pending + c.pending = make(map[string]entry) + c.mu.Unlock() + + if len(batch) == 0 { + return + } + + err := c.writeRows(ctx, batch) + if err == nil { + return + } + + c.logger.Error("Failed to write the PTR cache", "error", err) + + c.mu.Lock() + defer c.mu.Unlock() + + for address, answer := range batch { + _, newer := c.pending[address] + if !newer { + c.pending[address] = answer + } + } +} + +// writeRows writes batch to cache.db in one transaction. +func (c *Cache) writeRows(ctx context.Context, batch map[string]entry) error { + tx, err := c.db.BeginTx(ctx, nil) + if err != nil { + return err + } + + defer func() { _ = tx.Rollback() }() + + for address, answer := range batch { + _, err = tx.ExecContext(ctx, + "INSERT OR REPLACE INTO hostnames (address, names, expires_at) "+ + "VALUES (?, ?, ?)", + address, + strings.Join(answer.names, nameSeparator), + answer.expires.Unix()) + if err != nil { + return err + } + } + + return tx.Commit() +} + +// sweep deletes the expired answers from memory and cache.db. +func (c *Cache) sweep(ctx context.Context) { + now := c.now() + expired := func(_ string, answer entry) bool { + return !now.Before(answer.expires) + } + + c.mu.Lock() + maps.DeleteFunc(c.entries, expired) + maps.DeleteFunc(c.pending, expired) + c.mu.Unlock() + + _, err := c.db.ExecContext(ctx, + "DELETE FROM hostnames WHERE expires_at <= ?", now.Unix()) + if err != nil { + c.logger.Error("Failed to delete expired PTR cache rows", "error", err) + } +} diff --git a/internal/ptr/cache_internal_test.go b/internal/ptr/cache_internal_test.go new file mode 100644 index 0000000..e1f1575 --- /dev/null +++ b/internal/ptr/cache_internal_test.go @@ -0,0 +1,882 @@ +package ptr + +import ( + "context" + "database/sql" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "sync" + "testing" + "testing/synctest" + "time" + + "git.eeqj.de/sneak/routewatch/internal/cachedb" + "github.com/miekg/dns" +) + +// These addresses are public, because a reserved address is not sent to the +// resolvers. That rules out the documentation ranges. +const ( + testAddress = "8.8.4.4" + testReverse = "4.4.8.8.in-addr.arpa." + mappedAddress = "::ffff:" + testAddress + otherAddress = "8.8.8.8" + testV6Address = "2606:4700:4700::1111" + testV6Reverse = "1.1.1.1.0.0.0.0.0.0.0.0.0.0.0.0." + + "0.0.0.0.0.0.7.4.0.0.7.4.6.0.6.2.ip6.arpa." +) + +const ( + testName = "host.example.com." + otherName = "other.example.com." + + // testTimeLimit replaces LookupTimeout in the tests that use a real + // server. A stall on a busy host cannot use it up, and no test waits for + // it. + testTimeLimit = 2 * time.Minute +) + +// classlessOwner is the name that holds the PTR records of a CNAME chain. +const classlessOwner = "4.0-25.4.8.8.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, has testTimeLimit, 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 + cache.timeLimit = testTimeLimit + + return cache, clock +} + +// handlerTransport hands each request to the fake resolver for its host, with +// no network between them. A test in a synctest bubble needs this: the bubble's +// clock stands still while one of its goroutines waits on a real connection. As +// over a network, a request whose time is up is not sent. +type handlerTransport map[string]*fakeResolver + +func (h handlerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + recorder := httptest.NewRecorder() + + if r.Context().Err() == nil { + h[r.URL.Host].serve(recorder, r) + } + + err := r.Context().Err() + if err != nil { + return nil, err + } + + return recorder.Result(), nil +} + +// newBubbleCache returns a Cache with the real time limit, for a test in a +// synctest bubble. It has one fake resolver for each reply, in order, reached +// through a handlerTransport. +func newBubbleCache( + replies ...func(http.ResponseWriter, *http.Request, *dns.Msg), +) (*Cache, []*fakeResolver) { + transport := make(handlerTransport, len(replies)) + resolvers := make([]*fakeResolver, 0, len(replies)) + urls := make([]string, 0, len(replies)) + + for i, reply := range replies { + host := "resolver" + strconv.Itoa(i) + ".example.com" + resolver := &fakeResolver{reply: reply} + + transport[host] = resolver + resolvers = append(resolvers, resolver) + urls = append(urls, "https://"+host+"/dns-query") + } + + cache := New(Params{Resolvers: urls, Logger: slog.Default()}) + cache.client = &http.Client{Transport: transport} + + return cache, resolvers +} + +// 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}, + {mappedAddress, 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) + } + } +} + +// TestOnlyPublicAddressesAreSentToTheResolvers gives every resolver an answer +// to send, which a reserved address must not get. +func TestOnlyPublicAddressesAreSentToTheResolvers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + address string + want []string + requests int + }{ + {"private IPv4", "10.0.0.1", []string{}, 0}, + {"documentation IPv4", "192.0.2.1", []string{}, 0}, + {"private IPv4 in IPv6 form", "::ffff:10.0.0.1", []string{}, 0}, + {"unique-local IPv6", "fd00::1", []string{}, 0}, + {"documentation IPv6", "2001:db8::1", []string{}, 0}, + {"public IPv4", testAddress, []string{testName}, 1}, + {"public IPv4 in IPv6 form", mappedAddress, []string{testName}, 1}, + {"public IPv6", testV6Address, []string{testName}, 1}, + } + + for _, tt := range tests { + resolver := newFakeResolver(t, ptrReply(300, testName)) + cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) + + names := lookupOK(t, cache, tt.address) + if names == nil || !slices.Equal(names, tt.want) { + t.Errorf("%s: names %#v, want %#v", tt.name, names, tt.want) + } + + if got := resolver.requestCount(); got != tt.requests { + t.Errorf("%s: %d requests, want %d", tt.name, got, tt.requests) + } + } +} + +// 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. The first gets half of it, the +// share of one resolver in two. The test runs in a synctest bubble, whose clock +// is fake, so a stall on the host uses none of the limit. +func TestResolverThatNeverAnswersLeavesTimeForTheNext(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + cache, _ := newBubbleCache(hangingReply, ptrReply(300, testName)) + + start := time.Now() + names := lookupOK(t, cache, testAddress) + elapsed := time.Since(start) + + if !slices.Equal(names, []string{testName}) { + t.Errorf("names %v, want %s", names, testName) + } + + if elapsed != time.Second { + t.Errorf("took %s, want 1s", elapsed) + } + }) +} + +// TestLookupStopsAtTheTimeLimit has no resolver answer. The lookup fails when +// 2 seconds have passed, having asked every resolver. The test runs in a +// synctest bubble, whose clock is fake, so a stall on the host uses none of the +// limit. +func TestLookupStopsAtTheTimeLimit(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + cache, resolvers := newBubbleCache( + hangingReply, hangingReply, hangingReply, hangingReply) + + start := time.Now() + _, err := cache.LookupAddr(t.Context(), testAddress) + elapsed := time.Since(start) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("error %v, want %v", err, context.DeadlineExceeded) + } + + if elapsed != 2*time.Second { + t.Errorf("took %s, want 2s", elapsed) + } + + 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, mappedAddress) + lookupOK(t, cache, testAddress) + + if got := resolver.requestCount(); got != 1 { + t.Errorf("%d requests, want 1", got) + } +} + +// TestCallerCannotChangeACachedAnswer changes the list each lookup returned, as +// the server does when it removes the trailing dots. The second lookup is the +// first answered from the cache. The third shows that the cached list did not +// change with the list the second returned. +func TestCallerCannotChangeACachedAnswer(t *testing.T) { + t.Parallel() + + resolver := newFakeResolver(t, ptrReply(300, testName)) + cache, _ := newTestCache(t, openTestDB(t, t.TempDir()), resolver) + + for range 3 { + 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