1 Commits
Author SHA1 Message Date
sneak 56549d2a0c 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
2026-10-26 22:09:37 +00:00
3 changed files with 62 additions and 37 deletions
+2 -1
View File
@@ -36,7 +36,8 @@ tested.
- 2026-10-26: the WHOIS fetcher backs off more slowly. A failure makes its wait
1.5 times as long, up to one minute instead of five. Its queries to RIPE,
APNIC and AFRINIC leave out person and role objects, which RIPE counts against
a daily limit per address. A refused query is logged with the RIR's error line
a daily limit per address. APNIC and AFRINIC are asked for the AS's
organisation on its own. A refused query is logged with the RIR's error line
(closes #159)
- 2026-10-26: the home page's AS name search lists the ASes whose handle or
description holds the text, as links to their pages, from the new
+44 -23
View File
@@ -49,26 +49,29 @@ const (
RIRAFRNIC = "AFRINIC"
)
// noPersonalFlag keeps person and role objects out of an answer. The
// organisation object stays in it. RIPE counts the person and role objects
// it returns against a daily limit per IP address. Role objects for abuse
// contacts do not count. RIPE refuses an address for good that passes the
// limit too often:
// 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/
//
// RIPE documents the flag here:
// https://docs.db.ripe.net/Types-of-Queries/Referenced-Objects-in-Query-Response/
// APNIC and AFRINIC list it in their answers to the query "help". AFRINIC
// sends readers to that answer for its full list of flags:
// https://afrinic.net/whois-getting-started
const noPersonalFlag = "--no-personal"
// 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 client's IP address, and that address's limit
// reached. APNIC and AFRINIC run the same software. The lines are listed
// under "Access Errors" on this page, whose URL is split over two lines:
// 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/
// Appendix-C--RIPE-Database-Query-Server-Response-Codes-and-Messages/
const (
accessDeniedError = "%ERROR:201:"
accessLimitError = "%ERROR:202:"
@@ -161,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)
}
@@ -180,15 +199,17 @@ func (info *ASNInfo) isEmpty() bool {
info.RegDate == nil && info.LastMod == nil
}
// rirQuery returns the query sent to rir's server for asQuery, an AS
// number such as "AS3333". RIPE, APNIC and AFRINIC are asked to keep person
// and role objects out of the answer.
func rirQuery(rir, asQuery string) string {
// 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, RIRAPNIC, RIRAFRNIC:
return noPersonalFlag + " " + asQuery
case RIRRIPE:
return noPersonalFlag + " " + key
case RIRAPNIC, RIRAFRNIC:
return noReferencedFlag + " " + key
default:
return asQuery
return key
}
}
+16 -13
View File
@@ -101,22 +101,24 @@ const ripeRefusal = `% This is the RIPE Database query service.
% denied because of a repeated excessive querying.
`
// apnicAfrinicRecord is the answer of APNIC or AFRINIC for the AS when asked
// to leave out person and role objects. Like RIPE's, it holds the abuse
// address as a comment, the AS's object and the organisation object.
const apnicAfrinicRecord = `% Abuse contact for 'AS3333' is 'abuse@example.net'
// The answers of APNIC and AFRINIC to an "-r" query, which leaves out every
// object the AS's object refers to: first for testASN, then for the
// organisation that AS's object names.
const (
answerWithoutOrganisation = `% Abuse contact for 'AS3333' is 'abuse@example.net'
aut-num: AS3333
as-name: EXAMPLE-AS
org: ORG-EXAMPLE1
admin-c: EX1-TEST
last-modified: 2026-08-26T00:40:00Z
organisation: ORG-EXAMPLE1
`
organisationAnswer = `organisation: ORG-EXAMPLE1
org-name: Example Networks
country: ZA
address: 1 Example Road
`
)
// ianaPrivateUse is IANA's answer for a private-use AS: its block and no
// referral.
@@ -237,11 +239,11 @@ func TestLookupASNReadsTheRIRAnswer(t *testing.T) {
}
}
// TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData looks up an AS at
// APNIC and at AFRINIC. Each answers only a query that leaves out person
// and role objects, as RIPE does. That one answer still holds the
// organisation object, so the organisation's name and country are filled.
func TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData(t *testing.T) {
// TestLookupASNAsksForTheOrganisationOnItsOwn looks up an AS at APNIC and
// at AFRINIC. Each answers only "-r" queries, whose answer for the AS
// leaves out its organisation object, so the client asks for that object
// too. The organisation's name and country come from that second answer.
func TestLookupASNAsksForTheOrganisationOnItsOwn(t *testing.T) {
t.Parallel()
for _, server := range []string{"whois.apnic.net", "whois.afrinic.net"} {
@@ -251,8 +253,9 @@ func TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData(t *testing.T) {
address := server + ":43"
info, err := lookUp(t, map[string]string{
ianaQuery: "refer: " + server + "\n",
address + " --no-personal " + asQuery: apnicAfrinicRecord,
ianaQuery: "refer: " + server + "\n",
address + " -r " + asQuery: answerWithoutOrganisation,
address + " -r ORG-EXAMPLE1": organisationAnswer,
})
if err != nil {
t.Fatalf("LookupASN: %v", err)