// Package whois provides WHOIS lookup functionality for ASN information. package whois import ( "bufio" "context" "fmt" "net" "regexp" "strings" "time" ) // Timeout constants for WHOIS queries. const ( dialTimeout = 10 * time.Second readTimeout = 30 * time.Second writeTimeout = 5 * time.Second ) // Parsing constants. const ( keyValueParts = 2 // Expected parts when splitting "key: value" lacnicDateFormatLen = 8 // Length of YYYYMMDD date format ) // 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" ) // 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 { // Dialer for creating connections (can be overridden for testing) dialer *net.Dialer } // NewClient creates a new WHOIS client. func NewClient() *Client { return &Client{ dialer: &net.Dialer{ Timeout: dialTimeout, }, } } // LookupASN queries WHOIS for the given ASN and returns parsed information. 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 := c.parseIANAReferral(ianaResp) if whoisServer == "" { // No referral, try to parse what we have return c.parseResponse(asn, rir, ianaResp), nil } // Query the authoritative RIR rirResp, err := c.query(ctx, whoisServer, query) if err != nil { // Return partial data from IANA if RIR query fails info := c.parseResponse(asn, rir, ianaResp) info.RawResponse = ianaResp + "\n--- RIR query failed: " + err.Error() + " ---\n" return info, nil } // Combine responses and parse fullResponse := ianaResp + "\n" + rirResp info := c.parseResponse(asn, rir, fullResponse) info.RawResponse = fullResponse return info, nil } // query performs a raw WHOIS query to the specified server. func (c *Client) query(ctx context.Context, server, query string) (string, error) { conn, err := c.dialer.DialContext(ctx, "tcp", server) if err != nil { return "", fmt.Errorf("dial %s: %w", server, err) } defer func() { _ = conn.Close() }() // Set deadlines if err := conn.SetWriteDeadline(time.Now().Add(writeTimeout)); err != nil { return "", fmt.Errorf("set write deadline: %w", err) } // Send query if _, err := fmt.Fprintf(conn, "%s\r\n", query); err != nil { return "", fmt.Errorf("write query: %w", err) } // Read response if err := conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil { return "", fmt.Errorf("set read deadline: %w", err) } var sb strings.Builder scanner := bufio.NewScanner(conn) for scanner.Scan() { sb.WriteString(scanner.Text()) sb.WriteString("\n") } if err := scanner.Err(); err != nil { return sb.String(), fmt.Errorf("read response: %w", err) } return sb.String(), nil } // parseIANAReferral extracts the RIR and WHOIS server from an IANA response. func (c *Client) parseIANAReferral(response string) (rir, whoisServer string) { lines := strings.Split(response, "\n") for _, line := range lines { 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 case strings.Contains(server, "ripe"): return RIRRIPE, whoisServerRIPE case strings.Contains(server, "apnic"): return RIRAPNIC, whoisServerAPNIC case strings.Contains(server, "lacnic"): return RIRLACNIC, whoisServerLACNIC case strings.Contains(server, "afrinic"): return RIRAFRNIC, whoisServerAFRINIC default: // Unknown server, add port if missing if !strings.Contains(server, ":") { server += ":43" } return "", server } } // Also check organisation line for RIR hints if strings.HasPrefix(strings.ToLower(line), "organisation:") { org := strings.ToLower(line) switch { case strings.Contains(org, "arin"): rir = RIRARIN case strings.Contains(org, "ripe"): rir = RIRRIPE case strings.Contains(org, "apnic"): rir = RIRAPNIC case strings.Contains(org, "lacnic"): rir = RIRLACNIC case strings.Contains(org, "afrinic"): rir = RIRAFRNIC } } } return rir, "" } // parseResponse extracts ASN information from a WHOIS response. func (c *Client) parseResponse(asn int, rir, response string) *ASNInfo { info := &ASNInfo{ ASN: asn, RIR: rir, RawResponse: response, } lines := strings.Split(response, "\n") 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 == "" { continue } switch key { // AS Name (varies by RIR) case "asname", "as-name": if info.ASName == "" { info.ASName = value } // Organization case "orgname", "org-name", "owner": if info.OrgName == "" { info.OrgName = value } case "orgid", "org-id", "org": if info.OrgID == "" { info.OrgID = value } // Address (collect multiple lines) case "address": addressLines = append(addressLines, value) // Country case "country": if info.CountryCode == "" && len(value) == 2 { info.CountryCode = strings.ToUpper(value) } // Abuse contact case "orgabuseemail", "abuse-mailbox": if info.AbuseEmail == "" { info.AbuseEmail = value } case "orgabusephone": if info.AbusePhone == "" { info.AbusePhone = value } // Tech contact case "orgtechemail": if info.TechEmail == "" { info.TechEmail = value } case "orgtechphone": if info.TechPhone == "" { info.TechPhone = 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) } } } // 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 } // 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 { if _, err := time.Parse("20060102", value); err == nil { t, _ := time.Parse("20060102", value) return &t } } for _, format := range formats { if t, err := time.Parse(format, value); 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 "" }