package checker import ( "context" "encoding/json" "fmt" "io" "net/http" "os/exec" "strings" "time" "github.com/intodns/backend/internal/resolver" ) // checkDomainWhois checks domain registration and expiry via RDAP. func checkDomainWhois(domain string, r *resolver.Resolver) Category { cat := Category{Name: "registration", Title: "Domain Registration"} cleanDomain := strings.TrimSuffix(domain, ".") // Get RDAP info, fall back to whois CLI rdap := fetchRDAP(cleanDomain) var whoisData *whoisInfo if rdap == nil { whoisData = fetchWhoisCLI(cleanDomain) } // 1. Domain expiry check if rdap != nil { cat.Checks = append(cat.Checks, checkDomainExpiry(cleanDomain, rdap)) } else { cat.Checks = append(cat.Checks, checkDomainExpiryWhois(cleanDomain, whoisData)) } // 2. Registration info if rdap != nil { cat.Checks = append(cat.Checks, checkRegistrationInfo(cleanDomain, rdap)) } else { cat.Checks = append(cat.Checks, checkRegistrationInfoWhois(cleanDomain, whoisData)) } return cat } // whoisInfo holds parsed whois CLI output. type whoisInfo struct { Registered string Expires string Registrar string RegistrarURL string Status string Contact string RawLines []string } func fetchWhoisCLI(domain string) *whoisInfo { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "whois", domain) out, err := cmd.Output() if err != nil { return nil } info := &whoisInfo{} lines := strings.Split(string(out), "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "%") || line == "" { continue } info.RawLines = append(info.RawLines, line) parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(strings.ToLower(parts[0])) val := strings.TrimSpace(parts[1]) switch { case key == "expires" || key == "expiry date" || key == "registry expiry date" || key == "paid-till" || key == "expire date": info.Expires = val case key == "registered" || key == "creation date" || key == "created": info.Registered = val case key == "registrar" || key == "registrar name": info.Registrar = val case key == "registrar website" || key == "registrar url": info.RegistrarURL = val case key == "status" || key == "domain status": if info.Status == "" { info.Status = val } else { info.Status += ", " + val } case key == "contact organization" || key == "registrant organization": info.Contact = val } } return info } func checkDomainExpiryWhois(domain string, w *whoisInfo) CheckResult { start := time.Now() res := CheckResult{ID: "domain-expiry", Title: "Domain Expiry"} defer func() { res.DurationMs = measureDuration(start) }() if w == nil || w.Expires == "" { res.Status = StatusInfo res.Message = "Could not retrieve domain expiry information" return res } expiry, err := parseFlexDate(w.Expires) if err != nil { res.Status = StatusInfo res.Message = fmt.Sprintf("Domain expires: %s", w.Expires) return res } daysLeft := int(time.Until(expiry).Hours() / 24) switch { case daysLeft < 0: res.Status = StatusFail res.Message = fmt.Sprintf("Domain %s EXPIRED %d days ago (%s)! Renew immediately.", domain, -daysLeft, expiry.Format("2006-01-02")) case daysLeft <= 15: res.Status = StatusFail res.Message = fmt.Sprintf("Domain %s expires in %d days (%s). Renew urgently!", domain, daysLeft, expiry.Format("2006-01-02")) case daysLeft <= 30: res.Status = StatusWarn res.Message = fmt.Sprintf("Domain %s expires in %d days (%s). Consider renewing soon.", domain, daysLeft, expiry.Format("2006-01-02")) default: res.Status = StatusPass res.Message = fmt.Sprintf("Domain %s expires in %d days (%s)", domain, daysLeft, expiry.Format("2006-01-02")) } if w.Registered != "" { res.Details = append(res.Details, fmt.Sprintf("Registered: %s", w.Registered)) } res.Details = append(res.Details, fmt.Sprintf("Expires: %s (%d days left)", expiry.Format("2006-01-02"), daysLeft)) return res } func checkRegistrationInfoWhois(domain string, w *whoisInfo) CheckResult { start := time.Now() res := CheckResult{ID: "domain-registrar", Title: "Registrar"} defer func() { res.DurationMs = measureDuration(start) }() if w == nil { res.Status = StatusInfo res.Message = "Could not retrieve registrar information" return res } res.Status = StatusInfo if w.Registrar != "" { res.Message = fmt.Sprintf("Registered through %s", w.Registrar) res.Details = append(res.Details, fmt.Sprintf("Registrar: %s", w.Registrar)) } else { res.Message = "Registrar information not available" } if w.RegistrarURL != "" { res.Details = append(res.Details, fmt.Sprintf("Registrar website: %s", w.RegistrarURL)) } if w.Status != "" { res.Details = append(res.Details, fmt.Sprintf("Status: %s", w.Status)) } if w.Contact != "" { res.Details = append(res.Details, fmt.Sprintf("Registrant: %s", w.Contact)) } return res } func parseFlexDate(s string) (time.Time, error) { layouts := []string{ time.RFC3339, "2006-01-02T15:04:05Z", "2006-01-02", "02-Jan-2006", "2006.01.02", "02/01/2006", "01/02/2006", "2006-01-02 15:04:05", } for _, layout := range layouts { t, err := time.Parse(layout, s) if err == nil { return t, nil } } return time.Time{}, fmt.Errorf("cannot parse date: %s", s) } type rdapResponse struct { Handle string `json:"handle"` Name string `json:"ldhName"` Status []string `json:"status"` Events []rdapEvent `json:"events"` Entities []rdapEntity `json:"entities"` Nameservers []rdapNS `json:"nameservers"` } type rdapEvent struct { Action string `json:"eventAction"` Date string `json:"eventDate"` } type rdapEntity struct { Roles []string `json:"roles"` Handle string `json:"handle"` VcardArr []interface{} `json:"vcardArray"` Entities []rdapEntity `json:"entities"` PublicIDs []rdapPublicID `json:"publicIds"` } type rdapPublicID struct { Type string `json:"type"` Identifier string `json:"identifier"` } type rdapNS struct { LdhName string `json:"ldhName"` } func fetchRDAP(domain string) *rdapResponse { // Determine RDAP server based on TLD tld := domain if idx := strings.LastIndex(domain, "."); idx >= 0 { tld = domain[idx+1:] } // Try IANA bootstrap first, then known servers urls := []string{ fmt.Sprintf("https://rdap.org/domain/%s", domain), } // Add known TLD-specific RDAP servers switch strings.ToLower(tld) { case "com", "net": urls = append([]string{fmt.Sprintf("https://rdap.verisign.com/com/v1/domain/%s", domain)}, urls...) case "org": urls = append([]string{fmt.Sprintf("https://rdap.org/domain/%s", domain)}, urls...) case "lt": urls = append([]string{fmt.Sprintf("https://rdap.domreg.lt/domain/%s", domain)}, urls...) case "eu": urls = append([]string{fmt.Sprintf("https://rdap.eu/domain/%s", domain)}, urls...) case "io": urls = append([]string{fmt.Sprintf("https://rdap.nic.io/domain/%s", domain)}, urls...) case "dev": urls = append([]string{fmt.Sprintf("https://rdap.nic.google/domain/%s", domain)}, urls...) } client := &http.Client{Timeout: 5 * time.Second} for _, url := range urls { req, err := http.NewRequest("GET", url, nil) if err != nil { continue } req.Header.Set("Accept", "application/rdap+json") resp, err := client.Do(req) if err != nil { continue } defer resp.Body.Close() if resp.StatusCode != 200 { continue } body, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024)) if err != nil { continue } var rdap rdapResponse if err := json.Unmarshal(body, &rdap); err != nil { continue } return &rdap } return nil } func checkDomainExpiry(domain string, rdap *rdapResponse) CheckResult { start := time.Now() res := CheckResult{ID: "domain-expiry", Title: "Domain Expiry"} defer func() { res.DurationMs = measureDuration(start) }() if rdap == nil { res.Status = StatusInfo res.Message = "Could not retrieve domain registration information" return res } var expiryDate, registrationDate, lastChanged string for _, event := range rdap.Events { switch event.Action { case "expiration": expiryDate = event.Date case "registration": registrationDate = event.Date case "last changed", "last update of RDAP database": if lastChanged == "" || event.Action == "last changed" { lastChanged = event.Date } } } if expiryDate == "" { res.Status = StatusInfo res.Message = "Domain expiry date not available" if registrationDate != "" { res.Details = append(res.Details, fmt.Sprintf("Registered: %s", formatDate(registrationDate))) } return res } expiry, err := time.Parse(time.RFC3339, expiryDate) if err != nil { // Try other formats for _, layout := range []string{"2006-01-02T15:04:05Z", "2006-01-02", "2006-01-02 15:04:05"} { expiry, err = time.Parse(layout, expiryDate) if err == nil { break } } } if err != nil { res.Status = StatusInfo res.Message = fmt.Sprintf("Domain expires: %s (could not parse date)", expiryDate) return res } daysLeft := int(time.Until(expiry).Hours() / 24) switch { case daysLeft < 0: res.Status = StatusFail res.Message = fmt.Sprintf("Domain %s EXPIRED %d days ago (%s)! Renew immediately or risk losing the domain.", domain, -daysLeft, formatDate(expiryDate)) case daysLeft <= 15: res.Status = StatusFail res.Message = fmt.Sprintf("Domain %s expires in %d days (%s). Renew urgently!", domain, daysLeft, formatDate(expiryDate)) case daysLeft <= 30: res.Status = StatusWarn res.Message = fmt.Sprintf("Domain %s expires in %d days (%s). Consider renewing soon.", domain, daysLeft, formatDate(expiryDate)) default: res.Status = StatusPass res.Message = fmt.Sprintf("Domain %s expires in %d days (%s)", domain, daysLeft, formatDate(expiryDate)) } if registrationDate != "" { res.Details = append(res.Details, fmt.Sprintf("Registered: %s", formatDate(registrationDate))) } res.Details = append(res.Details, fmt.Sprintf("Expires: %s (%d days left)", formatDate(expiryDate), daysLeft)) if lastChanged != "" { res.Details = append(res.Details, fmt.Sprintf("Last changed: %s", formatDate(lastChanged))) } return res } func checkRegistrationInfo(domain string, rdap *rdapResponse) CheckResult { start := time.Now() res := CheckResult{ID: "domain-registrar", Title: "Registrar"} defer func() { res.DurationMs = measureDuration(start) }() if rdap == nil { res.Status = StatusInfo res.Message = "Could not retrieve registrar information" return res } // Find registrar entity var registrarName string var registrarHandle string for _, entity := range rdap.Entities { for _, role := range entity.Roles { if strings.ToLower(role) == "registrar" { registrarHandle = entity.Handle // Try to extract name from vcard registrarName = extractVcardName(entity.VcardArr) if registrarName == "" { registrarName = entity.Handle } } } } // Domain status if len(rdap.Status) > 0 { res.Details = append(res.Details, fmt.Sprintf("Status: %s", strings.Join(rdap.Status, ", "))) } if registrarName != "" { res.Status = StatusInfo res.Message = fmt.Sprintf("Registered through %s", registrarName) if registrarHandle != "" && registrarHandle != registrarName { res.Details = append(res.Details, fmt.Sprintf("Registrar: %s (%s)", registrarName, registrarHandle)) } else { res.Details = append(res.Details, fmt.Sprintf("Registrar: %s", registrarName)) } } else { res.Status = StatusInfo res.Message = "Registrar information not available" } // Nameservers from RDAP if len(rdap.Nameservers) > 0 { var nsNames []string for _, ns := range rdap.Nameservers { nsNames = append(nsNames, ns.LdhName) } res.Details = append(res.Details, fmt.Sprintf("Nameservers: %s", strings.Join(nsNames, ", "))) } return res } func extractVcardName(vcardArr []interface{}) string { if len(vcardArr) < 2 { return "" } entries, ok := vcardArr[1].([]interface{}) if !ok { return "" } for _, entry := range entries { arr, ok := entry.([]interface{}) if !ok || len(arr) < 4 { continue } propName, ok := arr[0].(string) if !ok { continue } if propName == "fn" || propName == "org" { if val, ok := arr[3].(string); ok && val != "" { return val } } } return "" } func formatDate(dateStr string) string { t, err := time.Parse(time.RFC3339, dateStr) if err != nil { for _, layout := range []string{"2006-01-02T15:04:05Z", "2006-01-02"} { t, err = time.Parse(layout, dateStr) if err == nil { break } } } if err != nil { return dateStr } return t.Format("2006-01-02") }