Compare commits

..
1 Commits
Author SHA1 Message Date
sneak b0b02811ce Pause a registry's WHOIS server after it refuses a query (closes #218)
check / check (push) Canceled after 0s
The fetcher kept one interval for every registry, so a registry that
refused a query was asked again within seconds. RIPE blocked the test
host's address that way.

The WHOIS client now pauses an RIR's or NIC.br's server for an hour
after the server refuses a query. A refused or reset connection is a
refusal, and so is an %ERROR:201: or %ERROR:202: line. Lookups at the
other servers go on. A lookup whose RIR is paused leaves the AS stale,
and counts as neither a success nor a failure. IANA is never paused,
because a pause of IANA would stop every lookup. NIC.br's hour-long
skip after its over-limit answer is now the same pause.

Model: opus-5-5
2026-11-02 01:06:26 +00:00
5 changed files with 105 additions and 55 deletions

No files matched your search

+8 -8
View File
@@ -35,14 +35,14 @@ tested.
# Completed Steps # Completed Steps
- 2026-11-02: after a WHOIS server refuses a query, RouteWatch asks it nothing - 2026-11-02: RouteWatch pauses an RIR's or NIC.br's WHOIS server for an hour
for an hour. A refusal is a refused or reset connection, or an `%ERROR:201:` after the server refuses a query. A refused or reset connection is a refusal.
or `%ERROR:202:` line, with which RIPE, APNIC and AFRINIC refuse a query. So is an answer with an `%ERROR:201:` or `%ERROR:202:` line. Lookups at the
RouteWatch still asks the other servers. An AS whose server is paused is other servers go on. An AS whose RIR is paused is looked up after the pause.
looked up after the pause. Every lookup asks IANA first, so a pause of IANA IANA is never paused, because a pause of IANA would stop every lookup. A
stops them all. The fetcher used to ask a server that had refused it again refusal by IANA is an ordinary failure. NIC.br is also paused for an hour
within seconds. NIC.br's answer that RouteWatch is over its limit starts the after it says RouteWatch has passed NIC.br's query limit. Before, the fetcher
same pause (closes #218) asked a server again within seconds of a refusal (closes #218)
- 2026-10-27: the tests of `internal/routewatch` no longer fail on a loaded - 2026-10-27: the tests of `internal/routewatch` no longer fail on a loaded
host. Five of them gave `HandleMessage` two seconds to apply a full prefix host. Five of them gave `HandleMessage` two seconds to apply a full prefix
batch, which under the race detector can take longer. A test now waits up to batch, which under the race detector can take longer. A test now waits up to
+7 -6
View File
@@ -288,11 +288,12 @@ func (f *ASNFetcher) fetchNextStale(ctx context.Context) {
// WHOIS data, so it is not looked up again until it is stale; that counts // WHOIS data, so it is not looked up again until it is stale; that counts
// as neither a success nor a failure. // as neither a success nor a failure.
// //
// A WHOIS server that refuses a query is paused for whois.PauseTime, and // The WHOIS client pauses an RIR's or NIC.br's server for whois.PauseTime
// the other servers are still asked. A lookup that needs a paused server // after the server refuses a query. Lookups at the other servers go on. IANA
// sends it nothing. It also leaves the AS stale, and counts as neither a // is never paused, so a refusal by IANA is an ordinary failure. A lookup
// success nor a failure. This holds for the lookups that QueueImmediate // whose RIR is paused fails with whois.ErrPaused. Such a lookup leaves the
// asks for too. // AS stale, and counts as neither a success nor a failure. The lookups that
// QueueImmediate asks for also run through fetchAndUpdate.
func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool { func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool {
ctx, cancel := context.WithTimeout(ctx, time.Minute) ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel() defer cancel()
@@ -314,7 +315,7 @@ func (f *ASNFetcher) fetchAndUpdate(ctx context.Context, asn int) bool {
} }
if errors.Is(err, whois.ErrPaused) { if errors.Is(err, whois.ErrPaused) {
f.logger.Info("Skipped WHOIS lookup, a server it needs is paused", f.logger.Info("Skipped WHOIS lookup, the RIR's server is paused",
"asn", asn, "error", err) "asn", asn, "error", err)
return false return false
@@ -133,10 +133,10 @@ func TestFetchAndUpdateKeepsTheRowWhenTheLookupFails(t *testing.T) {
} }
// TestFetchAndUpdateWaitsOutAPause looks up an AS at RIPE while RIPE refuses // TestFetchAndUpdateWaitsOutAPause looks up an AS at RIPE while RIPE refuses
// the query, and again a second before whois.PauseTime has passed, by a clock // the query. The test sets the WHOIS client's clock. The next lookup comes a
// the test sets on the WHOIS client. The second lookup sends RIPE nothing, // second before whois.PauseTime has passed. It sends RIPE nothing and leaves
// leaves the AS never looked up, and counts as neither a success nor a // the AS never looked up. It counts as neither a success nor a failure. A
// failure. A lookup at whois.PauseTime asks RIPE again. // lookup at whois.PauseTime asks RIPE again.
func TestFetchAndUpdateWaitsOutAPause(t *testing.T) { func TestFetchAndUpdateWaitsOutAPause(t *testing.T) {
t.Parallel() t.Parallel()
@@ -196,6 +196,45 @@ func TestFetchAndUpdateWaitsOutAPause(t *testing.T) {
} }
} }
// 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 // TestRecordStepsTheInterval has the fetcher fail from its base interval of
// 15 seconds, then succeed. Each failure makes the interval 1.5 times as // 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. // long, up to one minute, and each success halves it, down to one second.
+33 -24
View File
@@ -71,10 +71,11 @@ const nicbrHeader = "% Copyright (c) Nic.br"
// queries to stop the data being taken in bulk. // queries to stop the data being taken in bulk.
const nicbrLimitReached = "Query rate limit exceeded" const nicbrLimitReached = "Query rate limit exceeded"
// PauseTime is how long the client asks a WHOIS server nothing after the // PauseTime is how long the client sends an RIR's or NIC.br's server no
// server refuses a query, as ask says. RIPE refuses an address for good // query after that server refuses a query. queryRegistry says what a
// that keeps passing its limit, as noPersonalFlag says. An hour outlasts a // refusal is. RIPE refuses an address for good when the address keeps
// limit counted per minute or per hour. // passing RIPE's limit (see noPersonalFlag). An hour outlasts a limit
// counted per minute or per hour.
const PauseTime = time.Hour const PauseTime = time.Hour
// RIR identifiers. // RIR identifiers.
@@ -163,8 +164,8 @@ type Client struct {
// mu guards pausedUntil. // mu guards pausedUntil.
mu sync.Mutex mu sync.Mutex
// pausedUntil holds, for each server that refused a query, given as // pausedUntil maps a server, given as host:port, to the time the client
// host:port, when the client next asks it. // may ask it again.
pausedUntil map[string]time.Time pausedUntil map[string]time.Time
} }
@@ -186,16 +187,20 @@ func NewClient(logger *slog.Logger) *Client {
// NIC.br is paused, as askNICbr says. // NIC.br is paused, as askNICbr says.
// //
// It returns ErrNoReferral when IANA refers the AS to no RIR. Its error // It returns ErrNoReferral when IANA refers the AS to no RIR. Its error
// wraps ErrPaused when IANA or the RIR is paused. It returns another error // wraps ErrPaused when the RIR is paused. It returns another error in each
// when the query to IANA or to the RIR fails, when the RIR refuses it, when // of these cases:
// IANA's answer has neither a referral nor the AS's block, when IANA refers //
// the AS to a server none of the five RIRs run, or when the answer carries // - the query to IANA or to the RIR fails
// none of ASNInfo's fields. // - the RIR refuses the query
// - IANA's answer has neither a referral nor the AS's block
// - IANA refers the AS to a server none of the five RIRs run
// - the answer carries none of ASNInfo's fields
func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) { func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// Query IANA first to find the authoritative RIR // Query IANA first to find the authoritative RIR
query := fmt.Sprintf("AS%d", asn) query := fmt.Sprintf("AS%d", asn)
ianaResp, err := c.ask(ctx, whoisServerIANA, query) // IANA is never paused, because a pause of IANA would stop every lookup.
ianaResp, err := c.query(ctx, whoisServerIANA, query)
if err != nil { if err != nil {
return nil, fmt.Errorf("IANA query failed: %w", err) return nil, fmt.Errorf("IANA query failed: %w", err)
} }
@@ -217,7 +222,7 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
} }
// Query the authoritative RIR // Query the authoritative RIR
rirResp, err := c.ask(ctx, whoisServer, rirQuery(rir, query)) rirResp, err := c.queryRegistry(ctx, whoisServer, rirQuery(rir, query))
if err != nil { if err != nil {
return nil, fmt.Errorf("RIR query failed: %w", err) return nil, fmt.Errorf("RIR query failed: %w", err)
} }
@@ -255,7 +260,7 @@ func (c *Client) askNICbr(
) *ASNInfo { ) *ASNInfo {
asn := lacnicInfo.ASN asn := lacnicInfo.ASN
answer, err := c.ask(ctx, whoisServerNICbr, asQuery) answer, err := c.queryRegistry(ctx, whoisServerNICbr, asQuery)
if errors.Is(err, ErrPaused) { if errors.Is(err, ErrPaused) {
return lacnicInfo return lacnicInfo
} }
@@ -310,16 +315,19 @@ func rirQuery(rir, asQuery string) string {
} }
} }
// ask sends query to server and returns the answer. A server that refuses // queryRegistry sends query to the server of an RIR or of NIC.br, and
// the query is paused: ask then sends it nothing until PauseTime has // returns the answer. The client pauses the server for PauseTime when the
// passed, and returns an error that wraps ErrPaused instead. Each of these // server refuses the query. Each of these is a refusal:
// is a refusal, and an error:
// //
// - a refused connection // - a refused connection
// - a reset connection // - a reset connection
// - an answer with one of the error lines that RIPE's database software // - an %ERROR:201: or %ERROR:202: line in the answer
// refuses a query with. The error holds that line. //
func (c *Client) ask(ctx context.Context, server, query string) (string, error) { // A refusal is returned as an error. While the server is paused,
// queryRegistry sends it nothing and returns an error that wraps ErrPaused.
func (c *Client) queryRegistry(
ctx context.Context, server, query string,
) (string, error) {
c.mu.Lock() c.mu.Lock()
pausedUntil := c.pausedUntil[server] pausedUntil := c.pausedUntil[server]
c.mu.Unlock() c.mu.Unlock()
@@ -346,8 +354,9 @@ func (c *Client) ask(ctx context.Context, server, query string) (string, error)
return answer, nil return answer, nil
} }
// checkRefusal returns an error holding the first line of answer with which // checkRefusal returns an error holding the first line of answer that
// RIPE's database software refuses a query, or nil when there is none. // starts with accessDeniedError or accessLimitError. It returns nil when
// answer has no such line.
func checkRefusal(answer string) error { func checkRefusal(answer string) error {
for line := range strings.SplitSeq(answer, "\n") { for line := range strings.SplitSeq(answer, "\n") {
if strings.HasPrefix(line, accessDeniedError) || if strings.HasPrefix(line, accessDeniedError) ||
@@ -359,7 +368,7 @@ func checkRefusal(answer string) error {
return nil return nil
} }
// pause makes the client ask server nothing until PauseTime has passed. // pause stops queryRegistry from querying server until PauseTime has passed.
func (c *Client) pause(server string) { func (c *Client) pause(server string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
+14 -13
View File
@@ -718,9 +718,10 @@ func TestLookupASNKeepsLACNICsAnswerWhenNICbrFails(t *testing.T) {
// TestLookupASNSkipsNICbrAfterItsLimit looks up an AS that NIC.br // TestLookupASNSkipsNICbrAfterItsLimit looks up an AS that NIC.br
// registers three times with one client, while NIC.br answers that the // registers three times with one client, while NIC.br answers that the
// client is over its query limit. The first lookup asks NIC.br. The // client is over its query limit. The test sets the client's clock. The
// second, a second before PauseTime has passed, does not. The third, once // first lookup asks NIC.br. The next comes a second before PauseTime has
// PauseTime has passed, asks again. Each keeps LACNIC's fields. // passed, and does not ask NIC.br. The last comes at PauseTime and asks
// NIC.br again. Each keeps LACNIC's fields.
func TestLookupASNSkipsNICbrAfterItsLimit(t *testing.T) { func TestLookupASNSkipsNICbrAfterItsLimit(t *testing.T) {
t.Parallel() t.Parallel()
@@ -785,9 +786,9 @@ func TestLookupASNReportsARefusal(t *testing.T) {
} }
} }
// resetServer starts a TCP server on the loopback address that reads one // resetServer starts a TCP server on the loopback address and returns the
// query on each connection and then resets the connection. It returns the // server's address. The server reads one query on each connection, then
// server's address. // resets the connection.
func resetServer(t *testing.T) string { func resetServer(t *testing.T) string {
t.Helper() t.Helper()
@@ -821,9 +822,9 @@ func resetServer(t *testing.T) string {
return listener.Addr().String() return listener.Addr().String()
} }
// newRIPEClient returns a client whose fake servers send answers. It adds // newRIPEClient returns a client whose fake servers send answers. The
// one to *ripeAsks each time it connects to RIPE's server. When reset is // client adds one to *ripeAsks each time it connects to RIPE's server. When
// true, RIPE's server resets the connection instead of answering. // reset is true, RIPE's server resets the connection instead of answering.
func newRIPEClient( func newRIPEClient(
t *testing.T, answers map[string]string, reset bool, ripeAsks *int, t *testing.T, answers map[string]string, reset bool, ripeAsks *int,
) *whois.Client { ) *whois.Client {
@@ -853,10 +854,10 @@ func newRIPEClient(
} }
// TestLookupASNPausesAServerThatRefuses looks up an AS at RIPE while RIPE // TestLookupASNPausesAServerThatRefuses looks up an AS at RIPE while RIPE
// refuses the query, in each of the ways a server refuses. A lookup of the // refuses the query, in each of the ways a server refuses. The test sets
// AS a second before PauseTime has passed, by a clock the test sets, fails // the client's clock. A lookup a second before PauseTime has passed fails
// with ErrPaused and asks RIPE nothing. A lookup of an AS at APNIC still // with ErrPaused and asks RIPE nothing. A lookup of an AS at APNIC succeeds
// succeeds then. A lookup of the AS at PauseTime asks RIPE again. // during the pause. A lookup at PauseTime asks RIPE again.
func TestLookupASNPausesAServerThatRefuses(t *testing.T) { func TestLookupASNPausesAServerThatRefuses(t *testing.T) {
t.Parallel() t.Parallel()