diff --git a/TODO.md b/TODO.md index 94c3490..9b0066a 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,10 @@ tested. # Completed Steps +- 2026-10-27: the WHOIS details of an AS at RIPE, APNIC or AFRINIC come only + from its `aut-num` object and the organisation object it names. RIPE and APNIC + start their answer with the `as-block` object of the AS's block. Its dates + were stored as the AS's (closes #168) - 2026-10-27: AS names keep their accented letters. The AS list built into RouteWatch is rebuilt from today's `as.csv`, which added a country code column. That file no longer lists AS 0 or the documentation AS numbers. A diff --git a/internal/whois/whois.go b/internal/whois/whois.go index 042ec2b..b13e9bf 100644 --- a/internal/whois/whois.go +++ b/internal/whois/whois.go @@ -29,10 +29,18 @@ const maxAnswerSize = 1 << 20 // Parsing constants. const ( - keyValueParts = 2 // Expected parts when splitting "key: value" lacnicDateFormatLen = 8 // Length of YYYYMMDD date format ) +// The classes of the two objects that describe an AS in an answer from +// RIPE, APNIC or AFRINIC, and the key of the aut-num object's line that +// gives the ID of the AS's organisation object. +const ( + autNumClass = "aut-num" + organisationClass = "organisation" + orgKey = "org" +) + // WHOIS server addresses. const ( whoisServerIANA = "whois.iana.org:43" @@ -318,7 +326,9 @@ func (c *Client) parseIANAReferral(response string) (string, string, error) { return "", "", nil } -// parseResponse extracts ASN information from a WHOIS response. +// parseResponse extracts ASN information from a WHOIS response. Of an +// answer from RIPE, APNIC or AFRINIC it reads only the lines asObjectLines +// returns. Of an answer from ARIN or LACNIC it reads every line. func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo { info := &ASNInfo{ ASN: asn, @@ -327,26 +337,15 @@ func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo { } lines := strings.Split(response, "\n") + if rir == RIRRIPE || rir == RIRAPNIC || rir == RIRAFRNIC { + lines = asObjectLines(lines) + } var addressLines []string for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "%") || - strings.HasPrefix(line, "#") { - continue - } - - // Split on first colon - parts := strings.SplitN(line, ":", keyValueParts) - if len(parts) != keyValueParts { - continue - } - - key := strings.TrimSpace(strings.ToLower(parts[0])) - value := strings.TrimSpace(parts[1]) - - if value == "" { + key, value, ok := splitField(line) + if !ok || value == "" { continue } @@ -373,6 +372,72 @@ func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo { return info } +// splitField splits a "key: value" line of a WHOIS answer into its key, in +// lower case, and its value. The third result is false for a line that +// holds no field: a blank line, a comment line or a line without a colon. +func splitField(line string) (string, string, bool) { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") { + return "", "", false + } + + key, value, ok := strings.Cut(line, ":") + + return strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value), ok +} + +// asObjectLines returns the lines of the aut-num object in an answer from +// RIPE, APNIC or AFRINIC, followed by the lines of the organisation object +// whose ID the aut-num object gives on its "org:" line. The answer's other +// objects are left out. RIPE and APNIC start their answer with the +// as-block object of the block the AS is in, whose dates are not the AS's. +// +// These answers are in RPSL. A blank line ends an object. An object's first +// line has the object's class as its key and the object's ID as its value, +// as in "organisation: ORG-APNI1-AP". +func asObjectLines(lines []string) []string { + var autNum []string + + // The lines of each organisation object, by its ID. + organisations := make(map[string][]string) + + // The class and ID of the object the line is in, "" between objects. + var class, id string + + // The ID of the organisation object the aut-num object refers to. + var orgID string + + for _, line := range lines { + if strings.TrimSpace(line) == "" { + class = "" + + continue + } + + key, value, ok := splitField(line) + if !ok { + continue + } + + if class == "" { + class, id = key, value + } + + switch class { + case autNumClass: + autNum = append(autNum, line) + + if key == orgKey { + orgID = value + } + case organisationClass: + organisations[id] = append(organisations[id], line) + } + } + + return append(autNum, organisations[orgID]...) +} + // setResponseField stores the value of one "key: value" line of a WHOIS // response in info, keeping the first value seen for each field. func (c *Client) setResponseField(info *ASNInfo, key, value string) { diff --git a/internal/whois/whois_test.go b/internal/whois/whois_test.go index 18b1550..8284cec 100644 --- a/internal/whois/whois_test.go +++ b/internal/whois/whois_test.go @@ -51,13 +51,25 @@ 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 +// 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' @@ -119,6 +131,78 @@ 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 @@ -198,7 +282,8 @@ func lookUp( // 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. +// 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() @@ -238,6 +323,46 @@ func TestLookupASNReadsTheRIRAnswer(t *testing.T) { } } +// 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