Resolve PTR names over DNS-over-HTTPS and cache them for the record TTL (closes #238)
check / check (push) Successful in 4m39s
check / check (push) Successful in 4m39s
`ptr` keeps its field and shape. It now comes from Google, Cloudflare, AdGuard and Quad9 over RFC 8484, tried in turn within 2 seconds, in place of the host's resolver with no cache. Answers stay in memory and in a new `cache.db` for the record's TTL, at most 250,000 addresses. A reserved address is not sent to any resolver and has an empty `ptr`. Judgement call: each resolver gets an equal share of the time left, so a hung one does not use up the limit. Judgement call: the negative TTL is the smaller of the SOA minimum and the SOA's own TTL. Deviation: the cache is SQLite, by sneak's words, not the JSON file the standing rule asks for. Model: opus-5-5
This commit was merged in pull request #244.
This commit is contained in:
1 parent
641798bb22
commit
0cbd9cd943
21 files changed
+1973
-69
No files matched your search
@@ -9,17 +9,20 @@ package routewatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/cachedb"
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/iplist"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||
"git.eeqj.de/sneak/routewatch/internal/ptr"
|
||||
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||
"git.eeqj.de/sneak/routewatch/internal/server"
|
||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||
@@ -62,6 +65,8 @@ type RouteWatch struct {
|
||||
asnFetcher *ASNFetcher
|
||||
dbMaintainer *DBMaintainer
|
||||
ipLists *iplist.IPLists
|
||||
cacheDB *sql.DB
|
||||
ptrCache *ptr.Cache
|
||||
}
|
||||
|
||||
// ipListsDirName is the directory in the state directory that holds the IP
|
||||
@@ -150,6 +155,11 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
|
||||
rw.ipLists = ipLists
|
||||
rw.server.SetIPLists(ipLists)
|
||||
|
||||
err = rw.startPTRCache(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
err = rw.server.Start(ctx)
|
||||
if err != nil {
|
||||
@@ -168,8 +178,8 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
|
||||
|
||||
// Shutdown stops all services in order: the streamer, the handlers, which
|
||||
// write what they hold, the WHOIS fetcher, the database maintainer, the
|
||||
// IP list downloads and the HTTP server, and last the database, which it
|
||||
// checkpoints and closes. Each stage waits at most until ctx ends, so
|
||||
// IP list downloads, the HTTP server and the PTR cache, and last the database,
|
||||
// which it checkpoints and closes. Each stage waits at most until ctx ends, so
|
||||
// Shutdown returns soon after: a handler logs what it could not write by
|
||||
// then, and the HTTP server closes the connections of requests still
|
||||
// running.
|
||||
@@ -229,6 +239,8 @@ func (rw *RouteWatch) Shutdown(ctx context.Context) {
|
||||
"error", err)
|
||||
}
|
||||
|
||||
rw.stopPTRCache(ctx)
|
||||
|
||||
rw.closeDatabase(ctx)
|
||||
|
||||
// Log final metrics
|
||||
@@ -242,6 +254,55 @@ func (rw *RouteWatch) Shutdown(ctx context.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// startPTRCache opens cache.db, loads the PTR cache from it and gives the
|
||||
// server the cache. The HTTP handlers read it without a lock, so it is set
|
||||
// before they run.
|
||||
func (rw *RouteWatch) startPTRCache(ctx context.Context) error {
|
||||
cacheDB, err := cachedb.Open(ctx, rw.config.StateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ptrCache := ptr.New(ptr.Params{
|
||||
DB: cacheDB,
|
||||
Resolvers: ptr.Resolvers(),
|
||||
Logger: rw.logger.Logger,
|
||||
})
|
||||
|
||||
err = ptrCache.Start(ctx)
|
||||
if err != nil {
|
||||
_ = cacheDB.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
rw.cacheDB = cacheDB
|
||||
rw.ptrCache = ptrCache
|
||||
rw.server.SetPTRResolver(ptrCache)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopPTRCache stops the PTR cache, which writes the answers cache.db does not
|
||||
// have yet, and closes cache.db, unless ctx has ended and a write may still be
|
||||
// running.
|
||||
func (rw *RouteWatch) stopPTRCache(ctx context.Context) {
|
||||
if rw.ptrCache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rw.ptrCache.Stop(ctx)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := rw.cacheDB.Close()
|
||||
if err != nil {
|
||||
rw.logger.Error("Failed to close the cache database", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// closeDatabase checkpoints and closes the database, unless ctx has ended: a
|
||||
// write may then still be running, and both would wait for it. SQLite rolls
|
||||
// back a write cut off by the exit when the database next opens.
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -783,3 +785,74 @@ func TestShutdownCheckpointsAndClosesTheDatabaseLast(t *testing.T) {
|
||||
t.Errorf("Shutdown made the calls %q, want %q", store.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPTRCacheAnswersTheIPLookup starts the PTR cache and the HTTP server as
|
||||
// Run does, and looks up a reserved address, which asks no resolver. Without
|
||||
// the PTR cache the server has no PTR resolver, and every IP lookup answers
|
||||
// 500.
|
||||
func TestPTRCacheAnswersTheIPLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newMockStore()
|
||||
log := logger.New()
|
||||
port := freePort(t)
|
||||
cfg := &config.Config{Port: port, StateDir: t.TempDir()}
|
||||
s := streamer.New(log, metrics.New())
|
||||
|
||||
// A started server reads its routing table in the background, so it
|
||||
// needs one.
|
||||
rw := New(Dependencies{
|
||||
DB: store,
|
||||
Streamer: s,
|
||||
Server: server.New(store, routetable.New(routetable.Params{}), s, log,
|
||||
cfg),
|
||||
Logger: log,
|
||||
Config: cfg,
|
||||
})
|
||||
|
||||
err := rw.startPTRCache(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start the PTR cache: %v", err)
|
||||
}
|
||||
|
||||
defer rw.stopPTRCache(t.Context())
|
||||
|
||||
err = rw.server.Start(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start the HTTP server: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rw.server.Stop(t.Context()) }()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||
"http://"+net.JoinHostPort("127.0.0.1", strconv.Itoa(port))+
|
||||
"/api/v1/ip/10.0.0.1", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build the request: %v", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("the IP lookup failed: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read the answer: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, want %d; body %s", resp.StatusCode, http.StatusOK, body)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(body), `"ptr":[]`) {
|
||||
t.Errorf("answer %s, want an empty ptr", body)
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(cfg.StateDir, "cache.db"))
|
||||
if err != nil {
|
||||
t.Errorf("cache.db is not in the state directory: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user