Retry a WHOIS lookup that failed instead of storing it as complete (closes #105)
check / check (push) Canceled after 0s

A lookup whose RIR query failed, or whose RIR answer carried none of the
WHOIS fields (a refusal of service, for one), returned IANA's answer as
a success. The fetcher stored it as complete with blank fields, did not
look the AS up again for 30 days, and shortened its interval. Such a
lookup is now an error: the fetcher backs off and leaves the row as it
was. The fields are read from the RIR's answer alone. An AS IANA refers
to no RIR is stored with no WHOIS data, so it is not looked up again
until it is stale, counting as neither a success nor a failure. Tests
reach fake WHOIS servers through the client's DialContext.

Model: opus-5-5
This commit was merged in pull request #127.
This commit is contained in:
2026-10-20 00:02:34 +02:00
parent a173f4d159
commit abdc327c4c
4 changed files with 416 additions and 52 deletions
+53 -51
View File
@@ -4,6 +4,7 @@ package whois
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"regexp"
@@ -43,6 +44,17 @@ const (
RIRAFRNIC = "AFRINIC"
)
// Errors LookupASN returns when a WHOIS answer has nothing to store.
var (
// ErrNoReferral is returned when IANA refers the AS to no RIR, as it
// does for a reserved, private-use or unallocated AS number.
ErrNoReferral = errors.New("IANA refers the AS to no RIR")
errNoASBlock = errors.New("IANA answered with neither a referral " +
"nor an AS block")
errNoFields = errors.New("RIR answered with no WHOIS fields")
)
// ASNInfo contains parsed WHOIS information for an ASN.
type ASNInfo struct {
ASN int
@@ -63,20 +75,24 @@ type ASNInfo struct {
// Client performs WHOIS lookups for ASNs.
type Client struct {
// Dialer for creating connections (can be overridden for testing)
dialer *net.Dialer
// DialContext opens the connection to a WHOIS server, given as
// host:port. NewClient sets a net.Dialer's; tests replace it to reach
// local servers instead.
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
}
// NewClient creates a new WHOIS client.
func NewClient() *Client {
return &Client{
dialer: &net.Dialer{
Timeout: dialTimeout,
},
}
dialer := &net.Dialer{Timeout: dialTimeout}
return &Client{DialContext: dialer.DialContext}
}
// LookupASN queries WHOIS for the given ASN and returns parsed information.
// LookupASN asks IANA which RIR holds the given ASN, asks that RIR, and
// returns what the RIR's answer says. It returns ErrNoReferral when IANA
// refers the AS to no RIR, and another error when a query fails, when IANA's
// answer has neither a referral nor the AS's block, or when the RIR's answer
// carries none of ASNInfo's fields, as a refusal of service does.
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)
@@ -89,32 +105,46 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// Determine RIR from IANA response
rir, whoisServer := c.parseIANAReferral(ianaResp)
if whoisServer == "" {
// No referral, try to parse what we have
return c.parseResponse(asn, rir, ianaResp), nil
// IANA names the block of an AS it refers to no RIR. An answer
// without that, such as an empty one, says nothing about the AS.
if !strings.Contains(ianaResp, "as-block:") {
return nil, fmt.Errorf("%s: %w", query, errNoASBlock)
}
return nil, fmt.Errorf("%s: %w", query, ErrNoReferral)
}
// Query the authoritative RIR
rirResp, err := c.query(ctx, whoisServer, query)
if err != nil {
// Return partial data from IANA if RIR query fails
info := c.parseResponse(asn, rir, ianaResp)
info.RawResponse = fmt.Sprintf("%s\n--- RIR query failed: %v ---\n",
ianaResp, err)
return info, nil
return nil, fmt.Errorf("RIR query failed: %w", err)
}
// Combine responses and parse
fullResponse := ianaResp + "\n" + rirResp
info := c.parseResponse(asn, rir, fullResponse)
info.RawResponse = fullResponse
// The fields come from the RIR's answer alone: IANA's describes the
// block the AS is in.
info := c.parseResponse(asn, rir, rirResp)
if info.isEmpty() {
return nil, fmt.Errorf("%s from %s: %w", query, whoisServer, errNoFields)
}
info.RawResponse = ianaResp + "\n" + rirResp
return info, nil
}
// isEmpty reports whether info holds none of the fields a WHOIS answer
// fills, as when the answer is only comment and error lines.
func (info *ASNInfo) isEmpty() bool {
return info.ASName == "" && info.OrgName == "" && info.OrgID == "" &&
info.Address == "" && info.CountryCode == "" &&
info.AbuseEmail == "" && info.AbusePhone == "" &&
info.TechEmail == "" && info.TechPhone == "" &&
info.RegDate == nil && info.LastMod == nil
}
// query performs a raw WHOIS query to the specified server.
func (c *Client) query(ctx context.Context, server, query string) (string, error) {
conn, err := c.dialer.DialContext(ctx, "tcp", server)
conn, err := c.DialContext(ctx, "tcp", server)
if err != nil {
return "", fmt.Errorf("dial %s: %w", server, err)
}
@@ -155,9 +185,8 @@ func (c *Client) query(ctx context.Context, server, query string) (string, error
}
// parseIANAReferral extracts the RIR and WHOIS server from an IANA response.
// The server is "" when the response has no referral.
func (c *Client) parseIANAReferral(response string) (string, string) {
var rir string
for line := range strings.SplitSeq(response, "\n") {
line = strings.TrimSpace(line)
@@ -186,36 +215,9 @@ func (c *Client) parseIANAReferral(response string) (string, string) {
return "", server
}
}
// Also check organisation line for RIR hints
if strings.HasPrefix(strings.ToLower(line), "organisation:") {
orgRIR := rirInOrganisationLine(strings.ToLower(line))
if orgRIR != "" {
rir = orgRIR
}
}
}
return rir, ""
}
// rirInOrganisationLine returns the RIR a lowercased IANA "organisation:"
// line names, or "" when it names none.
func rirInOrganisationLine(org string) string {
switch {
case strings.Contains(org, "arin"):
return RIRARIN
case strings.Contains(org, "ripe"):
return RIRRIPE
case strings.Contains(org, "apnic"):
return RIRAPNIC
case strings.Contains(org, "lacnic"):
return RIRLACNIC
case strings.Contains(org, "afrinic"):
return RIRAFRNIC
default:
return ""
}
return "", ""
}
// parseResponse extracts ASN information from a WHOIS response.