Compare commits

..
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

No files matched your search

+2 -1
View File
@@ -36,7 +36,8 @@ 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. 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) (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
+44 -23
View File
@@ -49,26 +49,29 @@ const (
RIRAFRNIC = "AFRINIC" RIRAFRNIC = "AFRINIC"
) )
// noPersonalFlag keeps person and role objects out of an answer. The // Query flags that keep person and role objects out of an answer. RIPE
// organisation object stays in it. RIPE counts the person and role objects // counts each one it returns against a daily limit per address, and refuses
// it returns against a daily limit per IP address. Role objects for abuse // an address for good that passes the limit too often:
// 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.
// RIPE documents the flag here: const (
// https://docs.db.ripe.net/Types-of-Queries/Referenced-Objects-in-Query-Response/ // noPersonalFlag leaves out person and role objects only, so the answer
// APNIC and AFRINIC list it in their answers to the query "help". AFRINIC // keeps the organisation object:
// sends readers to that answer for its full list of flags: // https://docs.db.ripe.net/Types-of-Queries/Referenced-Objects-in-Query-Response/
// https://afrinic.net/whois-getting-started noPersonalFlag = "--no-personal"
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 client's IP address, and that address's limit // access denied to the address, and the address's limit reached. They are
// reached. APNIC and AFRINIC run the same software. The lines are listed // listed under "Access Errors" in Appendix C of
// 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:"
@@ -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 // 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)
} }
@@ -180,15 +199,17 @@ 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 asQuery, an AS // rirQuery returns the query sent to rir's server for key, an AS number
// number such as "AS3333". RIPE, APNIC and AFRINIC are asked to keep person // such as "AS3333" or an organisation ID. RIPE, APNIC and AFRINIC get a
// and role objects out of the answer. // flag that keeps person and role objects out of the answer.
func rirQuery(rir, asQuery string) string { func rirQuery(rir, key string) string {
switch rir { switch rir {
case RIRRIPE, RIRAPNIC, RIRAFRNIC: case RIRRIPE:
return noPersonalFlag + " " + asQuery return noPersonalFlag + " " + key
case RIRAPNIC, RIRAFRNIC:
return noReferencedFlag + " " + key
default: 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. % denied because of a repeated excessive querying.
` `
// apnicAfrinicRecord is the answer of APNIC or AFRINIC for the AS when asked // The answers of APNIC and AFRINIC to an "-r" query, which leaves out every
// to leave out person and role objects. Like RIPE's, it holds the abuse // object the AS's object refers to: first for testASN, then for the
// address as a comment, the AS's object and the organisation object. // organisation that AS's object names.
const apnicAfrinicRecord = `% Abuse contact for 'AS3333' is 'abuse@example.net' const (
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
`
organisation: ORG-EXAMPLE1 organisationAnswer = `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.
@@ -237,11 +239,11 @@ func TestLookupASNReadsTheRIRAnswer(t *testing.T) {
} }
} }
// TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData looks up an AS at // TestLookupASNAsksForTheOrganisationOnItsOwn looks up an AS at APNIC and
// APNIC and at AFRINIC. Each answers only a query that leaves out person // at AFRINIC. Each answers only "-r" queries, whose answer for the AS
// and role objects, as RIPE does. That one answer still holds the // leaves out its organisation object, so the client asks for that object
// organisation object, so the organisation's name and country are filled. // too. The organisation's name and country come from that second answer.
func TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData(t *testing.T) { func TestLookupASNAsksForTheOrganisationOnItsOwn(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"} {
@@ -251,8 +253,9 @@ func TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData(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 + " --no-personal " + asQuery: apnicAfrinicRecord, address + " -r " + asQuery: answerWithoutOrganisation,
address + " -r ORG-EXAMPLE1": organisationAnswer,
}) })
if err != nil { if err != nil {
t.Fatalf("LookupASN: %v", err) t.Fatalf("LookupASN: %v", err)