Back off WHOIS lookups more slowly and stop asking for personal data (closes #159)
check / check (push) Canceled after 0s

A failed lookup now makes the fetcher's wait 1.5 times as long, up to
one minute instead of five. A success still halves it.

RIPE counts the person and role objects an answer holds against a daily
limit per address, and refuses an address for good that passes it too
often. RIPE is now asked with --no-personal, which keeps the
organisation object. APNIC and AFRINIC document only -r, which also
leaves out the organisation object, so the client asks for that object
on its own.

An answer with RIPE's access-denied or limit-reached error line is now
reported as a refusal holding that line, not as an answer with no
fields.

Model: opus-5-5
This commit is contained in:
2026-10-26 22:09:37 +00:00
parent 99c7efc156
commit 56549d2a0c
5 changed files with 325 additions and 42 deletions
+85 -5
View File
@@ -49,6 +49,34 @@ const (
RIRAFRNIC = "AFRINIC"
)
// Query flags that keep person and role objects out of an answer. RIPE
// counts each one it returns against a daily limit per address, and refuses
// an address for good that passes the limit too often:
// https://docs.db.ripe.net/Types-of-Queries/Access-Control-for-Queries/
// APNIC and AFRINIC run the same database software.
const (
// noPersonalFlag leaves out person and role objects only, so the answer
// keeps the organisation object:
// https://docs.db.ripe.net/Types-of-Queries/Referenced-Objects-in-Query-Response/
noPersonalFlag = "--no-personal"
// noReferencedFlag leaves out every object the answer's objects refer
// to, the organisation object too. It is the only such flag APNIC and
// AFRINIC document:
// https://www.apnic.net/db/search/web-help.html
// https://afrinic.net/whois-getting-started
noReferencedFlag = "-r"
)
// The error lines with which RIPE's database software refuses a query:
// access denied to the address, and the address's limit reached. They are
// listed under "Access Errors" in Appendix C of
// https://docs.db.ripe.net/Appendices/
const (
accessDeniedError = "%ERROR:201:"
accessLimitError = "%ERROR:202:"
)
// 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
@@ -58,6 +86,7 @@ var (
errNoASBlock = errors.New("IANA answered with neither a referral " +
"nor an AS block")
errNoFields = errors.New("RIR answered with no WHOIS fields")
errRefused = errors.New("RIR refused the query")
errUnknownServer = errors.New("IANA refers the AS to a server no RIR runs")
errAnswerTooLarge = errors.New("WHOIS answer reached the size cap")
)
@@ -97,10 +126,10 @@ func NewClient() *Client {
// 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, when IANA refers the AS
// to a server none of the five RIRs run, or when the RIR's answer carries
// none of ASNInfo's fields, as a refusal of service does.
// refers the AS to no RIR, and another error when a query 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 RIR's 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)
@@ -127,7 +156,7 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
}
// Query the authoritative RIR
rirResp, err := c.query(ctx, whoisServer, query)
rirResp, err := c.queryRIR(ctx, whoisServer, rirQuery(rir, query))
if err != nil {
return nil, fmt.Errorf("RIR query failed: %w", err)
}
@@ -135,6 +164,22 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// The fields come from the RIR's answer alone: IANA's describes the
// block the AS is in.
info := c.parseResponse(asn, rir, rirResp)
// The answers of APNIC and AFRINIC leave out the AS's organisation
// object, which holds the organisation's name, country and address. It
// is asked for on its own and read after the AS's object, whose fields
// come first.
if (rir == RIRAPNIC || rir == RIRAFRNIC) && info.OrgID != "" {
orgResp, orgErr := c.queryRIR(ctx, whoisServer,
rirQuery(rir, info.OrgID))
if orgErr != nil {
return nil, fmt.Errorf("organisation query failed: %w", orgErr)
}
rirResp += "\n" + orgResp
info = c.parseResponse(asn, rir, rirResp)
}
if info.isEmpty() {
return nil, fmt.Errorf("%s from %s: %w", query, whoisServer, errNoFields)
}
@@ -154,6 +199,41 @@ func (info *ASNInfo) isEmpty() bool {
info.RegDate == nil && info.LastMod == nil
}
// rirQuery returns the query sent to rir's server for key, an AS number
// such as "AS3333" or an organisation ID. RIPE, APNIC and AFRINIC get a
// flag that keeps person and role objects out of the answer.
func rirQuery(rir, key string) string {
switch rir {
case RIRRIPE:
return noPersonalFlag + " " + key
case RIRAPNIC, RIRAFRNIC:
return noReferencedFlag + " " + key
default:
return key
}
}
// queryRIR sends query to an RIR's server and returns the answer. An answer
// holding an error line with which the server refuses the query is an
// error, and the error holds that line.
func (c *Client) queryRIR(
ctx context.Context, server, query string,
) (string, error) {
answer, err := c.query(ctx, server, query)
if err != nil {
return "", err
}
for line := range strings.SplitSeq(answer, "\n") {
if strings.HasPrefix(line, accessDeniedError) ||
strings.HasPrefix(line, accessLimitError) {
return "", fmt.Errorf("%w: %s", errRefused, line)
}
}
return answer, nil
}
// query performs a raw WHOIS query to the specified server. An answer that
// reaches maxAnswerSize is an error.
func (c *Client) query(ctx context.Context, server, query string) (string, error) {