Retry a WHOIS lookup that failed instead of storing it as complete (closes #105)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A lookup whose RIR query failed, or whose RIR answer carried none of the WHOIS fields (a refusal of service, for one), returned IANA's answer as a success. The fetcher stored it as complete with blank fields, did not look the AS up again for 30 days, and shortened its interval. Such a lookup is now an error: the fetcher backs off and leaves the row as it was. The fields are read from the RIR's answer alone. An AS IANA refers to no RIR is stored with no WHOIS data, so it is not looked up again until it is stale, counting as neither a success nor a failure. Tests reach fake WHOIS servers through the client's DialContext. Model: opus-5-5
This commit was merged in pull request #127.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
package routewatch
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
)
|
||||
|
||||
// The WHOIS servers a lookup asks: IANA first, then the RIR IANA refers it
|
||||
// to.
|
||||
const (
|
||||
ianaWHOISServer = "whois.iana.org:43"
|
||||
ripeWHOISServer = "whois.ripe.net:43"
|
||||
)
|
||||
|
||||
// ianaRefersToRIPE is IANA's answer for an AS in a block it gave to RIPE.
|
||||
const ianaRefersToRIPE = `refer: whois.ripe.net
|
||||
|
||||
as-block: 64496-64511
|
||||
organisation: Assigned by RIPE NCC
|
||||
changed: 2002-09-01
|
||||
source: IANA
|
||||
`
|
||||
|
||||
// ripeAccessDenied is RIPE's answer to a host it no longer serves.
|
||||
const ripeAccessDenied = `% This is the RIPE Database query service.
|
||||
%ERROR:201: access denied for 192.0.2.1
|
||||
`
|
||||
|
||||
// ianaPrivateUseBlock is IANA's answer for a private-use AS: its block and
|
||||
// no referral.
|
||||
const ianaPrivateUseBlock = `as-block: 64512-65534
|
||||
organisation: Reserved for Private Use
|
||||
source: IANA
|
||||
`
|
||||
|
||||
// newFetcherWithWHOISAnswers returns an ASN fetcher on a new database that
|
||||
// holds testASNA, never looked up. The fetcher's WHOIS client connects to a
|
||||
// fake server for each address in answers, which reads the query and sends
|
||||
// answers[address]; connecting to any other address is refused.
|
||||
func newFetcherWithWHOISAnswers(
|
||||
t *testing.T, answers map[string]string,
|
||||
) (*ASNFetcher, *database.Database) {
|
||||
t.Helper()
|
||||
|
||||
db := newTestDatabase(t)
|
||||
|
||||
_, err := db.GetOrCreateASN(testASNA, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateASN: %v", err)
|
||||
}
|
||||
|
||||
var servers sync.WaitGroup
|
||||
|
||||
t.Cleanup(servers.Wait)
|
||||
|
||||
f := NewASNFetcher(db, logger.New().Logger)
|
||||
f.whoisClient.DialContext = func(
|
||||
_ context.Context, _, address string,
|
||||
) (net.Conn, error) {
|
||||
answer, ok := answers[address]
|
||||
if !ok {
|
||||
return nil, syscall.ECONNREFUSED
|
||||
}
|
||||
|
||||
client, server := net.Pipe()
|
||||
|
||||
servers.Go(func() {
|
||||
defer func() { _ = server.Close() }()
|
||||
|
||||
_, _ = bufio.NewReader(server).ReadString('\n')
|
||||
_, _ = io.WriteString(server, answer)
|
||||
})
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
return f, db
|
||||
}
|
||||
|
||||
// TestFetchAndUpdateKeepsTheRowWhenTheLookupFails looks up an AS IANA refers
|
||||
// to RIPE when RIPE's server refuses the connection, and when it answers only
|
||||
// with an error. The AS must stay never looked up, and the fetcher must back
|
||||
// off.
|
||||
func TestFetchAndUpdateKeepsTheRowWhenTheLookupFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, answers := range map[string]map[string]string{
|
||||
"connection refused": {ianaWHOISServer: ianaRefersToRIPE},
|
||||
"only an error": {
|
||||
ianaWHOISServer: ianaRefersToRIPE,
|
||||
ripeWHOISServer: ripeAccessDenied,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, db := newFetcherWithWHOISAnswers(t, answers)
|
||||
|
||||
if f.fetchAndUpdate(t.Context(), testASNA) {
|
||||
t.Error("fetchAndUpdate reported success")
|
||||
}
|
||||
|
||||
stats, err := db.GetWHOISStats(t.Context(), whoisStaleThreshold)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWHOISStats: %v", err)
|
||||
}
|
||||
|
||||
if stats.NeverFetched != 1 {
|
||||
t.Errorf("never looked up = %d, want 1: the failed lookup "+
|
||||
"was stored", stats.NeverFetched)
|
||||
}
|
||||
|
||||
got := f.GetStats()
|
||||
if got.CurrentInterval != baseInterval*backoffMultiplier ||
|
||||
got.ConsecutiveFails != 1 {
|
||||
t.Errorf("interval %v after %d failures, want %v after 1",
|
||||
got.CurrentInterval, got.ConsecutiveFails,
|
||||
baseInterval*backoffMultiplier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchAndUpdateMarksAnASWithoutReferral looks up a private-use AS, which
|
||||
// IANA refers to no RIR. The AS must not be looked up again until it is
|
||||
// stale, and the lookup counts as neither a success nor a failure.
|
||||
func TestFetchAndUpdateMarksAnASWithoutReferral(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, db := newFetcherWithWHOISAnswers(t,
|
||||
map[string]string{ianaWHOISServer: ianaPrivateUseBlock})
|
||||
|
||||
f.fetchAndUpdate(t.Context(), testASNA)
|
||||
|
||||
asn, err := db.GetNextStaleASN(t.Context(), whoisStaleThreshold)
|
||||
if !errors.Is(err, database.ErrNoStaleASN) {
|
||||
t.Errorf("GetNextStaleASN = %d, %v; want ErrNoStaleASN", asn, err)
|
||||
}
|
||||
|
||||
got := f.GetStats()
|
||||
if got.CurrentInterval != baseInterval || got.SuccessesLastHour != 0 ||
|
||||
got.ErrorsLastHour != 0 {
|
||||
t.Errorf("interval %v, %d successes, %d errors; want %v, 0, 0",
|
||||
got.CurrentInterval, got.SuccessesLastHour, got.ErrorsLastHour,
|
||||
baseInterval)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user