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 db4e730..67ff3f9 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`. @@ -447,6 +444,38 @@ 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. + +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 every answer 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. +- An answer that an address has no PTR names 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. +- 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 @@ -495,7 +524,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/` @@ -652,6 +687,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 @@ -761,6 +798,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 @@ -781,6 +819,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 @@ -794,11 +834,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 @@ -847,6 +889,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 39f3c2e..5376740 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: each result of the IP lookup has a `flags` object with `is_tor` and `is_bogon`. The public IP lists are named `ip_lists` in the answer, `internal/iplist` in the code and `iplists` in the state directory. A list 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/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..00a493a --- /dev/null +++ b/internal/ptr/cache.go @@ -0,0 +1,303 @@ +// 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" + + "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 + 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, + 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. It 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) + } + + 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..1b38400 --- /dev/null +++ b/internal/ptr/cache_internal_test.go @@ -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