Files
Beszel/internal/hub/domains/whois/lookup.go
T
Tomas Dvorak b6f40af67f
Build Docker images / Hub (push) Failing after 52s
feat(hub): improve WHOIS lookup reliability and enhance site UI
Implement enhanced WHOIS lookup strategies, specifically targeting .eu
domains through EURid web scraping and alternative services to
improve data accuracy for expiry dates.

- Add EURid web scraping and alternative WHOIS service support for .eu domains
- Increase timeouts for .eu domain lookups in TCP and native WHOIS
- Improve domain scheduler to prevent overwriting valid data with zero-value dates
- Enhance site UI with subdomain indicators in domain tables
- Add filtering capabilities to the calendar view
- Implement drag-and-drop reordering for systems table
- Add new debug and test utilities for WHOIS and date parsing logic
2026-05-08 11:07:34 +02:00

1360 lines
37 KiB
Go

package whois
import (
"context"
"crypto/ecdsa"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os/exec"
"regexp"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/domain"
)
// LookupService handles WHOIS lookups with multiple fallback methods
type LookupService struct {
whoisXMLAPIKey string
rdapCache map[string]string
}
// NewLookupService creates a new WHOIS lookup service
func NewLookupService(apiKey string) *LookupService {
return &LookupService{
whoisXMLAPIKey: apiKey,
rdapCache: make(map[string]string),
}
}
// LookupDomain performs a comprehensive domain lookup (WHOIS, DNS, SSL, Host)
func (s *LookupService) LookupDomain(ctx context.Context, domainName string) (*domain.Domain, error) {
// Clean domain name
domainName = cleanDomain(domainName)
// Initialize domain struct
d := &domain.Domain{
DomainName: domainName,
Active: true,
AlertDaysBefore: 30, // Default: alert 30 days before expiry
Tags: []string{},
NameServers: []string{},
MXRecords: []string{},
TXTRecords: []string{},
IPv4Addresses: []string{},
IPv6Addresses: []string{},
}
// Perform WHOIS lookup
whoisData, err := s.LookupWHOIS(ctx, domainName)
if err == nil && whoisData != nil {
s.applyWHOISData(d, whoisData)
}
// Perform DNS lookups
s.lookupDNS(ctx, domainName, d)
// Perform SSL lookup
s.lookupSSL(ctx, domainName, d)
// Perform host lookup (using first IPv4)
if len(d.IPv4Addresses) > 0 {
s.lookupHost(d.IPv4Addresses[0], d)
}
// Fetch favicon
d.FaviconURL = fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", domainName)
d.LastChecked = time.Now()
return d, nil
}
// LookupWHOIS performs WHOIS lookup with multiple fallback methods
func (s *LookupService) LookupWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
var lastErr error
// Try RDAP first
data, err := s.tryRDAP(ctx, domainName)
if err == nil && data != nil && hasValidData(data) {
return data, nil
}
if err != nil {
lastErr = err
}
// Try TCP WHOIS (this should work for .eu domains)
data, err = s.tryTCPWHOIS(ctx, domainName)
if err == nil && data != nil && hasValidData(data) {
return data, nil
}
if err != nil {
lastErr = err
}
// Try native whois command (often works when TCP fails)
data, err = s.tryNativeWHOIS(ctx, domainName)
if err == nil && data != nil && hasValidData(data) {
return data, nil
}
if err != nil {
lastErr = err
}
// Try EURid web scraping for .eu domains to get expiry dates
parts := strings.Split(domainName, ".")
if len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" {
data, err = s.tryEURidWebScraping(ctx, domainName)
if err == nil && data != nil && hasValidData(data) {
return data, nil
}
if err != nil {
lastErr = err
}
// Try alternative WHOIS services for .eu domains
data, err = s.tryAlternativeWHOIS(ctx, domainName)
if err == nil && data != nil && hasValidData(data) {
return data, nil
}
if err != nil {
lastErr = err
}
}
// Try WhoisXML API if key is configured (this can provide expiry dates for .eu domains)
if s.whoisXMLAPIKey != "" {
data, err = s.tryWhoisXML(ctx, domainName)
if err == nil && data != nil {
return data, nil
}
}
return nil, fmt.Errorf("all WHOIS lookup methods failed for %s: %w", domainName, lastErr)
}
// tryRDAP attempts RDAP lookup
func (s *LookupService) tryRDAP(ctx context.Context, domainName string) (*domain.WHOISData, error) {
// Get TLD
parts := strings.Split(domainName, ".")
if len(parts) < 2 {
return nil, fmt.Errorf("invalid domain format")
}
tld := parts[len(parts)-1]
// Get RDAP base URL
baseURL, err := s.getRDAPBaseURL(ctx, tld)
if err != nil {
return nil, err
}
// Make RDAP request
url := fmt.Sprintf("%s/domain/%s", baseURL, domainName)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/rdap+json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("RDAP returned status %d", resp.StatusCode)
}
var rdapResp struct {
LdhName string `json:"ldhName"`
Handle string `json:"handle"`
Status []string `json:"status"`
Events []struct {
EventAction string `json:"eventAction"`
EventDate string `json:"eventDate"`
} `json:"events"`
Entities []struct {
Roles []string `json:"roles"`
PublicIds []struct {
Type string `json:"type"`
Identifier string `json:"identifier"`
} `json:"publicIds"`
VCardArray []interface{} `json:"vcardArray"`
} `json:"entities"`
SecureDNS struct {
ZoneSigned bool `json:"zoneSigned"`
} `json:"secureDNS"`
}
if err := json.NewDecoder(resp.Body).Decode(&rdapResp); err != nil {
return nil, err
}
// Parse events
var creationDate, expiryDate, updatedDate *time.Time
for _, event := range rdapResp.Events {
t, err := time.Parse(time.RFC3339, event.EventDate)
if err != nil || t.IsZero() {
continue
}
switch event.EventAction {
case "registration":
creationDate = &t
case "expiration":
expiryDate = &t
case "last changed":
updatedDate = &t
}
}
// Find registrar
var registrarName, registrarID string
for _, entity := range rdapResp.Entities {
for _, role := range entity.Roles {
if role == "registrar" {
// Try to get name from vCard
if len(entity.VCardArray) > 1 {
if vcard, ok := entity.VCardArray[1].([]interface{}); ok {
for _, item := range vcard {
if arr, ok := item.([]interface{}); ok && len(arr) >= 4 {
if arr[0] == "fn" {
if name, ok := arr[3].(string); ok {
registrarName = name
}
}
}
}
}
}
// Get IANA ID
for _, pid := range entity.PublicIds {
if pid.Type == "IANA Registrar ID" {
registrarID = pid.Identifier
}
}
}
}
}
dnssec := ""
if rdapResp.SecureDNS.ZoneSigned {
dnssec = "signed"
}
return &domain.WHOISData{
DomainName: rdapResp.LdhName,
Status: rdapResp.Status,
DNSSEC: dnssec,
Dates: domain.WHOISDates{
ExpiryDate: expiryDate,
CreationDate: creationDate,
UpdatedDate: updatedDate,
},
Registrar: domain.WHOISRegistrar{
Name: registrarName,
ID: registrarID,
URL: "",
RegistryDomainID: rdapResp.Handle,
},
}, nil
}
// tryNativeWHOIS tries the native whois command
func (s *LookupService) tryNativeWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
// Check if whois command exists
_, err := exec.LookPath("whois")
if err != nil {
return nil, fmt.Errorf("whois command not found")
}
// Use longer timeout for .eu domains
timeout := 10 * time.Second
parts := strings.Split(domainName, ".")
if len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu" {
timeout = 20 * time.Second
}
// Execute whois with timeout
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "whois", domainName)
output, err := cmd.Output()
if err != nil {
return nil, err
}
return s.parseWHOISOutput(string(output), domainName)
}
// whoisServers maps common TLDs to their WHOIS servers
var whoisServers = map[string]string{
"com": "whois.verisign-grs.com",
"net": "whois.verisign-grs.com",
"org": "whois.pir.org",
"io": "whois.nic.io",
"co": "whois.nic.co",
"dev": "whois.nic.google",
"app": "whois.nic.google",
"xyz": "whois.nic.xyz",
"info": "whois.afilias.net",
"biz": "whois.biz",
"us": "whois.nic.us",
"uk": "whois.nic.uk",
"de": "whois.denic.de",
"fr": "whois.nic.fr",
"eu": "whois.eu",
"nl": "whois.domain-registry.nl",
"ca": "whois.cira.ca",
"au": "whois.auda.org.au",
"me": "whois.nic.me",
"tv": "whois.nic.tv",
"cc": "whois.nic.cc",
"ws": "whois.website.ws",
"name": "whois.nic.name",
"mobi": "whois.dotmobiregistry.net",
"asia": "whois.nic.asia",
"pro": "whois.nic.pro",
"jobs": "whois.nic.jobs",
"travel": "whois.nic.travel",
}
// tryTCPWHOIS performs WHOIS lookup via direct TCP connection (port 43)
func (s *LookupService) tryTCPWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
parts := strings.Split(domainName, ".")
if len(parts) < 2 {
return nil, fmt.Errorf("invalid domain format")
}
tld := strings.ToLower(parts[len(parts)-1])
server, ok := whoisServers[tld]
if !ok {
// Fallback to IANA for unknown TLDs
server = "whois.iana.org"
}
addr := net.JoinHostPort(server, "43")
// Use longer timeout for .eu domains as they can be slow
timeout := 10 * time.Second
if tld == "eu" {
timeout = 20 * time.Second
}
dialer := &net.Dialer{Timeout: timeout}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("tcp whois dial failed: %w", err)
}
defer conn.Close()
// Some servers require the domain followed by \r\n
query := domainName + "\r\n"
if _, err := conn.Write([]byte(query)); err != nil {
return nil, fmt.Errorf("tcp whois write failed: %w", err)
}
// Read response with deadline
if err := conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
return nil, err
}
var output strings.Builder
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
if n > 0 {
output.Write(buf[:n])
}
if err != nil {
break
}
}
return s.parseWHOISOutput(output.String(), domainName)
}
// tryWhoisXML tries the WhoisXML API
func (s *LookupService) tryWhoisXML(ctx context.Context, domainName string) (*domain.WHOISData, error) {
if s.whoisXMLAPIKey == "" {
return nil, fmt.Errorf("no API key configured")
}
url := fmt.Sprintf(
"https://www.whoisxmlapi.com/whoisserver/WhoisService?apiKey=%s&outputFormat=json&domainName=%s",
s.whoisXMLAPIKey, domainName,
)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("WhoisXML API returned %d", resp.StatusCode)
}
var result struct {
WhoisRecord struct {
DomainName string `json:"domainName"`
RegistrarName string `json:"registrarName"`
RegistrarIANAID string `json:"registrarIANAID"`
RegistryData struct {
Status string `json:"status"`
CreatedDateNormalized string `json:"createdDateNormalized"`
ExpiresDateNormalized string `json:"expiresDateNormalized"`
UpdatedDateNormalized string `json:"updatedDateNormalized"`
WhoisServer string `json:"whoisServer"`
} `json:"registryData"`
} `json:"WhoisRecord"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
record := result.WhoisRecord
registry := record.RegistryData
creationDate, _ := time.Parse("2006-01-02", registry.CreatedDateNormalized)
expiryDate, _ := time.Parse("2006-01-02", registry.ExpiresDateNormalized)
updatedDate, _ := time.Parse("2006-01-02", registry.UpdatedDateNormalized)
return &domain.WHOISData{
DomainName: record.DomainName,
Status: strings.Split(registry.Status, ", "),
Dates: domain.WHOISDates{
ExpiryDate: &expiryDate,
CreationDate: &creationDate,
UpdatedDate: &updatedDate,
},
Registrar: domain.WHOISRegistrar{
Name: record.RegistrarName,
ID: record.RegistrarIANAID,
URL: fmt.Sprintf("https://%s", registry.WhoisServer),
},
}, nil
}
// parseEUWHOIS parses .eu domain WHOIS output which has a unique format
func (s *LookupService) parseEUWHOIS(output, domainName string) (*domain.WHOISData, error) {
lines := strings.Split(output, "\n")
var registrarName, organization string
var statuses []string
// Parse the .eu specific format
currentSection := ""
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip comments and empty lines
if line == "" || strings.HasPrefix(line, "%") {
continue
}
// Track sections
if strings.HasPrefix(line, "Registrant:") {
currentSection = "registrant"
continue
}
if strings.HasPrefix(line, "Technical:") {
currentSection = "technical"
continue
}
if strings.HasPrefix(line, "Registrar:") {
currentSection = "registrar"
continue
}
if strings.HasPrefix(line, "Name servers:") {
currentSection = "nameservers"
continue
}
// Parse based on current section
if idx := strings.Index(line, ":"); idx > 0 {
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
switch currentSection {
case "registrar":
if strings.TrimSpace(key) == "Name" {
registrarName = value
}
if strings.TrimSpace(key) == "Website" {
// Could extract website URL if needed
}
case "technical":
if strings.TrimSpace(key) == "Organisation" {
organization = value
}
}
}
}
// For .eu domains, we often don't get expiry dates, so we'll return what we have
return &domain.WHOISData{
DomainName: domainName,
Status: statuses,
DNSSEC: "", // .eu WHOIS doesn't provide DNSSEC info
Dates: domain.WHOISDates{
ExpiryDate: nil, // .eu domains don't show expiry in TCP WHOIS
CreationDate: nil,
UpdatedDate: nil,
},
Registrar: domain.WHOISRegistrar{
Name: registrarName,
ID: "",
URL: "",
},
Registrant: domain.WHOISContact{
Name: "NOT DISCLOSED",
Organization: organization,
},
}, nil
}
// tryEURidWebScraping attempts to scrape EURid's web WHOIS for .eu domains
func (s *LookupService) tryEURidWebScraping(ctx context.Context, domainName string) (*domain.WHOISData, error) {
// Try multiple EURid endpoints
endpoints := []string{
fmt.Sprintf("https://whois.eurid.eu/en/?q=%s", domainName),
fmt.Sprintf("https://whois.eurid.eu/en/search?q=%s", domainName),
fmt.Sprintf("https://www.eurid.eu/en/whois/?domain=%s", domainName),
}
for _, url := range endpoints {
data, err := s.tryEURidEndpoint(ctx, url, domainName)
if err == nil && data != nil {
return data, nil
}
}
return nil, fmt.Errorf("all EURid web scraping attempts failed for %s", domainName)
}
// tryEURidEndpoint attempts to scrape a specific EURid endpoint
func (s *LookupService) tryEURidEndpoint(ctx context.Context, url, domainName string) (*domain.WHOISData, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Use more realistic browser headers
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Pragma", "no-cache")
req.Header.Set("Sec-Ch-Ua", "\"Not_A Brand\";v=\"8\", \"Chromium\";v=\"120\", \"Google Chrome\";v=\"120\"")
req.Header.Set("Sec-Ch-Ua-Mobile", "?0")
req.Header.Set("Sec-Ch-Ua-Platform", "\"Windows\"")
req.Header.Set("Sec-Fetch-Dest", "document")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-Site", "none")
req.Header.Set("Sec-Fetch-User", "?1")
req.Header.Set("Upgrade-Insecure-Requests", "1")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch EURid web page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("EURid web page returned status %d", resp.StatusCode)
}
// Read the HTML response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read EURid response: %w", err)
}
// Parse the HTML to extract expiry date
return s.parseEURidWebHTML(string(body), domainName)
}
// tryAlternativeWHOIS tries alternative WHOIS services for .eu domains
func (s *LookupService) tryAlternativeWHOIS(ctx context.Context, domainName string) (*domain.WHOISData, error) {
// Try multiple alternative WHOIS services
services := []struct {
name string
url string
}{
{"whois.com", fmt.Sprintf("https://www.whois.com/whois/%s", domainName)},
{"who.is", fmt.Sprintf("https://who.is/whois/%s", domainName)},
{"ip2location.com", fmt.Sprintf("https://www.ip2location.com/whois/%s", domainName)},
}
for _, service := range services {
data, err := s.tryAlternativeWHOISService(ctx, service.name, service.url, domainName)
if err == nil && data != nil {
return data, nil
}
}
return nil, fmt.Errorf("all alternative WHOIS services failed for %s", domainName)
}
// tryAlternativeWHOISService attempts to fetch WHOIS data from an alternative service
func (s *LookupService) tryAlternativeWHOISService(ctx context.Context, serviceName, url, domainName string) (*domain.WHOISData, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request for %s: %w", serviceName, err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch %s: %w", serviceName, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%s returned status %d", serviceName, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read %s response: %w", serviceName, err)
}
return s.parseAlternativeWHOISHTML(string(body), domainName, serviceName)
}
// parseAlternativeWHOISHTML parses HTML from alternative WHOIS services
func (s *LookupService) parseAlternativeWHOISHTML(html, domainName, serviceName string) (*domain.WHOISData, error) {
var expiryDate, registrarName, status string
// Look for expiry date patterns (common across WHOIS services)
expiryPatterns := []string{
`Expiry Date:\s*</[^>]*>\s*([^<\n]+)`,
`Expiry Date:</[^>]*>\s*([^<\n]+)`,
`Expires on:\s*</[^>]*>\s*([^<\n]+)`,
`Expires:\s*</[^>]*>\s*([^<\n]+)`,
`"expiry":"([^"]+)"`,
`"expires":"([^"]+)"`,
`data-expiry="([^"]+)"`,
`\d{4}-\d{2}-\d{2}`, // ISO date pattern
`\d{2}/\d{2}/\d{4}`, // DD/MM/YYYY pattern
}
for _, pattern := range expiryPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
expiryDate = strings.TrimSpace(matches[1])
break
}
}
// Look for registrar name
registrarPatterns := []string{
`Registrar:\s*</[^>]*>\s*([^<\n]+)`,
`Registrar:</[^>]*>\s*([^<\n]+)`,
`Registered through:\s*</[^>]*>\s*([^<\n]+)`,
`"registrar":"([^"]+)"`,
`data-registrar="([^"]+)"`,
}
for _, pattern := range registrarPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
registrarName = strings.TrimSpace(matches[1])
break
}
}
// Look for status
statusPatterns := []string{
`Status:\s*</[^>]*>\s*([^<\n]+)`,
`Status:</[^>]*>\s*([^<\n]+)`,
`"status":"([^"]+)"`,
`data-status="([^"]+)"`,
}
for _, pattern := range statusPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
status = strings.TrimSpace(matches[1])
break
}
}
// Parse expiry date if found
var parsedExpiry *time.Time
if expiryDate != "" {
// Try different date formats
dateFormats := []string{
"2006-01-02", // ISO
"02/01/2006", // DD/MM/YYYY
"01/02/2006", // MM/DD/YYYY
"2006-01-02T15:04:05Z", // ISO with time
}
for _, format := range dateFormats {
if parsed, err := time.Parse(format, expiryDate); err == nil {
parsedExpiry = &parsed
break
}
}
}
// Create WHOIS data structure
var statuses []string
if status != "" {
statuses = []string{status}
}
return &domain.WHOISData{
DomainName: domainName,
Status: statuses,
DNSSEC: "",
Dates: domain.WHOISDates{
ExpiryDate: parsedExpiry,
CreationDate: nil,
UpdatedDate: nil,
},
Registrar: domain.WHOISRegistrar{
Name: registrarName,
ID: "",
URL: "",
},
Registrant: domain.WHOISContact{
Name: "NOT DISCLOSED",
Organization: "",
},
}, nil
}
// parseEURidWebHTML parses EURid's web WHOIS HTML to extract domain information
func (s *LookupService) parseEURidWebHTML(html, domainName string) (*domain.WHOISData, error) {
// This is a simplified HTML parser - in production, you'd want to use a proper HTML parser
// For now, we'll use regex to find key information
var expiryDate, registrarName, status string
// Look for expiry date patterns in EURid's HTML
expiryPatterns := []string{
`Expiry date:\s*</strong>\s*(\d{2}/\d{2}/\d{4})`,
`Expiry date:</strong>\s*(\d{2}/\d{2}/\d{4})`,
`Expiry date</strong>\s*(\d{2}/\d{2}/\d{4})`,
`"expiryDate":"([^"]+)"`,
`data-expiry="([^"]+)"`,
}
for _, pattern := range expiryPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
expiryDate = matches[1]
break
}
}
// Look for registrar name
registrarPatterns := []string{
`Registrar:\s*</strong>\s*([^<\n]+)`,
`Registrar:</strong>\s*([^<\n]+)`,
`Registrar</strong>\s*([^<\n]+)`,
`"registrar":"([^"]+)"`,
`data-registrar="([^"]+)"`,
}
for _, pattern := range registrarPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
registrarName = strings.TrimSpace(matches[1])
break
}
}
// Look for status
statusPatterns := []string{
`Status:\s*</strong>\s*([^<\n]+)`,
`Status:</strong>\s*([^<\n]+)`,
`Status</strong>\s*([^<\n]+)`,
`"status":"([^"]+)"`,
`data-status="([^"]+)"`,
}
for _, pattern := range statusPatterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
status = strings.TrimSpace(matches[1])
break
}
}
// Parse expiry date if found
var parsedExpiry *time.Time
if expiryDate != "" {
// Try DD/MM/YYYY format first
if parsed, err := time.Parse("02/01/2006", expiryDate); err == nil {
parsedExpiry = &parsed
} else if parsed, err := time.Parse("2006-01-02", expiryDate); err == nil {
parsedExpiry = &parsed
}
}
// Create WHOIS data structure
var statuses []string
if status != "" {
statuses = []string{status}
}
return &domain.WHOISData{
DomainName: domainName,
Status: statuses,
DNSSEC: "",
Dates: domain.WHOISDates{
ExpiryDate: parsedExpiry,
CreationDate: nil,
UpdatedDate: nil,
},
Registrar: domain.WHOISRegistrar{
Name: registrarName,
ID: "",
URL: "",
},
Registrant: domain.WHOISContact{
Name: "NOT DISCLOSED",
Organization: "",
},
}, nil
}
// parseWHOISOutput parses the raw WHOIS text output
func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHOISData, error) {
lines := strings.Split(output, "\n")
data := make(map[string]string)
// Special handling for .eu domains which have a different format
parts := strings.Split(domainName, ".")
isEUDomain := len(parts) >= 2 && strings.ToLower(parts[len(parts)-1]) == "eu"
if isEUDomain {
return s.parseEUWHOIS(output, domainName)
}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "%") {
continue
}
// Parse "Key: Value" format
if idx := strings.Index(line, ":"); idx > 0 {
key := strings.ToLower(strings.TrimSpace(line[:idx]))
value := strings.TrimSpace(line[idx+1:])
// Normalize key
key = strings.ReplaceAll(key, " ", "_")
key = strings.ReplaceAll(key, "/", "_")
if value != "" && !strings.HasPrefix(value, "REDACTED") {
data[key] = value
}
}
}
// Extract dates - try many field name variations used by different registries
expiryDate := s.parseDate(
data["registry_expiry_date"],
data["registrar_registration_expiration_date"],
data["expiry_date"],
data["expiration_time"],
data["expire"],
data["paid_until"],
data["expire_date"],
data["renewal_date"],
data["valid_until"],
)
creationDate := s.parseDate(
data["creation_date"],
data["created_date"],
data["registration_time"],
data["registered_on"],
data["domain_registered"],
)
updatedDate := s.parseDate(
data["updated_date"],
data["last_updated"],
data["last_modified"],
data["modified_date"],
)
// Extract registrar - try multiple field names used by different WHOIS servers
// .eu domains use "Name:" under Registrar section
registrarName := data["registrar"]
if registrarName == "" {
registrarName = data["registrar_name"]
}
if registrarName == "" {
registrarName = data["sponsoring_registrar"]
}
if registrarName == "" {
registrarName = data["registrar_organization"]
}
if registrarName == "" {
registrarName = data["registrant_organization"]
}
if registrarName == "" {
registrarName = data["registrar_url"]
}
if registrarName == "" {
registrarName = data["registrar_abuse_contact_email"]
}
if registrarName == "" {
registrarName = "Unknown"
}
// Parse status
statusStr := data["domain_status"]
var statuses []string
if statusStr != "" {
statuses = s.parseStatus(statusStr)
}
// Extract registrant contact info
registrant := domain.WHOISContact{
Name: data["registrant_name"],
Organization: data["registrant_organization"],
Street: data["registrant_street"],
City: data["registrant_city"],
State: data["registrant_state_province"],
Country: data["registrant_country"],
PostalCode: data["registrant_postal_code"],
}
// Try alternate field names for registrant (.eu uses "holder", other variations)
if registrant.Name == "" {
registrant.Name = data["registrant"]
}
if registrant.Name == "" {
registrant.Name = data["holder"]
}
if registrant.Name == "" {
registrant.Name = data["domain_holder"]
}
if registrant.Organization == "" {
registrant.Organization = data["org"]
}
if registrant.Organization == "" {
registrant.Organization = data["organization"]
}
if registrant.Organization == "" {
registrant.Organization = data["holder_org"]
}
if registrant.Country == "" {
registrant.Country = data["country"]
}
if registrant.Country == "" {
registrant.Country = data["holder_country"]
}
// Parse DNSSEC more thoroughly
dnssec := data["dnssec"]
if dnssec == "" {
// Try alternate field names
dnssec = data["dnssec_signed"]
}
if dnssec == "" {
dnssec = data["signed_dnssec"]
}
// Normalize DNSSEC value
dnssec = strings.ToLower(strings.TrimSpace(dnssec))
if dnssec == "signed" || dnssec == "yes" || dnssec == "true" {
dnssec = "signed"
} else if dnssec == "unsigned" || dnssec == "no" || dnssec == "false" {
dnssec = "unsigned"
}
return &domain.WHOISData{
DomainName: domainName,
Status: statuses,
DNSSEC: dnssec,
Dates: domain.WHOISDates{
ExpiryDate: expiryDate,
CreationDate: creationDate,
UpdatedDate: updatedDate,
},
Registrar: domain.WHOISRegistrar{
Name: registrarName,
ID: data["registrar_iana_id"],
URL: data["registrar_url"],
RegistryDomainID: data["registry_domain_id"],
},
Registrant: registrant,
Abuse: domain.WHOISAbuse{
Email: data["registrar_abuse_contact_email"],
Phone: data["registrar_abuse_contact_phone"],
},
}, nil
}
// parseDate attempts to parse a date from multiple possible formats
func (s *LookupService) parseDate(dates ...string) *time.Time {
formats := []string{
// Standard ISO formats
"2006-01-02",
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05-07:00",
"2006-01-02 15:04:05",
"2006-01-02 15:04:05.0",
// US formats
"01/02/2006",
"01/02/2006 15:04:05",
// European formats
"02/01/2006",
"02.01.2006",
// Verbal formats
"Jan 2 2006",
"January 2 2006",
"2 Jan 2006",
"2 January 2006",
"Jan 02 2006",
"02-Jan-2006",
"2-Jan-2006",
// With timezone names
"2006-01-02 15:04:05 MST",
"2006-01-02 15:04:05 UTC",
// Common registrar formats
"Monday, January 2, 2006",
"Mon, 02 Jan 2006 15:04:05 MST",
"Mon, 2 Jan 2006 15:04:05 MST",
// Additional formats
"20060102",
"20060102150405",
}
for _, dateStr := range dates {
if dateStr == "" || dateStr == "REDACTED" || strings.Contains(dateStr, "REDACTED") {
continue
}
dateStr = strings.TrimSpace(dateStr)
// Remove common prefixes/suffixes that don't help
dateStr = strings.TrimPrefix(dateStr, "before ")
dateStr = strings.TrimPrefix(dateStr, "after ")
for _, format := range formats {
if t, err := time.Parse(format, dateStr); err == nil {
return &t
}
}
}
return nil
}
// parseStatus parses domain status from WHOIS output
func (s *LookupService) parseStatus(statusStr string) []string {
knownStatuses := []string{
"clientDeleteProhibited", "clientHold", "clientRenewProhibited",
"clientTransferProhibited", "clientUpdateProhibited",
"serverDeleteProhibited", "serverHold", "serverRenewProhibited",
"serverTransferProhibited", "serverUpdateProhibited",
"inactive", "ok", "pendingCreate", "pendingDelete", "pendingRenew",
"pendingRestore", "pendingTransfer", "pendingUpdate",
"addPeriod", "autoRenewPeriod", "renewPeriod", "transferPeriod",
}
statusStr = strings.ToLower(statusStr)
var matches []string
for _, status := range knownStatuses {
if strings.Contains(statusStr, strings.ToLower(status)) {
matches = append(matches, status)
}
}
return matches
}
// getRDAPBaseURL gets the RDAP base URL for a TLD
func (s *LookupService) getRDAPBaseURL(ctx context.Context, tld string) (string, error) {
// Check cache
if url, ok := s.rdapCache[tld]; ok {
return url, nil
}
// Fetch IANA RDAP bootstrap
url := "https://data.iana.org/rdap/dns.json"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var bootstrap struct {
Services [][]interface{} `json:"services"`
}
if err := json.NewDecoder(resp.Body).Decode(&bootstrap); err != nil {
return "", err
}
// Populate cache and find URL for this TLD
for _, service := range bootstrap.Services {
if len(service) >= 2 {
tlds, ok1 := service[0].([]interface{})
urls, ok2 := service[1].([]interface{})
if ok1 && ok2 && len(urls) > 0 {
if urlStr, ok := urls[0].(string); ok {
for _, t := range tlds {
if tldStr, ok := t.(string); ok {
s.rdapCache[tldStr] = strings.TrimSuffix(urlStr, "/")
}
}
}
}
}
}
if url, ok := s.rdapCache[tld]; ok {
return url, nil
}
return "", fmt.Errorf("no RDAP server found for TLD .%s", tld)
}
// lookupDNS performs DNS lookups
func (s *LookupService) lookupDNS(ctx context.Context, domainName string, d *domain.Domain) {
// NS records
nsRecords, _ := net.LookupNS(domainName)
for _, ns := range nsRecords {
d.NameServers = append(d.NameServers, ns.Host)
}
// MX records
mxRecords, _ := net.LookupMX(domainName)
for _, mx := range mxRecords {
d.MXRecords = append(d.MXRecords, fmt.Sprintf("%s (priority: %d)", mx.Host, mx.Pref))
}
// TXT records
txtRecords, _ := net.LookupTXT(domainName)
d.TXTRecords = txtRecords
// CNAME record
cname, err := net.LookupCNAME(domainName)
if err == nil && cname != domainName && cname != "" {
d.CNAMERecord = cname
}
// SRV records (common services)
srvServices := []string{"sip", "xmpp-server", "ldap", "autodiscover", "imap", "smtp", "caldavs", "carddavs"}
srvProtos := []string{"tcp", "udp", "tls"}
for _, service := range srvServices {
for _, proto := range srvProtos {
_, addrs, err := net.LookupSRV(service, proto, domainName)
if err == nil {
for _, addr := range addrs {
d.SRVRecords = append(d.SRVRecords, fmt.Sprintf("_%s._%s %s:%d (priority: %d, weight: %d)", service, proto, addr.Target, addr.Port, addr.Priority, addr.Weight))
}
}
}
}
// IPv4
ipv4Addrs, _ := net.LookupHost(domainName)
for _, ip := range ipv4Addrs {
if strings.Contains(ip, ".") {
d.IPv4Addresses = append(d.IPv4Addresses, ip)
}
}
// IPv6
ipv6Addrs, _ := net.LookupIP(domainName)
for _, ip := range ipv6Addrs {
if ip.To4() == nil {
d.IPv6Addresses = append(d.IPv6Addresses, ip.String())
}
}
}
// lookupSSL fetches SSL certificate info
func (s *LookupService) lookupSSL(ctx context.Context, domainName string, d *domain.Domain) {
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", domainName+":443", &tls.Config{
ServerName: domainName,
InsecureSkipVerify: true,
})
if err != nil {
return
}
defer conn.Close()
cert := conn.ConnectionState().PeerCertificates[0]
if cert != nil {
if len(cert.Issuer.Organization) > 0 {
d.SSLIssuer = cert.Issuer.Organization[0]
}
if len(cert.Issuer.Country) > 0 {
d.SSLIssuerCountry = cert.Issuer.Country[0]
}
d.SSLValidFrom = &cert.NotBefore
d.SSLValidTo = &cert.NotAfter
d.SSLSubject = cert.Subject.CommonName
fingerprint := sha256.Sum256(cert.Raw)
d.SSLFingerprint = strings.ToUpper(strings.Join(splitHex(hex.EncodeToString(fingerprint[:])), ":"))
d.SSLSignatureAlgo = cert.SignatureAlgorithm.String()
switch key := cert.PublicKey.(type) {
case *rsa.PublicKey:
d.SSLKeySize = key.N.BitLen()
case *ecdsa.PublicKey:
d.SSLKeySize = key.Curve.Params().BitSize
default:
d.SSLKeySize = 0
}
}
}
func splitHex(value string) []string {
parts := make([]string, 0, len(value)/2)
for i := 0; i < len(value); i += 2 {
end := i + 2
if end > len(value) {
end = len(value)
}
parts = append(parts, value[i:end])
}
return parts
}
// lookupHost fetches host/geolocation info
func (s *LookupService) lookupHost(ip string, d *domain.Domain) {
// Use ip-api.com (free, no auth required for non-commercial use)
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,country,regionName,city,lat,lon,isp,org,as", ip)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
var result struct {
Status string `json:"status"`
Message string `json:"message"`
Country string `json:"country"`
Region string `json:"regionName"`
City string `json:"city"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
ISP string `json:"isp"`
Org string `json:"org"`
AS string `json:"as"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return
}
if result.Status == "success" {
d.HostCountry = result.Country
d.HostRegion = result.Region
d.HostCity = result.City
d.HostLat = result.Lat
d.HostLon = result.Lon
d.HostISP = result.ISP
d.HostOrg = result.Org
d.HostAS = result.AS
}
}
// applyWHOISData applies WHOIS data to domain struct
func (s *LookupService) applyWHOISData(d *domain.Domain, whois *domain.WHOISData) {
d.DomainName = whois.DomainName
d.Status = strings.Join(whois.Status, ", ")
d.DNSSEC = whois.DNSSEC
d.ExpiryDate = whois.Dates.ExpiryDate
d.CreationDate = whois.Dates.CreationDate
d.UpdatedDate = whois.Dates.UpdatedDate
d.RegistrarName = whois.Registrar.Name
d.RegistrarID = whois.Registrar.ID
d.RegistrarURL = whois.Registrar.URL
d.RegistryDomainID = whois.Registrar.RegistryDomainID
// Apply registrant contact info if available
if whois.Registrant.Name != "" || whois.Registrant.Organization != "" {
d.RegistrantName = whois.Registrant.Name
d.RegistrantOrg = whois.Registrant.Organization
d.RegistrantStreet = whois.Registrant.Street
d.RegistrantCity = whois.Registrant.City
d.RegistrantState = whois.Registrant.State
d.RegistrantCountry = whois.Registrant.Country
d.RegistrantPostal = whois.Registrant.PostalCode
}
// Apply abuse contact info
if whois.Abuse.Email != "" || whois.Abuse.Phone != "" {
d.AbuseEmail = whois.Abuse.Email
d.AbusePhone = whois.Abuse.Phone
}
}
// cleanDomain cleans and normalizes a domain name
func cleanDomain(domain string) string {
// Remove protocol
domain = regexp.MustCompile(`^https?://`).ReplaceAllString(domain, "")
// Remove www prefix
domain = regexp.MustCompile(`^www\.`).ReplaceAllString(domain, "")
// Remove path and query
if idx := strings.IndexAny(domain, "/?#"); idx != -1 {
domain = domain[:idx]
}
// Remove port
if idx := strings.Index(domain, ":"); idx != -1 {
domain = domain[:idx]
}
return strings.ToLower(strings.TrimSpace(domain))
}
// hasValidData checks if WHOIS data has useful parsed fields
func hasValidData(data *domain.WHOISData) bool {
if data == nil {
return false
}
// Accept if we got any meaningful date (non-nil and not zero)
if data.Dates.ExpiryDate != nil && !data.Dates.ExpiryDate.IsZero() {
return true
}
if data.Dates.CreationDate != nil && !data.Dates.CreationDate.IsZero() {
return true
}
if data.Registrar.Name != "" && data.Registrar.Name != "Unknown" {
return true
}
if len(data.Status) > 0 {
return true
}
return false
}