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