Files
dnstest/backend/internal/checker/whois.go
robertas_stauskas a70f3262e0 Initial commit: DNS Test - DNS health checking tool
Go backend (miekg/dns) + Nuxt 3 frontend (Tailwind CSS v4).

8 check categories, 52 checks total:
- Overview: @ record, WWW, MX with ASN/provider lookup
- Domain Registration: expiry, registrar (RDAP + whois fallback)
- Parent Delegation: NS records, glue, consistency
- Nameservers: 17 checks (reachability, auth, recursion, TCP/UDP, AXFR, etc.)
- SOA: serial consistency, timing values
- Mail (MX): 11 checks (CNAME, PTR, public IPs, consistency)
- Mail Auth: SPF, DKIM, DMARC
- WWW: A record, CNAME

Features:
- SSE streaming (results appear as each category completes)
- SQLite history (modernc.org/sqlite)
- Rate limiting, CORS, request logging
- Dark mode, responsive design
2026-03-20 13:39:57 +02:00

470 lines
12 KiB
Go

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")
}