Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 37876c89fa Pause a WHOIS server for an hour 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 asks a server nothing for an hour after the server
refuses a query. A refusal is a refused or reset connection, or an
%ERROR:201: or %ERROR:202: line. The other servers are still asked. A
lookup that needs a paused server leaves the AS stale, and counts as
neither a success nor a failure. NIC.br's hour-long skip after its
over-limit answer is now the same pause.

Model: opus-5-5
2026-11-02 00:39:14 +00:00
5 changed files with 55 additions and 105 deletions

No files matched your search

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