package whois_test import ( "bufio" "context" "errors" "io" "net" "slices" "strings" "sync" "syscall" "testing" "time" "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: 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 '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 ` // 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 its block. 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) } } // 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) } }) } } // 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) } }) } }