1 Commits
Author SHA1 Message Date
sneak 080b641ce6 Back off WHOIS lookups more slowly and stop asking for personal data (closes #159)
check / check (push) Waiting to run
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 IP address, and refuses an address for good that passes it
too often. RIPE, APNIC and AFRINIC are now asked with --no-personal,
which leaves those objects out but keeps the organisation object.

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:26:29 +00:00
3 changed files with 37 additions and 62 deletions
+1 -2
View File
@@ -36,8 +36,7 @@ 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. APNIC and AFRINIC are asked for the AS's
organisation on its own. A refused query is logged with the RIR's error line
a daily limit per address. 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
+23 -44
View File
@@ -49,29 +49,26 @@ 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:
// 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:
// 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"
)
//
// 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"
// 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
// 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:
// https://docs.db.ripe.net/Appendices/
// Appendix-C--RIPE-Database-Query-Server-Response-Codes-and-Messages/
const (
accessDeniedError = "%ERROR:201:"
accessLimitError = "%ERROR:202:"
@@ -164,22 +161,6 @@ 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)
}
@@ -199,17 +180,15 @@ 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 {
// 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 {
switch rir {
case RIRRIPE:
return noPersonalFlag + " " + key
case RIRAPNIC, RIRAFRNIC:
return noReferencedFlag + " " + key
case RIRRIPE, RIRAPNIC, RIRAFRNIC:
return noPersonalFlag + " " + asQuery
default:
return key
return asQuery
}
}
+13 -16
View File
@@ -101,24 +101,22 @@ const ripeRefusal = `% This is the RIPE Database query service.
% denied because of a repeated excessive querying.
`
// 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'
// 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'
aut-num: AS3333
as-name: EXAMPLE-AS
org: ORG-EXAMPLE1
admin-c: EX1-TEST
last-modified: 2026-08-26T00:40:00Z
`
organisationAnswer = `organisation: ORG-EXAMPLE1
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.
@@ -239,11 +237,11 @@ func TestLookupASNReadsTheRIRAnswer(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) {
// 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) {
t.Parallel()
for _, server := range []string{"whois.apnic.net", "whois.afrinic.net"} {
@@ -253,9 +251,8 @@ func TestLookupASNAsksForTheOrganisationOnItsOwn(t *testing.T) {
address := server + ":43"
info, err := lookUp(t, map[string]string{
ianaQuery: "refer: " + server + "\n",
address + " -r " + asQuery: answerWithoutOrganisation,
address + " -r ORG-EXAMPLE1": organisationAnswer,
ianaQuery: "refer: " + server + "\n",
address + " --no-personal " + asQuery: apnicAfrinicRecord,
})
if err != nil {
t.Fatalf("LookupASN: %v", err)