check / check (push) Canceled after 0s
APNIC and RIPE start their answer for an AS with the as-block object of the block the AS is in. The parser kept the first value of each field across the whole answer, so it stored the block's dates as the AS's. The parser now reads an answer from RIPE, APNIC or AFRINIC object by object. It takes the fields from the aut-num object and the organisation object it names, and leaves out every other object. The irt object's address therefore no longer joins the organisation's. ARIN and LACNIC answers are parsed as before. Model: opus-5-5
568 lines
17 KiB
Go
568 lines
17 KiB
Go
package whois_test
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"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
|
|
`
|
|
|
|
// 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()
|
|
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()
|
|
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: "refer: whois.lacnic.net\n",
|
|
"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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|