check / check (push) Canceled after 0s
The fetcher kept one interval for every registry, so a registry that refused a query was asked again within seconds. RIPE blocked the test host's address that way. The WHOIS client now asks a server nothing for an hour after the server refuses a query. A refusal is a refused or reset connection, or an %ERROR:201: or %ERROR:202: line. The other servers are still asked. A lookup that needs a paused server leaves the AS stale, and counts as neither a success nor a failure. NIC.br's hour-long skip after its over-limit answer is now the same pause. Model: opus-5-5
1018 lines
30 KiB
Go
1018 lines
30 KiB
Go
package whois_test
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.eeqj.de/sneak/routewatch/internal/whois"
|
|
)
|
|
|
|
// The servers LookupASN asks: IANA first, then the RIR IANA refers it to.
|
|
const (
|
|
ianaServer = "whois.iana.org:43"
|
|
ripeServer = "whois.ripe.net:43"
|
|
)
|
|
|
|
// testASN is the AS the tests look up, and asQuery the query for it.
|
|
const (
|
|
testASN = 3333
|
|
asQuery = "AS3333"
|
|
)
|
|
|
|
// The fake servers' answers are keyed by a server's address, a space and a
|
|
// query. These are the keys of the queries LookupASN sends for testASN:
|
|
// IANA's, then RIPE's, which asks RIPE to leave out person and role
|
|
// objects.
|
|
const (
|
|
ianaQuery = ianaServer + " " + asQuery
|
|
ripeQuery = ripeServer + " --no-personal " + asQuery
|
|
)
|
|
|
|
// ianaReferral is IANA's answer for an AS in a block it gave to RIPE.
|
|
const ianaReferral = `% IANA WHOIS server
|
|
|
|
refer: whois.ripe.net
|
|
|
|
as-block: 3154-3353
|
|
organisation: Assigned by RIPE NCC
|
|
whois: whois.ripe.net
|
|
changed: 2002-09-01
|
|
source: IANA
|
|
`
|
|
|
|
// lacnicReferral is IANA's answer for an AS in a block it gave to LACNIC,
|
|
// shortened to the referral.
|
|
const lacnicReferral = "refer: whois.lacnic.net\n"
|
|
|
|
// ripeRecord is RIPE's answer for the AS when asked to leave out person and
|
|
// role objects. It starts with the as-block object of the block the AS is
|
|
// in. Then come the abuse address as a comment, the AS's object and the
|
|
// organisation object it refers to.
|
|
const ripeRecord = `% This is the RIPE Database query service.
|
|
% The objects are in RPSL format.
|
|
|
|
% Note: --no-personal means ALL personal data has been filtered from this output.
|
|
|
|
% Information related to 'AS3209 - AS3353'
|
|
|
|
as-block: AS3209 - AS3353
|
|
descr: RIPE NCC ASN block
|
|
remarks: These AS Numbers are assigned to network operators
|
|
remarks: in the RIPE NCC service region.
|
|
mnt-by: RIPE-NCC-HM-MNT
|
|
created: 2018-11-22T15:27:19Z
|
|
last-modified: 2018-11-22T15:27:19Z
|
|
source: RIPE
|
|
|
|
% Information related to 'AS3333'
|
|
|
|
% Abuse contact for 'AS3333' is 'abuse@ripe.net'
|
|
|
|
aut-num: AS3333
|
|
as-name: RIPE-NCC-AS
|
|
org: ORG-RIEN1-RIPE
|
|
admin-c: BRD-RIPE
|
|
tech-c: OPS4-RIPE
|
|
abuse-c: OPS4-RIPE
|
|
mnt-by: RIPE-NCC-MNT
|
|
created: 2002-09-17T13:08:32Z
|
|
last-modified: 2024-01-09T08:55:41Z
|
|
source: RIPE
|
|
|
|
organisation: ORG-RIEN1-RIPE
|
|
org-name: Reseaux IP Europeens Network Coordination Centre (RIPE NCC)
|
|
country: NL
|
|
org-type: RIR
|
|
address: P.O. Box 10096
|
|
address: 1001EB Amsterdam
|
|
abuse-c: OPS4-RIPE
|
|
created: 2012-03-09T13:08:29Z
|
|
last-modified: 2024-03-26T10:55:38Z
|
|
source: RIPE
|
|
`
|
|
|
|
// The error lines with which RIPE refuses a query: access denied to the
|
|
// host, and the host's limit on returned objects reached.
|
|
const (
|
|
ripeAccessDenied = "%ERROR:201: access denied for 192.0.2.1"
|
|
ripeLimitReached = "%ERROR:202: access control limit reached"
|
|
)
|
|
|
|
// ripeRefusal is RIPE's answer to a host it no longer serves.
|
|
const ripeRefusal = `% This is the RIPE Database query service.
|
|
% The objects are in RPSL format.
|
|
%
|
|
` + ripeAccessDenied + `
|
|
%
|
|
% Sorry, access from your host has been permanently
|
|
% 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'
|
|
|
|
aut-num: AS3333
|
|
as-name: EXAMPLE-AS
|
|
org: ORG-EXAMPLE1
|
|
admin-c: EX1-TEST
|
|
last-modified: 2026-08-26T00:40:00Z
|
|
|
|
organisation: ORG-EXAMPLE1
|
|
org-name: Example Networks
|
|
country: ZA
|
|
address: 1 Example Road
|
|
`
|
|
|
|
// apnicServer is APNIC's WHOIS server, and apnicASN an AS that APNIC holds.
|
|
const (
|
|
apnicServer = "whois.apnic.net:43"
|
|
apnicASN = 4608
|
|
)
|
|
|
|
// The keys of the queries LookupASN sends for apnicASN: IANA's, then
|
|
// APNIC's, which asks APNIC to leave out person and role objects.
|
|
const (
|
|
apnicIANAQuery = ianaServer + " AS4608"
|
|
apnicQuery = apnicServer + " --no-personal AS4608"
|
|
)
|
|
|
|
// apnicRecord is APNIC's answer for apnicASN when asked to leave out person
|
|
// and role objects, shortened. It starts with the as-block object of the
|
|
// block the AS is in. Then come the abuse address as a comment, the AS's
|
|
// object, and the irt and organisation objects it refers to.
|
|
const apnicRecord = `% [whois.apnic.net]
|
|
% Whois data copyright terms http://www.apnic.net/db/dbcopyright.html
|
|
|
|
% Note: --no-personal means ALL personal data has been filtered from this output.
|
|
|
|
% Information related to 'AS4608 - AS4865'
|
|
|
|
as-block: AS4608 - AS4865
|
|
descr: APNIC ASN block
|
|
remarks: These AS numbers are further assigned by APNIC
|
|
remarks: to APNIC members and end-users in the APNIC region
|
|
mnt-by: APNIC-HM
|
|
mnt-lower: APNIC-HM
|
|
last-modified: 2019-05-20T03:52:26Z
|
|
source: APNIC
|
|
|
|
% Information related to 'AS4608'
|
|
|
|
% Abuse contact for 'AS4608' is 'abuse@apnic.net'
|
|
|
|
aut-num: AS4608
|
|
as-name: APNIC-SERVICES
|
|
descr: Asia Pacific Network Information Centre
|
|
country: AU
|
|
org: ORG-APNI1-AP
|
|
admin-c: AIC1-AP
|
|
abuse-c: AA1589-AP
|
|
mnt-by: APNIC-HM
|
|
mnt-irt: IRT-APNIC-IS-AP
|
|
last-modified: 2026-08-26T00:40:00Z
|
|
source: APNIC
|
|
|
|
irt: IRT-APNIC-IS-AP
|
|
address: South Brisbane, Australia
|
|
e-mail: helpdesk@apnic.net
|
|
abuse-mailbox: abuse@apnic.net
|
|
mnt-by: MAINT-APNIC-IS-AP
|
|
last-modified: 2025-11-18T00:27:08Z
|
|
source: APNIC
|
|
|
|
organisation: ORG-APNI1-AP
|
|
org-name: Asia Pacific Network Information Centre
|
|
org-type: LIR
|
|
country: AU
|
|
address: PO Box 3646
|
|
address: South Brisbane, QLD 4101,
|
|
address: Australia
|
|
e-mail: noc@apnic.net
|
|
mnt-by: APNIC-HM
|
|
last-modified: 2026-08-24T12:58:43Z
|
|
source: APNIC
|
|
|
|
% This query was served by the APNIC Whois Service version 1.88.48 (WHOIS-UK2)
|
|
`
|
|
|
|
// ianaPrivateUse is IANA's answer for a private-use AS: its block and no
|
|
// referral.
|
|
const ianaPrivateUse = `% IANA WHOIS server
|
|
|
|
as-block: 64512-65534
|
|
organisation: Reserved for Private Use
|
|
descr: Reserved for Private Use
|
|
source: IANA
|
|
`
|
|
|
|
// ianaNoObject is IANA's answer when it has nothing for the query.
|
|
const ianaNoObject = `% IANA WHOIS server
|
|
% This query returned 0 objects.
|
|
`
|
|
|
|
// fillerLine is one of the lines a server that does not stop keeps sending.
|
|
const fillerLine = "remarks: and more\n"
|
|
|
|
// endlessAnswerSize is how many bytes of filler lines that server sends:
|
|
// four times the client's cap on an answer.
|
|
const endlessAnswerSize = 4 << 20
|
|
|
|
// fakeServers returns a DialContext for a whois.Client that connects to a
|
|
// fake server at each address answers names. Each key of answers is a
|
|
// server's address, a space and a query, and its value is that server's
|
|
// answer to that query. A server reads one query and sends its answer, or
|
|
// nothing for a query it has no answer for. Connecting to any other address
|
|
// is refused.
|
|
func fakeServers(
|
|
t *testing.T, answers map[string]string,
|
|
) func(context.Context, string, string) (net.Conn, error) {
|
|
t.Helper()
|
|
|
|
addresses := make(map[string]bool)
|
|
|
|
for key := range answers {
|
|
address, _, _ := strings.Cut(key, " ")
|
|
addresses[address] = true
|
|
}
|
|
|
|
var servers sync.WaitGroup
|
|
|
|
t.Cleanup(servers.Wait)
|
|
|
|
return func(_ context.Context, _, address string) (net.Conn, error) {
|
|
if !addresses[address] {
|
|
return nil, syscall.ECONNREFUSED
|
|
}
|
|
|
|
client, server := net.Pipe()
|
|
|
|
servers.Go(func() {
|
|
defer func() { _ = server.Close() }()
|
|
|
|
query, _ := bufio.NewReader(server).ReadString('\n')
|
|
answer := answers[address+" "+strings.TrimSpace(query)]
|
|
_, _ = io.WriteString(server, answer)
|
|
})
|
|
|
|
return client, nil
|
|
}
|
|
}
|
|
|
|
// lookUp looks testASN up with fake servers sending answers.
|
|
func lookUp(
|
|
t *testing.T, answers map[string]string,
|
|
) (*whois.ASNInfo, error) {
|
|
t.Helper()
|
|
|
|
client := whois.NewClient(slog.New(slog.DiscardHandler))
|
|
client.DialContext = fakeServers(t, answers)
|
|
|
|
return client.LookupASN(t.Context(), testASN)
|
|
}
|
|
|
|
// TestLookupASNReadsTheRIRAnswer looks up an AS IANA refers to RIPE. RIPE
|
|
// answers only a query that leaves out person and role objects, and its
|
|
// answer to that still fills the AS's fields: the abuse address from the
|
|
// comment line, and the organisation's name and country from its object.
|
|
// The dates are the AS's. IANA's "changed:" line dates the AS's block, and
|
|
// so do the dates of the as-block object at the start of RIPE's answer.
|
|
func TestLookupASNReadsTheRIRAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
info, err := lookUp(t, map[string]string{
|
|
ianaQuery: ianaReferral,
|
|
ripeQuery: ripeRecord,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("LookupASN: %v", err)
|
|
}
|
|
|
|
got := []string{
|
|
info.ASName, info.OrgID, info.OrgName, info.CountryCode,
|
|
info.AbuseEmail, info.RIR,
|
|
}
|
|
want := []string{
|
|
"RIPE-NCC-AS", "ORG-RIEN1-RIPE",
|
|
"Reseaux IP Europeens Network Coordination Centre (RIPE NCC)", "NL",
|
|
"abuse@ripe.net", whois.RIRRIPE,
|
|
}
|
|
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("LookupASN = AS name, org ID, org name, country, abuse "+
|
|
"address, RIR %q; want %q", got, want)
|
|
}
|
|
|
|
wantRegDate := time.Date(2002, 9, 17, 13, 8, 32, 0, time.UTC)
|
|
if info.RegDate == nil || !info.RegDate.Equal(wantRegDate) {
|
|
t.Errorf("LookupASN registered = %v, want %v",
|
|
info.RegDate, wantRegDate)
|
|
}
|
|
|
|
wantLastMod := time.Date(2024, 1, 9, 8, 55, 41, 0, time.UTC)
|
|
if info.LastMod == nil || !info.LastMod.Equal(wantLastMod) {
|
|
t.Errorf("LookupASN last modified = %v, want %v",
|
|
info.LastMod, wantLastMod)
|
|
}
|
|
}
|
|
|
|
// TestLookupASNReadsOnlyTheASAndItsOrganisation looks up an AS at APNIC,
|
|
// whose answer starts with the as-block object of the block the AS is in.
|
|
// The last-modified date is the aut-num object's, not the block's. The
|
|
// address is the organisation object's alone, without the irt object's.
|
|
func TestLookupASNReadsOnlyTheASAndItsOrganisation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := whois.NewClient(slog.New(slog.DiscardHandler))
|
|
client.DialContext = fakeServers(t, map[string]string{
|
|
apnicIANAQuery: "refer: whois.apnic.net\n",
|
|
apnicQuery: apnicRecord,
|
|
})
|
|
|
|
info, err := client.LookupASN(t.Context(), apnicASN)
|
|
if err != nil {
|
|
t.Fatalf("LookupASN: %v", err)
|
|
}
|
|
|
|
wantLastMod := time.Date(2026, 8, 26, 0, 40, 0, 0, time.UTC)
|
|
if info.LastMod == nil || !info.LastMod.Equal(wantLastMod) {
|
|
t.Errorf("LookupASN last modified = %v, want %v",
|
|
info.LastMod, wantLastMod)
|
|
}
|
|
|
|
got := []string{
|
|
info.ASName, info.OrgID, info.OrgName, info.CountryCode,
|
|
info.Address, info.AbuseEmail,
|
|
}
|
|
want := []string{
|
|
"APNIC-SERVICES", "ORG-APNI1-AP",
|
|
"Asia Pacific Network Information Centre", "AU",
|
|
"PO Box 3646\nSouth Brisbane, QLD 4101,\nAustralia", "abuse@apnic.net",
|
|
}
|
|
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("LookupASN = AS name, org ID, org name, country, address, "+
|
|
"abuse address %q; want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// 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"} {
|
|
t.Run(server, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
address := server + ":43"
|
|
|
|
info, err := lookUp(t, map[string]string{
|
|
ianaQuery: "refer: " + server + "\n",
|
|
address + " --no-personal " + asQuery: apnicAfrinicRecord,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("LookupASN: %v", err)
|
|
}
|
|
|
|
got := []string{
|
|
info.ASName, info.OrgName, info.CountryCode, info.AbuseEmail,
|
|
}
|
|
want := []string{
|
|
"EXAMPLE-AS", "Example Networks", "ZA", "abuse@example.net",
|
|
}
|
|
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("LookupASN = AS name, org name, country, abuse "+
|
|
"address %q; want %q", got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The name of an organisation with accented letters, in Latin-1, one byte for
|
|
// each letter, and in UTF-8.
|
|
const (
|
|
latin1OrgName = "Fundaci\xf3n Ra\xedces de Espa\xf1a"
|
|
utf8OrgName = "Fundación Raíces de España"
|
|
)
|
|
|
|
// TestLookupASNStoresAnswersInUTF8 looks up an AS at RIPE, which answers in
|
|
// Latin-1, and at LACNIC, which answers in UTF-8. Both answers give the
|
|
// organisation's name with its accented letters in UTF-8, in the parsed field
|
|
// and in the stored answer. Before the fix the Latin-1 answer was kept as
|
|
// received, which is not valid UTF-8.
|
|
func TestLookupASNStoresAnswersInUTF8(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, answers := range map[string]map[string]string{
|
|
"RIPE in Latin-1": {
|
|
ianaQuery: ianaReferral,
|
|
ripeQuery: "aut-num: AS3333\n" +
|
|
"org-name: " + latin1OrgName + "\n",
|
|
},
|
|
"LACNIC in UTF-8": {
|
|
ianaQuery: lacnicReferral,
|
|
"whois.lacnic.net:43 " + asQuery: "aut-num: AS3333\n" +
|
|
"owner: " + utf8OrgName + "\n",
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
info, err := lookUp(t, answers)
|
|
if err != nil {
|
|
t.Fatalf("LookupASN: %v", err)
|
|
}
|
|
|
|
if info.OrgName != utf8OrgName {
|
|
t.Errorf("LookupASN org name = %q, want %q",
|
|
info.OrgName, utf8OrgName)
|
|
}
|
|
|
|
if !utf8.ValidString(info.RawResponse) ||
|
|
!strings.Contains(info.RawResponse, utf8OrgName) {
|
|
t.Errorf("LookupASN answer = %q, want UTF-8 holding %q",
|
|
info.RawResponse, utf8OrgName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// lacnicServer is LACNIC's WHOIS server and nicbrServer NIC.br's, which
|
|
// registers Brazil's ASes under LACNIC. brazilASN is one of those ASes.
|
|
const (
|
|
lacnicServer = "whois.lacnic.net:43"
|
|
nicbrServer = "whois.registro.br:43"
|
|
brazilASN = 52751
|
|
)
|
|
|
|
// The keys of the queries LookupASN sends for brazilASN: IANA's, LACNIC's,
|
|
// then NIC.br's.
|
|
const (
|
|
brazilIANAQuery = ianaServer + " AS52751"
|
|
brazilLACNICQuery = lacnicServer + " AS52751"
|
|
brazilNICbrQuery = nicbrServer + " AS52751"
|
|
)
|
|
|
|
// The name of brazilASN's organisation in three forms: as LACNIC sends it,
|
|
// with each accented letter replaced by U+FFFD; as NIC.br sends it, in
|
|
// Latin-1; and in UTF-8.
|
|
const (
|
|
lacnicBrazilOrgName = "Brasrede Telecomunica\U0000FFFD\U0000FFFDes LTDA"
|
|
nicbrBrazilOrgName = "Brasrede Telecomunica\xe7\xf5es LTDA"
|
|
brazilOrgName = "Brasrede Telecomunicações LTDA"
|
|
)
|
|
|
|
// nicbrCopyright is the copyright notice at the top of NIC.br's answers.
|
|
const nicbrCopyright = "% Copyright (c) Nic.br - " +
|
|
"Use of this data is governed by the Use and\n" +
|
|
"% Privacy Policy at https://registro.br/upp . Distribution,\n" +
|
|
"% commercialization, reproduction, and use for advertising or similar\n" +
|
|
"% purposes are expressly prohibited.\n"
|
|
|
|
// lacnicBrazilRecord is LACNIC's answer for brazilASN, recorded on
|
|
// 2026-10-26. It is NIC.br's answer, which LACNIC passes on after a line
|
|
// of its own and a space. It is shortened. The client's address is a
|
|
// documentation address. The contact's name, the contact's object and the
|
|
// footer are left out.
|
|
const lacnicBrazilRecord = "% IP Client: 192.0.2.1\n" +
|
|
" " + nicbrCopyright + `% 2026-10-26T23:12:22-03:00 - 192.0.2.1
|
|
|
|
aut-num: AS52751
|
|
owner: ` + lacnicBrazilOrgName + `
|
|
owner-c: AVL217
|
|
routing-c: AVL217
|
|
abuse-c: AVL217
|
|
created: 20120829
|
|
changed: 20130306
|
|
inetnum: 177.38.152.0/21
|
|
inetnum: 2804:b34::/32
|
|
inetnum: 191.243.4.0/22
|
|
inetnum: 131.72.248.0/22
|
|
inetnum: 138.94.228.0/22
|
|
inetnum: 170.233.8.0/22
|
|
inetnum: 170.246.4.0/22
|
|
`
|
|
|
|
// nicbrRecord is NIC.br's answer for brazilASN, in Latin-1, recorded on
|
|
// 2026-10-26 and shortened as lacnicBrazilRecord is.
|
|
const nicbrRecord = nicbrCopyright + `% 2026-10-26T23:14:41-03:00 - 192.0.2.1
|
|
|
|
aut-num: AS52751
|
|
owner: ` + nicbrBrazilOrgName + `
|
|
owner-c: AVL217
|
|
routing-c: AVL217
|
|
abuse-c: AVL217
|
|
created: 20120829
|
|
changed: 20130306
|
|
inetnum: 177.38.152.0/21
|
|
inetnum: 2804:b34::/32
|
|
inetnum: 191.243.4.0/22
|
|
inetnum: 131.72.248.0/22
|
|
inetnum: 138.94.228.0/22
|
|
inetnum: 170.233.8.0/22
|
|
inetnum: 170.246.4.0/22
|
|
`
|
|
|
|
// nicbrOverLimit stands for NIC.br's answer for brazilASN to a client over
|
|
// its query limit, and is not recorded. Such an answer leaves information
|
|
// out, here the owner, and says so in its first line.
|
|
const nicbrOverLimit = `% Query rate limit exceeded. Reduced information.
|
|
|
|
aut-num: AS52751
|
|
created: 20120829
|
|
changed: 20130306
|
|
`
|
|
|
|
// newNICbrClient returns a client whose fake servers send answers and
|
|
// whose logger writes JSON lines to logs. It adds one to *nicbrAsks each
|
|
// time it connects to NIC.br's server.
|
|
func newNICbrClient(
|
|
t *testing.T, answers map[string]string, logs io.Writer, nicbrAsks *int,
|
|
) *whois.Client {
|
|
t.Helper()
|
|
|
|
dial := fakeServers(t, answers)
|
|
|
|
client := whois.NewClient(slog.New(slog.NewJSONHandler(logs, nil)))
|
|
client.DialContext = func(
|
|
ctx context.Context, network, address string,
|
|
) (net.Conn, error) {
|
|
if address == nicbrServer {
|
|
*nicbrAsks++
|
|
}
|
|
|
|
return dial(ctx, network, address)
|
|
}
|
|
|
|
return client
|
|
}
|
|
|
|
// lookUpBrazilianAS looks brazilASN up with fake servers sending answers,
|
|
// and reports whether the client connected to NIC.br's server.
|
|
func lookUpBrazilianAS(
|
|
t *testing.T, answers map[string]string,
|
|
) (*whois.ASNInfo, bool, error) {
|
|
t.Helper()
|
|
|
|
nicbrAsks := 0
|
|
client := newNICbrClient(t, answers, io.Discard, &nicbrAsks)
|
|
|
|
info, err := client.LookupASN(t.Context(), brazilASN)
|
|
|
|
return info, nicbrAsks > 0, err
|
|
}
|
|
|
|
// TestLookupASNTakesABrazilianASFromNICbr looks up an AS that NIC.br
|
|
// registers. LACNIC's answer is NIC.br's with each accented letter replaced
|
|
// by U+FFFD, so the client also asks NIC.br's server, which answers in
|
|
// Latin-1. The organisation's name has its accented letters, in the parsed
|
|
// field and in the stored answer.
|
|
func TestLookupASNTakesABrazilianASFromNICbr(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
info, _, err := lookUpBrazilianAS(t, map[string]string{
|
|
brazilIANAQuery: lacnicReferral,
|
|
brazilLACNICQuery: lacnicBrazilRecord,
|
|
brazilNICbrQuery: nicbrRecord,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("LookupASN: %v", err)
|
|
}
|
|
|
|
if info.OrgName != brazilOrgName || info.RIR != whois.RIRLACNIC {
|
|
t.Errorf("LookupASN org name, RIR = %q, %q; want %q, %q",
|
|
info.OrgName, info.RIR, brazilOrgName, whois.RIRLACNIC)
|
|
}
|
|
|
|
if !strings.Contains(info.RawResponse, brazilOrgName) {
|
|
t.Errorf("LookupASN answer = %q, want one holding %q",
|
|
info.RawResponse, brazilOrgName)
|
|
}
|
|
|
|
wantRegDate := time.Date(2012, 8, 29, 0, 0, 0, 0, time.UTC)
|
|
if info.RegDate == nil || !info.RegDate.Equal(wantRegDate) {
|
|
t.Errorf("LookupASN registered = %v, want %v",
|
|
info.RegDate, wantRegDate)
|
|
}
|
|
}
|
|
|
|
// TestLookupASNAsksNICbrOnlyForItsASes looks up an AS at LACNIC whose
|
|
// answer does not carry NIC.br's copyright notice, as for an AS that LACNIC
|
|
// registers itself. The client does not connect to NIC.br's server, which
|
|
// would answer with a record, and keeps LACNIC's fields.
|
|
func TestLookupASNAsksNICbrOnlyForItsASes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
info, askedNICbr, err := lookUpBrazilianAS(t, map[string]string{
|
|
brazilIANAQuery: lacnicReferral,
|
|
brazilLACNICQuery: "aut-num: AS52751\n" +
|
|
"owner: " + utf8OrgName + "\n",
|
|
brazilNICbrQuery: nicbrRecord,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("LookupASN: %v", err)
|
|
}
|
|
|
|
if askedNICbr {
|
|
t.Error("LookupASN asked NIC.br's server about an AS whose " +
|
|
"answer from LACNIC is not NIC.br's")
|
|
}
|
|
|
|
if info.OrgName != utf8OrgName {
|
|
t.Errorf("LookupASN org name = %q, want LACNIC's %q",
|
|
info.OrgName, utf8OrgName)
|
|
}
|
|
}
|
|
|
|
// TestLookupASNKeepsLACNICsAnswerWhenNICbrFails looks up an AS that NIC.br
|
|
// registers when NIC.br's server refuses the connection, sends nothing, or
|
|
// sends the reduced answer for a client over its query limit. The client
|
|
// asks NIC.br, and the lookup still succeeds with LACNIC's fields. The
|
|
// client logs one warning, which names the AS and the reason.
|
|
func TestLookupASNKeepsLACNICsAnswerWhenNICbrFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, test := range map[string]struct {
|
|
nicbrAnswer map[string]string
|
|
reason string
|
|
}{
|
|
"connection refused": {
|
|
nicbrAnswer: map[string]string{},
|
|
reason: syscall.ECONNREFUSED.Error(),
|
|
},
|
|
"no answer": {
|
|
nicbrAnswer: map[string]string{brazilNICbrQuery: ""},
|
|
reason: "no WHOIS fields",
|
|
},
|
|
"over the query limit": {
|
|
nicbrAnswer: map[string]string{brazilNICbrQuery: nicbrOverLimit},
|
|
reason: "over its query limit",
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
answers := map[string]string{
|
|
brazilIANAQuery: lacnicReferral,
|
|
brazilLACNICQuery: lacnicBrazilRecord,
|
|
}
|
|
maps.Copy(answers, test.nicbrAnswer)
|
|
|
|
var logs bytes.Buffer
|
|
|
|
nicbrAsks := 0
|
|
client := newNICbrClient(t, answers, &logs, &nicbrAsks)
|
|
|
|
info, err := client.LookupASN(t.Context(), brazilASN)
|
|
if err != nil {
|
|
t.Fatalf("LookupASN: %v", err)
|
|
}
|
|
|
|
if nicbrAsks == 0 {
|
|
t.Error("LookupASN did not ask NIC.br's server")
|
|
}
|
|
|
|
if info.OrgName != lacnicBrazilOrgName {
|
|
t.Errorf("LookupASN org name = %q, want LACNIC's %q",
|
|
info.OrgName, lacnicBrazilOrgName)
|
|
}
|
|
|
|
var logged struct {
|
|
Level string `json:"level"`
|
|
ASN int `json:"asn"`
|
|
}
|
|
|
|
err = json.Unmarshal(logs.Bytes(), &logged)
|
|
if err != nil {
|
|
t.Fatalf("want one log line, got %q: %v", logs.String(), err)
|
|
}
|
|
|
|
if logged.Level != slog.LevelWarn.String() ||
|
|
logged.ASN != brazilASN ||
|
|
!strings.Contains(logs.String(), test.reason) {
|
|
t.Errorf("logged %q, want a warning for AS%d holding %q",
|
|
logs.String(), brazilASN, test.reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLookupASNSkipsNICbrAfterItsLimit looks up an AS that NIC.br
|
|
// registers three times with one client, while NIC.br answers that the
|
|
// client is over its query limit. The first lookup asks NIC.br. The
|
|
// second, a second before PauseTime has passed, does not. The third, once
|
|
// PauseTime has passed, asks again. Each keeps LACNIC's fields.
|
|
func TestLookupASNSkipsNICbrAfterItsLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
nicbrAsks := 0
|
|
client := newNICbrClient(t, map[string]string{
|
|
brazilIANAQuery: lacnicReferral,
|
|
brazilLACNICQuery: lacnicBrazilRecord,
|
|
brazilNICbrQuery: nicbrOverLimit,
|
|
}, io.Discard, &nicbrAsks)
|
|
|
|
start := time.Date(2026, 10, 27, 0, 0, 0, 0, time.UTC)
|
|
now := start
|
|
client.Now = func() time.Time { return now }
|
|
|
|
for _, step := range []struct {
|
|
after time.Duration
|
|
wantAsks int
|
|
}{
|
|
{after: 0, wantAsks: 1},
|
|
{after: whois.PauseTime - time.Second, wantAsks: 1},
|
|
{after: whois.PauseTime, wantAsks: 2},
|
|
} {
|
|
now = start.Add(step.after)
|
|
|
|
info, err := client.LookupASN(t.Context(), brazilASN)
|
|
if err != nil {
|
|
t.Fatalf("LookupASN after %v: %v", step.after, err)
|
|
}
|
|
|
|
if nicbrAsks != step.wantAsks {
|
|
t.Errorf("after %v NIC.br's server was asked %d times, want %d",
|
|
step.after, nicbrAsks, step.wantAsks)
|
|
}
|
|
|
|
if info.OrgName != lacnicBrazilOrgName {
|
|
t.Errorf("LookupASN after %v org name = %q, want LACNIC's %q",
|
|
step.after, info.OrgName, lacnicBrazilOrgName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLookupASNReportsARefusal looks up an AS IANA refers to RIPE when RIPE
|
|
// refuses the query, with each of the error lines RIPE refuses with. The
|
|
// error holds RIPE's line, so the log says why the lookup failed.
|
|
func TestLookupASNReportsARefusal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, line := range []string{ripeAccessDenied, ripeLimitReached} {
|
|
t.Run(line, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := lookUp(t, map[string]string{
|
|
ianaQuery: ianaReferral,
|
|
ripeQuery: "% This is the RIPE Database query service.\n" +
|
|
line + "\n",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), line) {
|
|
t.Errorf("LookupASN error = %v, want one holding %q",
|
|
err, line)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// resetServer starts a TCP server on the loopback address that reads one
|
|
// query on each connection and then resets the connection. It returns the
|
|
// server's address.
|
|
func resetServer(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
var listenConfig net.ListenConfig
|
|
|
|
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = listener.Close() })
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, _ = bufio.NewReader(conn).ReadString('\n')
|
|
|
|
// Closing a connection whose linger time is zero resets it.
|
|
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
|
_ = tcpConn.SetLinger(0)
|
|
}
|
|
|
|
_ = conn.Close()
|
|
}
|
|
}()
|
|
|
|
return listener.Addr().String()
|
|
}
|
|
|
|
// newRIPEClient returns a client whose fake servers send answers. It adds
|
|
// one to *ripeAsks each time it connects to RIPE's server. When reset is
|
|
// true, RIPE's server resets the connection instead of answering.
|
|
func newRIPEClient(
|
|
t *testing.T, answers map[string]string, reset bool, ripeAsks *int,
|
|
) *whois.Client {
|
|
t.Helper()
|
|
|
|
dial := fakeServers(t, answers)
|
|
resetAddress := resetServer(t)
|
|
|
|
client := whois.NewClient(slog.New(slog.DiscardHandler))
|
|
client.DialContext = func(
|
|
ctx context.Context, network, address string,
|
|
) (net.Conn, error) {
|
|
if address == ripeServer {
|
|
*ripeAsks++
|
|
}
|
|
|
|
if address == ripeServer && reset {
|
|
var dialer net.Dialer
|
|
|
|
return dialer.DialContext(ctx, network, resetAddress)
|
|
}
|
|
|
|
return dial(ctx, network, address)
|
|
}
|
|
|
|
return client
|
|
}
|
|
|
|
// TestLookupASNPausesAServerThatRefuses looks up an AS at RIPE while RIPE
|
|
// refuses the query, in each of the ways a server refuses. A lookup of the
|
|
// AS a second before PauseTime has passed, by a clock the test sets, fails
|
|
// with ErrPaused and asks RIPE nothing. A lookup of an AS at APNIC still
|
|
// succeeds then. A lookup of the AS at PauseTime asks RIPE again.
|
|
func TestLookupASNPausesAServerThatRefuses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, test := range map[string]struct {
|
|
// ripeAnswer is RIPE's answer. With none, RIPE's server refuses
|
|
// the connection, or resets it when reset is true.
|
|
ripeAnswer string
|
|
reset bool
|
|
}{
|
|
"access denied": {ripeAnswer: ripeRefusal},
|
|
"limit reached": {ripeAnswer: ripeLimitReached + "\n"},
|
|
"refused connection": {},
|
|
"reset connection": {reset: true},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
answers := map[string]string{
|
|
ianaQuery: ianaReferral,
|
|
apnicIANAQuery: "refer: whois.apnic.net\n",
|
|
apnicQuery: apnicRecord,
|
|
}
|
|
if test.ripeAnswer != "" {
|
|
answers[ripeQuery] = test.ripeAnswer
|
|
}
|
|
|
|
ripeAsks := 0
|
|
client := newRIPEClient(t, answers, test.reset, &ripeAsks)
|
|
|
|
start := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
|
|
now := start
|
|
client.Now = func() time.Time { return now }
|
|
|
|
_, err := client.LookupASN(t.Context(), testASN)
|
|
if err == nil || errors.Is(err, whois.ErrPaused) {
|
|
t.Fatalf("LookupASN while RIPE refuses: error %v, want a "+
|
|
"refusal", err)
|
|
}
|
|
|
|
now = start.Add(whois.PauseTime - time.Second)
|
|
|
|
_, err = client.LookupASN(t.Context(), testASN)
|
|
if !errors.Is(err, whois.ErrPaused) || ripeAsks != 1 {
|
|
t.Errorf("LookupASN during the pause: error %v after %d "+
|
|
"queries to RIPE, want ErrPaused after 1", err, ripeAsks)
|
|
}
|
|
|
|
_, err = client.LookupASN(t.Context(), apnicASN)
|
|
if err != nil {
|
|
t.Errorf("LookupASN of an AS at APNIC during RIPE's "+
|
|
"pause: %v", err)
|
|
}
|
|
|
|
now = start.Add(whois.PauseTime)
|
|
|
|
_, _ = client.LookupASN(t.Context(), testASN)
|
|
if ripeAsks != 2 {
|
|
t.Errorf("RIPE was asked %d times after the pause, want 2",
|
|
ripeAsks)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLookupASNFailsWhenTheRIRGivesNoAnswer looks up an AS IANA refers to
|
|
// RIPE when RIPE's server refuses the connection, and when it answers only
|
|
// with an error. Neither is a lookup that found the AS.
|
|
func TestLookupASNFailsWhenTheRIRGivesNoAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, answers := range map[string]map[string]string{
|
|
"connection refused": {ianaQuery: ianaReferral},
|
|
"only an error": {ianaQuery: ianaReferral, ripeQuery: ripeRefusal},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := lookUp(t, answers)
|
|
if err == nil || errors.Is(err, whois.ErrNoReferral) {
|
|
t.Errorf("LookupASN error = %v, want one that is not "+
|
|
"ErrNoReferral", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLookupASNReportsNoReferral looks up a private-use AS, which IANA
|
|
// refers to no RIR.
|
|
func TestLookupASNReportsNoReferral(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := lookUp(t, map[string]string{ianaQuery: ianaPrivateUse})
|
|
if !errors.Is(err, whois.ErrNoReferral) {
|
|
t.Errorf("LookupASN error = %v, want ErrNoReferral", err)
|
|
}
|
|
}
|
|
|
|
// TestLookupASNFailsWhenIANAAnswersNoBlock looks up an AS when IANA answers
|
|
// with neither a referral nor the AS's block. That is not IANA saying no
|
|
// RIR holds the AS.
|
|
func TestLookupASNFailsWhenIANAAnswersNoBlock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := lookUp(t, map[string]string{ianaQuery: ianaNoObject})
|
|
if err == nil || errors.Is(err, whois.ErrNoReferral) {
|
|
t.Errorf("LookupASN error = %v, want one that is not ErrNoReferral",
|
|
err)
|
|
}
|
|
}
|
|
|
|
// TestLookupASNFailsWhenTheRIRAnswerDoesNotEnd looks up an AS whose RIR
|
|
// server follows the record with filler lines far past any real answer.
|
|
// The client stops reading at its cap and fails instead of keeping them.
|
|
func TestLookupASNFailsWhenTheRIRAnswerDoesNotEnd(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
endless := ripeRecord +
|
|
strings.Repeat(fillerLine, endlessAnswerSize/len(fillerLine))
|
|
|
|
_, err := lookUp(t, map[string]string{
|
|
ianaQuery: ianaReferral,
|
|
ripeQuery: endless,
|
|
})
|
|
if err == nil || errors.Is(err, whois.ErrNoReferral) {
|
|
t.Errorf("LookupASN error = %v, want one that is not ErrNoReferral",
|
|
err)
|
|
}
|
|
}
|
|
|
|
// TestLookupASNFailsOnAReferralToAnotherServer looks up an AS when IANA's
|
|
// answer refers it to a server none of the five RIRs run, with a port and
|
|
// without. The client follows a referral only to an RIR's server, so it
|
|
// fails instead of asking that server, which would answer with a record.
|
|
func TestLookupASNFailsOnAReferralToAnotherServer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Each case maps the server on the "refer:" line to the address the
|
|
// client would dial to follow it. IANA's answer also names the AS's
|
|
// block, so a client that dropped the referral would report no RIR.
|
|
for refer, address := range map[string]string{
|
|
"whois.example.net:8080": "whois.example.net:8080",
|
|
"whois.example.net": "whois.example.net:43",
|
|
} {
|
|
t.Run(refer, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := lookUp(t, map[string]string{
|
|
ianaQuery: "refer: " + refer + "\n\n" +
|
|
"as-block: 3154-3353\n",
|
|
address + " " + asQuery: ripeRecord,
|
|
})
|
|
if err == nil || errors.Is(err, whois.ErrNoReferral) {
|
|
t.Errorf("LookupASN error = %v, want one that is not "+
|
|
"ErrNoReferral", err)
|
|
}
|
|
})
|
|
}
|
|
}
|