check / check (push) Canceled after 0s
A lookup whose RIR is paused is skipped, and the skip left the fetcher's wait as it was. Each lookup asks IANA before it finds the RIR paused. So while most stale ASes were at a paused RIR, the fetcher could ask IANA every second. A skipped lookup now makes the wait 1.5 times as long, up to one minute, as a failure does. It is still not counted as a failure, so the status page's failure figures leave it out. Model: opus-5-5
424 lines
12 KiB
Go
424 lines
12 KiB
Go
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"
|
|
"git.eeqj.de/sneak/routewatch/internal/whois"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
want := baseInterval * backoffNumerator / backoffDenominator
|
|
|
|
got := f.GetStats()
|
|
if got.CurrentInterval != want || got.ConsecutiveFails != 1 {
|
|
t.Errorf("interval %v after %d failures, want %v after 1",
|
|
got.CurrentInterval, got.ConsecutiveFails, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFetchAndUpdateWaitsOutAPause looks up an AS at RIPE while RIPE refuses
|
|
// the query. The test sets the WHOIS client's clock. The next lookup comes a
|
|
// second before whois.PauseTime has passed. It sends RIPE nothing and leaves
|
|
// the AS never looked up. It counts as neither a success nor a failure. A
|
|
// lookup at whois.PauseTime asks RIPE again.
|
|
func TestFetchAndUpdateWaitsOutAPause(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f, db := newFetcherWithWHOISAnswers(t, map[string]string{
|
|
ianaWHOISServer: ianaRefersToRIPE,
|
|
ripeWHOISServer: ripeAccessDenied,
|
|
})
|
|
|
|
ripeAsks := 0
|
|
dial := f.whoisClient.DialContext
|
|
f.whoisClient.DialContext = func(
|
|
ctx context.Context, network, address string,
|
|
) (net.Conn, error) {
|
|
if address == ripeWHOISServer {
|
|
ripeAsks++
|
|
}
|
|
|
|
return dial(ctx, network, address)
|
|
}
|
|
|
|
start := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
|
|
now := start
|
|
f.whoisClient.Now = func() time.Time { return now }
|
|
|
|
f.fetchAndUpdate(t.Context(), testASNA)
|
|
|
|
now = start.Add(whois.PauseTime - time.Second)
|
|
|
|
f.fetchAndUpdate(t.Context(), testASNA)
|
|
|
|
if ripeAsks != 1 {
|
|
t.Errorf("RIPE was asked %d times during its pause, want 1", ripeAsks)
|
|
}
|
|
|
|
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", stats.NeverFetched)
|
|
}
|
|
|
|
got := f.GetStats()
|
|
if got.ConsecutiveFails != 1 || got.ErrorsLastHour != 1 ||
|
|
got.SuccessesLastHour != 0 {
|
|
t.Errorf("%d failures in a row, %d errors, %d successes; want 1, 1, 0",
|
|
got.ConsecutiveFails, got.ErrorsLastHour, got.SuccessesLastHour)
|
|
}
|
|
|
|
now = start.Add(whois.PauseTime)
|
|
|
|
f.fetchAndUpdate(t.Context(), testASNA)
|
|
|
|
if ripeAsks != 2 {
|
|
t.Errorf("RIPE was asked %d times after its pause, want 2", ripeAsks)
|
|
}
|
|
}
|
|
|
|
// TestFetchAndUpdateBacksOffDuringAPause looks up an AS at RIPE while RIPE
|
|
// refuses the query. The test sets the WHOIS client's clock. It looks the AS
|
|
// up again each time the interval has passed, so each later lookup is
|
|
// skipped. Each skip must make the interval 1.5 times as long, up to one
|
|
// minute, as a failure does. The skips must not count as failures.
|
|
func TestFetchAndUpdateBacksOffDuringAPause(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f, _ := newFetcherWithWHOISAnswers(t, map[string]string{
|
|
ianaWHOISServer: ianaRefersToRIPE,
|
|
ripeWHOISServer: ripeAccessDenied,
|
|
})
|
|
|
|
now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
|
|
f.whoisClient.Now = func() time.Time { return now }
|
|
|
|
f.fetchAndUpdate(t.Context(), testASNA)
|
|
|
|
for i, want := range []time.Duration{
|
|
33750 * time.Millisecond,
|
|
50625 * time.Millisecond,
|
|
time.Minute,
|
|
time.Minute,
|
|
} {
|
|
now = now.Add(f.getInterval())
|
|
|
|
f.fetchAndUpdate(t.Context(), testASNA)
|
|
|
|
if got := f.getInterval(); got != want {
|
|
t.Errorf("interval after %d skipped lookups = %v, want %v",
|
|
i+1, got, want)
|
|
}
|
|
}
|
|
|
|
got := f.GetStats()
|
|
if got.ConsecutiveFails != 1 || got.ErrorsLastHour != 1 {
|
|
t.Errorf("%d failures in a row and %d errors, want 1 and 1",
|
|
got.ConsecutiveFails, got.ErrorsLastHour)
|
|
}
|
|
}
|
|
|
|
// TestFetchAndUpdateKeepsAskingIANA looks up an AS while IANA's server
|
|
// refuses the connection. The test sets the WHOIS client's clock. The next
|
|
// lookup comes a second before whois.PauseTime has passed. IANA is never
|
|
// paused, so that lookup asks IANA again. Each refusal counts as a failure.
|
|
func TestFetchAndUpdateKeepsAskingIANA(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// With no answers, every server refuses the connection.
|
|
f, _ := newFetcherWithWHOISAnswers(t, map[string]string{})
|
|
|
|
ianaAsks := 0
|
|
dial := f.whoisClient.DialContext
|
|
f.whoisClient.DialContext = func(
|
|
ctx context.Context, network, address string,
|
|
) (net.Conn, error) {
|
|
if address == ianaWHOISServer {
|
|
ianaAsks++
|
|
}
|
|
|
|
return dial(ctx, network, address)
|
|
}
|
|
|
|
start := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
|
|
now := start
|
|
f.whoisClient.Now = func() time.Time { return now }
|
|
|
|
f.fetchAndUpdate(t.Context(), testASNA)
|
|
|
|
now = start.Add(whois.PauseTime - time.Second)
|
|
|
|
f.fetchAndUpdate(t.Context(), testASNA)
|
|
|
|
got := f.GetStats()
|
|
if ianaAsks != 2 || got.ConsecutiveFails != 2 {
|
|
t.Errorf("IANA was asked %d times, with %d failures in a row; "+
|
|
"want 2 and 2", ianaAsks, got.ConsecutiveFails)
|
|
}
|
|
}
|
|
|
|
// TestRecordStepsTheInterval has the fetcher fail from its base interval of
|
|
// 15 seconds, then succeed. Each failure makes the interval 1.5 times as
|
|
// long, up to one minute, and each success halves it, down to one second.
|
|
func TestRecordStepsTheInterval(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := NewASNFetcher(newMockStore(), logger.New().Logger)
|
|
|
|
for i, want := range []time.Duration{
|
|
22500 * time.Millisecond,
|
|
33750 * time.Millisecond,
|
|
50625 * time.Millisecond,
|
|
time.Minute,
|
|
time.Minute,
|
|
} {
|
|
f.recordFailure()
|
|
|
|
if got := f.getInterval(); got != want {
|
|
t.Errorf("interval after %d failures = %v, want %v",
|
|
i+1, got, want)
|
|
}
|
|
}
|
|
|
|
for i, want := range []time.Duration{
|
|
30 * time.Second,
|
|
15 * time.Second,
|
|
7500 * time.Millisecond,
|
|
3750 * time.Millisecond,
|
|
1875 * time.Millisecond,
|
|
time.Second,
|
|
time.Second,
|
|
} {
|
|
f.recordSuccess()
|
|
|
|
if got := f.getInterval(); got != want {
|
|
t.Errorf("interval after %d successes = %v, want %v",
|
|
i+1, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestASNFetcherStopReturnsByItsDeadline starts a fetcher, has it look up an
|
|
// AS whose WHOIS server does not answer until the test ends, and stops it with
|
|
// a short deadline while that lookup is in progress. Stop must return all the
|
|
// same instead of waiting for the lookup.
|
|
func TestASNFetcherStopReturnsByItsDeadline(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const stopDeadline = 100 * time.Millisecond
|
|
|
|
lookupStarted := make(chan struct{})
|
|
release := make(chan struct{})
|
|
|
|
f := NewASNFetcher(newMockStore(), logger.New().Logger)
|
|
f.whoisClient.DialContext = func(
|
|
context.Context, string, string,
|
|
) (net.Conn, error) {
|
|
close(lookupStarted)
|
|
<-release
|
|
|
|
return nil, syscall.ECONNREFUSED
|
|
}
|
|
|
|
f.Start(t.Context())
|
|
f.QueueImmediate(testASNA)
|
|
|
|
select {
|
|
case <-lookupStarted:
|
|
case <-time.After(stepTimeout):
|
|
t.Fatal("the fetcher did not start the lookup")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), stopDeadline)
|
|
defer cancel()
|
|
|
|
if !returnsInTime(func() { f.Stop(ctx) }) {
|
|
t.Fatalf("Stop with a deadline of %s did not return while a lookup "+
|
|
"was in progress", stopDeadline)
|
|
}
|
|
|
|
close(release)
|
|
|
|
select {
|
|
case <-f.done:
|
|
case <-time.After(stepTimeout):
|
|
t.Fatal("the fetcher goroutine did not return after the lookup ended")
|
|
}
|
|
}
|
|
|
|
// TestRecordKeepsOnlyTheStatsWindow records a success and a failure every
|
|
// second, by a clock the test sets, for three times statsWindow, and never
|
|
// asks for the stats. The fetcher must keep only the times within the last
|
|
// statsWindow.
|
|
func TestRecordKeepsOnlyTheStatsWindow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
step = time.Second
|
|
want = int(statsWindow / step)
|
|
records = 3 * want
|
|
)
|
|
|
|
clock := time.Now()
|
|
|
|
f := NewASNFetcher(newMockStore(), logger.New().Logger)
|
|
f.now = func() time.Time { return clock }
|
|
|
|
for range records {
|
|
clock = clock.Add(step)
|
|
|
|
f.recordSuccess()
|
|
f.recordFailure()
|
|
}
|
|
|
|
if len(f.successTimes) != want || len(f.errorTimes) != want {
|
|
t.Errorf("%d success and %d error times kept, want %d of each, "+
|
|
"those of the last %s", len(f.successTimes), len(f.errorTimes),
|
|
want, statsWindow)
|
|
}
|
|
}
|