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
541 lines
15 KiB
Go
541 lines
15 KiB
Go
// Package whois provides WHOIS lookup functionality for ASN information.
|
|
package whois
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/text/encoding/charmap"
|
|
)
|
|
|
|
// Timeout constants for WHOIS queries.
|
|
const (
|
|
dialTimeout = 10 * time.Second
|
|
readTimeout = 30 * time.Second
|
|
writeTimeout = 5 * time.Second
|
|
)
|
|
|
|
// maxAnswerSize caps the bytes read of one WHOIS answer: a real one is a
|
|
// few KiB, so one this large is from a server that does not stop sending.
|
|
const maxAnswerSize = 1 << 20
|
|
|
|
// Parsing constants.
|
|
const (
|
|
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"
|
|
whoisServerARIN = "whois.arin.net:43"
|
|
whoisServerRIPE = "whois.ripe.net:43"
|
|
whoisServerAPNIC = "whois.apnic.net:43"
|
|
whoisServerLACNIC = "whois.lacnic.net:43"
|
|
whoisServerAFRINIC = "whois.afrinic.net:43"
|
|
)
|
|
|
|
// RIR identifiers.
|
|
const (
|
|
RIRARIN = "ARIN"
|
|
RIRRIPE = "RIPE"
|
|
RIRAPNIC = "APNIC"
|
|
RIRLACNIC = "LACNIC"
|
|
RIRAFRNIC = "AFRINIC"
|
|
)
|
|
|
|
// noPersonalFlag keeps person and role objects out of an answer. The
|
|
// organisation object stays in it. RIPE counts the person and role objects
|
|
// it returns against a daily limit per IP address. Role objects for abuse
|
|
// contacts do not count. RIPE refuses an address for good that passes the
|
|
// limit too often:
|
|
// https://docs.db.ripe.net/Types-of-Queries/Access-Control-for-Queries/
|
|
//
|
|
// RIPE documents the flag here:
|
|
// https://docs.db.ripe.net/Types-of-Queries/Referenced-Objects-in-Query-Response/
|
|
// APNIC and AFRINIC list it in their answers to the query "help". AFRINIC
|
|
// sends readers to that answer for its full list of flags:
|
|
// https://afrinic.net/whois-getting-started
|
|
const noPersonalFlag = "--no-personal"
|
|
|
|
// The error lines with which RIPE's database software refuses a query:
|
|
// access denied to the client's IP address, and that address's limit
|
|
// reached. APNIC and AFRINIC run the same software. The lines are listed
|
|
// under "Access Errors" on this page, whose URL is split over two lines:
|
|
// https://docs.db.ripe.net/Appendices/
|
|
// Appendix-C--RIPE-Database-Query-Server-Response-Codes-and-Messages/
|
|
const (
|
|
accessDeniedError = "%ERROR:201:"
|
|
accessLimitError = "%ERROR:202:"
|
|
)
|
|
|
|
// Errors LookupASN returns when a WHOIS answer has nothing to store.
|
|
var (
|
|
// ErrNoReferral is returned when IANA refers the AS to no RIR, as it
|
|
// does for a reserved, private-use or unallocated AS number.
|
|
ErrNoReferral = errors.New("IANA refers the AS to no RIR")
|
|
|
|
errNoASBlock = errors.New("IANA answered with neither a referral " +
|
|
"nor an AS block")
|
|
errNoFields = errors.New("RIR answered with no WHOIS fields")
|
|
errRefused = errors.New("RIR refused the query")
|
|
errUnknownServer = errors.New("IANA refers the AS to a server no RIR runs")
|
|
errAnswerTooLarge = errors.New("WHOIS answer reached the size cap")
|
|
)
|
|
|
|
// ASNInfo contains parsed WHOIS information for an ASN.
|
|
type ASNInfo struct {
|
|
ASN int
|
|
ASName string
|
|
OrgName string
|
|
OrgID string
|
|
Address string
|
|
CountryCode string
|
|
AbuseEmail string
|
|
AbusePhone string
|
|
TechEmail string
|
|
TechPhone string
|
|
RIR string
|
|
RegDate *time.Time
|
|
LastMod *time.Time
|
|
RawResponse string
|
|
}
|
|
|
|
// Client performs WHOIS lookups for ASNs.
|
|
type Client struct {
|
|
// DialContext opens the connection to a WHOIS server, given as
|
|
// host:port. NewClient sets a net.Dialer's; tests replace it to reach
|
|
// local servers instead.
|
|
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
|
}
|
|
|
|
// NewClient creates a new WHOIS client.
|
|
func NewClient() *Client {
|
|
dialer := &net.Dialer{Timeout: dialTimeout}
|
|
|
|
return &Client{DialContext: dialer.DialContext}
|
|
}
|
|
|
|
// 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.
|
|
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)
|
|
|
|
ianaResp, err := c.query(ctx, whoisServerIANA, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("IANA query failed: %w", err)
|
|
}
|
|
|
|
// Determine RIR from IANA response
|
|
rir, whoisServer, err := c.parseIANAReferral(ianaResp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", query, err)
|
|
}
|
|
|
|
if whoisServer == "" {
|
|
// IANA names the block of an AS it refers to no RIR. An answer
|
|
// without that, such as an empty one, says nothing about the AS.
|
|
if !strings.Contains(ianaResp, "as-block:") {
|
|
return nil, fmt.Errorf("%s: %w", query, errNoASBlock)
|
|
}
|
|
|
|
return nil, fmt.Errorf("%s: %w", query, ErrNoReferral)
|
|
}
|
|
|
|
// Query the authoritative RIR
|
|
rirResp, err := c.queryRIR(ctx, whoisServer, rirQuery(rir, query))
|
|
if err != nil {
|
|
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.
|
|
info := c.parseResponse(asn, rir, rirResp)
|
|
if info.isEmpty() {
|
|
return nil, fmt.Errorf("%s from %s: %w", query, whoisServer, errNoFields)
|
|
}
|
|
|
|
info.RawResponse = ianaResp + "\n" + rirResp
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// 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 {
|
|
return info.ASName == "" && info.OrgName == "" && info.OrgID == "" &&
|
|
info.Address == "" && info.CountryCode == "" &&
|
|
info.AbuseEmail == "" && info.AbusePhone == "" &&
|
|
info.TechEmail == "" && info.TechPhone == "" &&
|
|
info.RegDate == nil && info.LastMod == nil
|
|
}
|
|
|
|
// rirQuery returns the query sent to rir's server for asQuery, an AS
|
|
// number such as "AS3333". RIPE, APNIC and AFRINIC are asked to keep person
|
|
// and role objects out of the answer.
|
|
func rirQuery(rir, asQuery string) string {
|
|
switch rir {
|
|
case RIRRIPE, RIRAPNIC, RIRAFRNIC:
|
|
return noPersonalFlag + " " + asQuery
|
|
default:
|
|
return asQuery
|
|
}
|
|
}
|
|
|
|
// queryRIR sends query to an RIR's server and returns the answer. An answer
|
|
// holding an error line with which the server refuses the query is an
|
|
// error, and the error holds that line.
|
|
func (c *Client) queryRIR(
|
|
ctx context.Context, server, query string,
|
|
) (string, error) {
|
|
answer, err := c.query(ctx, server, query)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for line := range strings.SplitSeq(answer, "\n") {
|
|
if strings.HasPrefix(line, accessDeniedError) ||
|
|
strings.HasPrefix(line, accessLimitError) {
|
|
return "", fmt.Errorf("%w: %s", errRefused, line)
|
|
}
|
|
}
|
|
|
|
return answer, nil
|
|
}
|
|
|
|
// query performs a raw WHOIS query to the specified server and returns the
|
|
// answer in UTF-8. An answer that reaches maxAnswerSize is an error.
|
|
func (c *Client) query(ctx context.Context, server, query string) (string, error) {
|
|
conn, err := c.DialContext(ctx, "tcp", server)
|
|
if err != nil {
|
|
return "", fmt.Errorf("dial %s: %w", server, err)
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
// Set deadlines
|
|
err = conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
|
if err != nil {
|
|
return "", fmt.Errorf("set write deadline: %w", err)
|
|
}
|
|
|
|
// Send query
|
|
_, err = fmt.Fprintf(conn, "%s\r\n", query)
|
|
if err != nil {
|
|
return "", fmt.Errorf("write query: %w", err)
|
|
}
|
|
|
|
// Read response
|
|
err = conn.SetReadDeadline(time.Now().Add(readTimeout))
|
|
if err != nil {
|
|
return "", fmt.Errorf("set read deadline: %w", err)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
|
|
// The reader ends at the cap; N, the bytes it has left, is 0 once the
|
|
// answer has reached it.
|
|
answer := &io.LimitedReader{R: conn, N: maxAnswerSize}
|
|
|
|
scanner := bufio.NewScanner(answer)
|
|
for scanner.Scan() {
|
|
sb.WriteString(scanner.Text())
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
err = scanner.Err()
|
|
if err != nil {
|
|
return sb.String(), fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if answer.N == 0 {
|
|
return "", fmt.Errorf("read response: %w", errAnswerTooLarge)
|
|
}
|
|
|
|
return toUTF8(sb.String())
|
|
}
|
|
|
|
// 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:
|
|
// https://www.ripe.net/ripe/mail/archives/db-wg/2024-May/008075.html
|
|
func toUTF8(answer string) (string, error) {
|
|
if utf8.ValidString(answer) {
|
|
return answer, nil
|
|
}
|
|
|
|
decoded, err := charmap.ISO8859_1.NewDecoder().String(answer)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode Latin-1 answer: %w", err)
|
|
}
|
|
|
|
return decoded, nil
|
|
}
|
|
|
|
// parseIANAReferral extracts the RIR and WHOIS server from an IANA response.
|
|
// The server is "" when the response has no referral. A referral to a server
|
|
// that is none of the five RIRs' is an error, so a forged or broken answer
|
|
// cannot send the client to any other host or port.
|
|
func (c *Client) parseIANAReferral(response string) (string, string, error) {
|
|
for line := range strings.SplitSeq(response, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Look for "refer:" line
|
|
if strings.HasPrefix(strings.ToLower(line), "refer:") {
|
|
server := strings.TrimSpace(strings.TrimPrefix(line, "refer:"))
|
|
server = strings.TrimSpace(strings.TrimPrefix(server, "Refer:"))
|
|
|
|
switch {
|
|
case strings.Contains(server, "arin"):
|
|
return RIRARIN, whoisServerARIN, nil
|
|
case strings.Contains(server, "ripe"):
|
|
return RIRRIPE, whoisServerRIPE, nil
|
|
case strings.Contains(server, "apnic"):
|
|
return RIRAPNIC, whoisServerAPNIC, nil
|
|
case strings.Contains(server, "lacnic"):
|
|
return RIRLACNIC, whoisServerLACNIC, nil
|
|
case strings.Contains(server, "afrinic"):
|
|
return RIRAFRNIC, whoisServerAFRINIC, nil
|
|
default:
|
|
return "", "", fmt.Errorf("%w: %q", errUnknownServer, server)
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", "", nil
|
|
}
|
|
|
|
// 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,
|
|
RIR: rir,
|
|
RawResponse: response,
|
|
}
|
|
|
|
lines := strings.Split(response, "\n")
|
|
if rir == RIRRIPE || rir == RIRAPNIC || rir == RIRAFRNIC {
|
|
lines = asObjectLines(lines)
|
|
}
|
|
|
|
var addressLines []string
|
|
|
|
for _, line := range lines {
|
|
key, value, ok := splitField(line)
|
|
if !ok || value == "" {
|
|
continue
|
|
}
|
|
|
|
// Address (collect multiple lines)
|
|
if key == "address" {
|
|
addressLines = append(addressLines, value)
|
|
|
|
continue
|
|
}
|
|
|
|
c.setResponseField(info, key, value)
|
|
}
|
|
|
|
// Combine address lines
|
|
if len(addressLines) > 0 {
|
|
info.Address = strings.Join(addressLines, "\n")
|
|
}
|
|
|
|
// Extract abuse email from comment lines (common in ARIN responses)
|
|
if info.AbuseEmail == "" {
|
|
info.AbuseEmail = c.extractAbuseEmail(response)
|
|
}
|
|
|
|
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) {
|
|
switch key {
|
|
// Country
|
|
case "country":
|
|
if info.CountryCode == "" && len(value) == 2 {
|
|
info.CountryCode = strings.ToUpper(value)
|
|
}
|
|
|
|
// Registration dates
|
|
case "regdate", "created":
|
|
if info.RegDate == nil {
|
|
info.RegDate = c.parseDate(value)
|
|
}
|
|
case "updated", "last-modified", "changed":
|
|
if info.LastMod == nil {
|
|
info.LastMod = c.parseDate(value)
|
|
}
|
|
|
|
default:
|
|
field := textFieldForKey(info, key)
|
|
if field != nil && *field == "" {
|
|
*field = value
|
|
}
|
|
}
|
|
}
|
|
|
|
// textFieldForKey returns the text field of info that a WHOIS response key
|
|
// fills, or nil when the key fills none.
|
|
func textFieldForKey(info *ASNInfo, key string) *string {
|
|
switch key {
|
|
// AS Name (varies by RIR)
|
|
case "asname", "as-name":
|
|
return &info.ASName
|
|
|
|
// Organization
|
|
case "orgname", "org-name", "owner":
|
|
return &info.OrgName
|
|
case "orgid", "org-id", "org":
|
|
return &info.OrgID
|
|
|
|
// Abuse contact
|
|
case "orgabuseemail", "abuse-mailbox":
|
|
return &info.AbuseEmail
|
|
case "orgabusephone":
|
|
return &info.AbusePhone
|
|
|
|
// Tech contact
|
|
case "orgtechemail":
|
|
return &info.TechEmail
|
|
case "orgtechphone":
|
|
return &info.TechPhone
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// parseDate attempts to parse various date formats used in WHOIS responses.
|
|
func (c *Client) parseDate(value string) *time.Time {
|
|
// Common formats
|
|
formats := []string{
|
|
"2006-01-02",
|
|
"2006-01-02T15:04:05Z",
|
|
"2006-01-02T15:04:05-07:00",
|
|
"20060102",
|
|
"02-Jan-2006",
|
|
}
|
|
|
|
// Clean up value
|
|
value = strings.TrimSpace(value)
|
|
// Handle "YYYYMMDD" format from LACNIC
|
|
if len(value) == lacnicDateFormatLen {
|
|
t, err := time.Parse("20060102", value)
|
|
if err == nil {
|
|
return &t
|
|
}
|
|
}
|
|
|
|
for _, format := range formats {
|
|
t, err := time.Parse(format, value)
|
|
if err == nil {
|
|
return &t
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractAbuseEmail extracts abuse email from response using regex.
|
|
func (c *Client) extractAbuseEmail(response string) string {
|
|
// Look for "Abuse contact for 'AS...' is 'email@domain'"
|
|
re := regexp.MustCompile(`[Aa]buse contact.*?is\s+['"]?([^\s'"]+@[^\s'"]+)['"]?`)
|
|
if matches := re.FindStringSubmatch(response); len(matches) > 1 {
|
|
return matches[1]
|
|
}
|
|
|
|
return ""
|
|
}
|