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) 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 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 - 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, 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 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 a daily limit per address. A refused query is logged with the RIR's error line
organisation on its own. A refused query is logged with the RIR's error line
(closes #159) (closes #159)
- 2026-10-26: the home page's AS name search lists the ASes whose handle or - 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 description holds the text, as links to their pages, from the new
+23 -44
View File
@@ -49,29 +49,26 @@ const (
RIRAFRNIC = "AFRINIC" RIRAFRNIC = "AFRINIC"
) )
// Query flags that keep person and role objects out of an answer. RIPE // noPersonalFlag keeps person and role objects out of an answer. The
// counts each one it returns against a daily limit per address, and refuses // organisation object stays in it. RIPE counts the person and role objects
// an address for good that passes the limit too often: // 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/ // https://docs.db.ripe.net/Types-of-Queries/Access-Control-for-Queries/
// APNIC and AFRINIC run the same database software. //
const ( // RIPE documents the flag here:
// noPersonalFlag leaves out person and role objects only, so the answer // https://docs.db.ripe.net/Types-of-Queries/Referenced-Objects-in-Query-Response/
// keeps the organisation object: // APNIC and AFRINIC list it in their answers to the query "help". AFRINIC
// https://docs.db.ripe.net/Types-of-Queries/Referenced-Objects-in-Query-Response/ // sends readers to that answer for its full list of flags:
noPersonalFlag = "--no-personal" // https://afrinic.net/whois-getting-started
const 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: // 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 // access denied to the client's IP address, and that address's limit
// listed under "Access Errors" in Appendix C of // 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/ // https://docs.db.ripe.net/Appendices/
// Appendix-C--RIPE-Database-Query-Server-Response-Codes-and-Messages/
const ( const (
accessDeniedError = "%ERROR:201:" accessDeniedError = "%ERROR:201:"
accessLimitError = "%ERROR:202:" 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 // The fields come from the RIR's answer alone: IANA's describes the
// block the AS is in. // block the AS is in.
info := c.parseResponse(asn, rir, rirResp) 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() { if info.isEmpty() {
return nil, fmt.Errorf("%s from %s: %w", query, whoisServer, errNoFields) 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 info.RegDate == nil && info.LastMod == nil
} }
// rirQuery returns the query sent to rir's server for key, an AS number // rirQuery returns the query sent to rir's server for asQuery, an AS
// such as "AS3333" or an organisation ID. RIPE, APNIC and AFRINIC get a // number such as "AS3333". RIPE, APNIC and AFRINIC are asked to keep person
// flag that keeps person and role objects out of the answer. // and role objects out of the answer.
func rirQuery(rir, key string) string { func rirQuery(rir, asQuery string) string {
switch rir { switch rir {
case RIRRIPE: case RIRRIPE, RIRAPNIC, RIRAFRNIC:
return noPersonalFlag + " " + key return noPersonalFlag + " " + asQuery
case RIRAPNIC, RIRAFRNIC:
return noReferencedFlag + " " + key
default: 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. % denied because of a repeated excessive querying.
` `
// The answers of APNIC and AFRINIC to an "-r" query, which leaves out every // apnicAfrinicRecord is the answer of APNIC or AFRINIC for the AS when asked
// object the AS's object refers to: first for testASN, then for the // to leave out person and role objects. Like RIPE's, it holds the abuse
// organisation that AS's object names. // address as a comment, the AS's object and the organisation object.
const ( const apnicAfrinicRecord = `% Abuse contact for 'AS3333' is 'abuse@example.net'
answerWithoutOrganisation = `% Abuse contact for 'AS3333' is 'abuse@example.net'
aut-num: AS3333 aut-num: AS3333
as-name: EXAMPLE-AS as-name: EXAMPLE-AS
org: ORG-EXAMPLE1 org: ORG-EXAMPLE1
admin-c: EX1-TEST admin-c: EX1-TEST
last-modified: 2026-08-26T00:40:00Z last-modified: 2026-08-26T00:40:00Z
`
organisationAnswer = `organisation: ORG-EXAMPLE1 organisation: ORG-EXAMPLE1
org-name: Example Networks org-name: Example Networks
country: ZA country: ZA
address: 1 Example Road address: 1 Example Road
` `
)
// ianaPrivateUse is IANA's answer for a private-use AS: its block and no // ianaPrivateUse is IANA's answer for a private-use AS: its block and no
// referral. // referral.
@@ -239,11 +237,11 @@ func TestLookupASNReadsTheRIRAnswer(t *testing.T) {
} }
} }
// TestLookupASNAsksForTheOrganisationOnItsOwn looks up an AS at APNIC and // TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData looks up an AS at
// at AFRINIC. Each answers only "-r" queries, whose answer for the AS // APNIC and at AFRINIC. Each answers only a query that leaves out person
// leaves out its organisation object, so the client asks for that object // and role objects, as RIPE does. That one answer still holds the
// too. The organisation's name and country come from that second answer. // organisation object, so the organisation's name and country are filled.
func TestLookupASNAsksForTheOrganisationOnItsOwn(t *testing.T) { func TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData(t *testing.T) {
t.Parallel() t.Parallel()
for _, server := range []string{"whois.apnic.net", "whois.afrinic.net"} { for _, server := range []string{"whois.apnic.net", "whois.afrinic.net"} {
@@ -253,9 +251,8 @@ func TestLookupASNAsksForTheOrganisationOnItsOwn(t *testing.T) {
address := server + ":43" address := server + ":43"
info, err := lookUp(t, map[string]string{ info, err := lookUp(t, map[string]string{
ianaQuery: "refer: " + server + "\n", ianaQuery: "refer: " + server + "\n",
address + " -r " + asQuery: answerWithoutOrganisation, address + " --no-personal " + asQuery: apnicAfrinicRecord,
address + " -r ORG-EXAMPLE1": organisationAnswer,
}) })
if err != nil { if err != nil {
t.Fatalf("LookupASN: %v", err) t.Fatalf("LookupASN: %v", err)