diff --git a/TODO.md b/TODO.md index 57d039b..99d072f 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,11 @@ tested. # Completed Steps +- 2026-10-27: the WHOIS details of a Brazilian AS keep their accented letters. + LACNIC's answer for such an AS comes from NIC.br, Brazil's registry, with each + accented letter replaced. The details now come from NIC.br's own server, + `whois.registro.br`. When that query fails, the details from LACNIC's answer + are kept (closes #174) - 2026-10-27: `make asupdate` stops with an error when the download of `as.csv` breaks off. The generator used to retry the failed read forever. A record that does not parse is still logged and skipped (closes #177) diff --git a/internal/whois/whois.go b/internal/whois/whois.go index b13e9bf..c8a9154 100644 --- a/internal/whois/whois.go +++ b/internal/whois/whois.go @@ -49,8 +49,25 @@ const ( whoisServerAPNIC = "whois.apnic.net:43" whoisServerLACNIC = "whois.lacnic.net:43" whoisServerAFRINIC = "whois.afrinic.net:43" + + // NIC.br registers Brazil's ASes under LACNIC. + whoisServerNICbr = "whois.registro.br:43" ) +// nicbrHeader starts the copyright notice at the top of NIC.br's WHOIS +// answers. LACNIC's answer for an AS that NIC.br registers is NIC.br's, and +// carries the same notice: +// +// % Copyright (c) Nic.br - Use of this data is governed by the Use and +// % Privacy Policy at https://registro.br/upp . Distribution, +const nicbrHeader = "% Copyright (c) Nic.br" + +// nicbrLimitReached is in the line that marks NIC.br's reduced answer to a +// client over its query limit. NIC.br publishes no limit. Its Use and +// Privacy Policy, https://registro.br/upp, says only that it restricts +// queries to stop the data being taken in bulk. +const nicbrLimitReached = "Query rate limit exceeded" + // RIR identifiers. const ( RIRARIN = "ARIN" @@ -133,11 +150,14 @@ func NewClient() *Client { } // LookupASN asks IANA which RIR holds the given ASN, asks that RIR, and -// returns what the RIR's answer says. It returns ErrNoReferral when IANA -// refers the AS to no RIR, and another error when a query fails, when the -// RIR refuses it, when IANA's answer has neither a referral nor the AS's -// block, when IANA refers the AS to a server none of the five RIRs run, or -// when the RIR's answer carries none of ASNInfo's fields. +// returns what the RIR's answer says. When LACNIC's answer is NIC.br's, it +// returns what NIC.br's own server says instead, unless that query fails. +// +// It returns ErrNoReferral when IANA refers the AS to no RIR, and another +// error when the query to IANA or to the RIR fails, when the RIR refuses +// it, when IANA's answer has neither a referral nor the AS's block, when +// IANA refers the AS to a server none of the five RIRs run, or when the +// answer carries none of ASNInfo's fields. func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) { // Query IANA first to find the authoritative RIR query := fmt.Sprintf("AS%d", asn) @@ -169,18 +189,45 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) { return nil, fmt.Errorf("RIR query failed: %w", err) } - // The fields come from the RIR's answer alone: IANA's describes the - // block the AS is in. + // The fields never come from IANA's answer, which describes the block + // the AS is in. info := c.parseResponse(asn, rir, rirResp) + + if rir == RIRLACNIC && strings.Contains(rirResp, nicbrHeader) { + info = c.askNICbr(ctx, query, info) + } + if info.isEmpty() { return nil, fmt.Errorf("%s from %s: %w", query, whoisServer, errNoFields) } - info.RawResponse = ianaResp + "\n" + rirResp + info.RawResponse = ianaResp + "\n" + info.RawResponse return info, nil } +// askNICbr asks NIC.br's server about an AS whose answer from LACNIC is +// NIC.br's, and returns what NIC.br's answer says. LACNIC replaces each +// accented letter in such an answer with U+FFFD. NIC.br's own server keeps +// them, in Latin-1. askNICbr returns lacnicInfo, what LACNIC's answer says, +// when the query fails, when NIC.br sends the reduced answer for a client +// over its limit, or when NIC.br's answer carries none of ASNInfo's fields. +func (c *Client) askNICbr( + ctx context.Context, asQuery string, lacnicInfo *ASNInfo, +) *ASNInfo { + answer, err := c.query(ctx, whoisServerNICbr, asQuery) + if err != nil || strings.Contains(answer, nicbrLimitReached) { + return lacnicInfo + } + + info := c.parseResponse(lacnicInfo.ASN, RIRLACNIC, answer) + if info.isEmpty() { + return lacnicInfo + } + + return info +} + // isEmpty reports whether info holds none of the fields a WHOIS answer // fills, as when the answer is only comment and error lines. func (info *ASNInfo) isEmpty() bool { @@ -276,9 +323,9 @@ func (c *Client) query(ctx context.Context, server, query string) (string, error } // toUTF8 returns a WHOIS answer in UTF-8. An answer that is valid UTF-8 is -// returned as it is. Any other answer is decoded as Latin-1, which RIPE and -// APNIC send. LACNIC, ARIN and AFRINIC send UTF-8. RIPE's database working -// group lists the five here: +// returned as it is. Any other answer is decoded as Latin-1, which RIPE, +// APNIC and NIC.br send. LACNIC, ARIN and AFRINIC send UTF-8. RIPE's +// database working group lists the five RIRs here: // https://www.ripe.net/ripe/mail/archives/db-wg/2024-May/008075.html func toUTF8(answer string) (string, error) { if utf8.ValidString(answer) { diff --git a/internal/whois/whois_test.go b/internal/whois/whois_test.go index 8284cec..02a7059 100644 --- a/internal/whois/whois_test.go +++ b/internal/whois/whois_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "io" + "maps" "net" "slices" "strings" @@ -50,6 +51,10 @@ 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 @@ -421,7 +426,7 @@ func TestLookupASNStoresAnswersInUTF8(t *testing.T) { "org-name: " + latin1OrgName + "\n", }, "LACNIC in UTF-8": { - ianaQuery: "refer: whois.lacnic.net\n", + ianaQuery: lacnicReferral, "whois.lacnic.net:43 " + asQuery: "aut-num: AS3333\n" + "owner: " + utf8OrgName + "\n", }, @@ -448,6 +453,190 @@ func TestLookupASNStoresAnswersInUTF8(t *testing.T) { } } +// 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 +` + +// 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() + + dial := fakeServers(t, answers) + askedNICbr := false + + client := whois.NewClient() + client.DialContext = func( + ctx context.Context, network, address string, + ) (net.Conn, error) { + if address == nicbrServer { + askedNICbr = true + } + + return dial(ctx, network, address) + } + + info, err := client.LookupASN(t.Context(), brazilASN) + + return info, askedNICbr, 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) + } +} + +// 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. +func TestLookupASNKeepsLACNICsAnswerWhenNICbrFails(t *testing.T) { + t.Parallel() + + for name, nicbrAnswer := range map[string]map[string]string{ + "connection refused": {}, + "no answer": {brazilNICbrQuery: ""}, + "over the query limit": {brazilNICbrQuery: nicbrOverLimit}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + answers := map[string]string{ + brazilIANAQuery: lacnicReferral, + brazilLACNICQuery: lacnicBrazilRecord, + } + maps.Copy(answers, nicbrAnswer) + + info, askedNICbr, err := lookUpBrazilianAS(t, answers) + if err != nil { + t.Fatalf("LookupASN: %v", err) + } + + if !askedNICbr { + 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) + } + }) + } +} + // 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.