check / check (push) Canceled after 0s
Shutdown passes its context to every stage: the streamer, the four handlers, the WHOIS fetcher, the database maintainer and the HTTP server each stop waiting when it ends. A handler then skips its last write and logs how many updates it did not write, and the HTTP server closes the connections still open. The database is checkpointed and closed last, and left open once the deadline has passed, since a write may still hold it. The stop hook gives Shutdown 8 seconds and fx 9, inside the 10 seconds upaas and docker stop allow, so a stop signal exits 0. Model: opus-5-5
206 lines
5.5 KiB
Go
206 lines
5.5 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|