check / check (push) Canceled after 0s
A failed lookup now makes the fetcher's wait 1.5 times as long, up to one minute instead of five. A success still halves it. RIPE counts the person and role objects an answer holds against a daily limit per IP address, and refuses an address for good that passes it too often. RIPE, APNIC and AFRINIC are now asked with --no-personal, which leaves those objects out but keeps the organisation object. An answer with RIPE's access-denied or limit-reached error line is now reported as a refusal holding that line, not as an answer with no fields. Model: opus-5-5
279 lines
7.3 KiB
Go
279 lines
7.3 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"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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(handleMessageTimeout):
|
|
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(handleMessageTimeout):
|
|
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, far longer than 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 (
|
|
records = 100_000
|
|
step = time.Second
|
|
want = int(statsWindow / step)
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|