Files
routewatch/internal/whois/whois.go
T
clawbot 73f53c8220
check / check (push) Canceled after 0s
Take Brazilian AS details from NIC.br's WHOIS server (closes #174)
LACNIC's answer for an AS that NIC.br registers is NIC.br's answer with
each accented letter replaced by U+FFFD, so a name such as AS52751's
cannot be recovered from it. When LACNIC's answer carries NIC.br's
copyright notice, the client now also asks whois.registro.br for the AS
and takes the fields from that answer, which is Latin-1 and decoded as
before. When that query fails, when the answer has no fields, or when
NIC.br says the client is over its query limit, LACNIC's fields are kept
and a warning is logged. After an over-limit answer the client skips
NIC.br for an hour. The tests use recorded answers for AS52751.

Model: opus-5-5
2026-10-27 03:59:11 +01:00

647 lines
19 KiB
Go

// Package whois provides WHOIS lookup functionality for ASN information.
package whois
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"regexp"
"strings"
"sync"
"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"
// 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"
// NICbrSkipTime is how long the client stops asking NIC.br after NIC.br
// answers that the client is over its query limit. NIC.br publishes
// neither the limit nor how long it lasts. An hour outlasts a limit
// counted per minute or per hour. The ASes looked up meanwhile get
// NIC.br's fields when they are next refreshed.
const NICbrSkipTime = time.Hour
// 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)
// Now returns the current time. NewClient sets time.Now; tests replace
// it to move the clock.
Now func() time.Time
logger *slog.Logger
// mu guards nicbrSkipUntil.
mu sync.Mutex
// nicbrSkipUntil is when the client next asks NIC.br, after NIC.br
// answered that the client is over its query limit.
nicbrSkipUntil time.Time
}
// NewClient creates a new WHOIS client that logs to logger.
func NewClient(logger *slog.Logger) *Client {
dialer := &net.Dialer{Timeout: dialTimeout}
return &Client{
DialContext: dialer.DialContext,
Now: time.Now,
logger: logger,
}
}
// LookupASN asks IANA which RIR holds the given ASN, asks that RIR, and
// 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 or
// is skipped, as askNICbr says.
//
// 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)
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 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" + 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, and logs a
// warning 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. After the reduced answer, it returns lacnicInfo without asking
// NIC.br until NICbrSkipTime has passed.
func (c *Client) askNICbr(
ctx context.Context, asQuery string, lacnicInfo *ASNInfo,
) *ASNInfo {
asn := lacnicInfo.ASN
c.mu.Lock()
skipping := c.Now().Before(c.nicbrSkipUntil)
c.mu.Unlock()
if skipping {
return lacnicInfo
}
answer, err := c.query(ctx, whoisServerNICbr, asQuery)
if err != nil {
c.logger.Warn("NIC.br query failed, keeping LACNIC's fields",
"asn", asn, "error", err)
return lacnicInfo
}
if strings.Contains(answer, nicbrLimitReached) {
c.mu.Lock()
c.nicbrSkipUntil = c.Now().Add(NICbrSkipTime)
c.mu.Unlock()
c.logger.Warn("NIC.br answered that the client is over its query "+
"limit, keeping LACNIC's fields and skipping NIC.br",
"asn", asn, "skip_for", NICbrSkipTime)
return lacnicInfo
}
info := c.parseResponse(asn, RIRLACNIC, answer)
if info.isEmpty() {
c.logger.Warn("NIC.br answered with no WHOIS fields, "+
"keeping LACNIC's fields", "asn", asn)
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 {
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,
// 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) {
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 ""
}