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
This commit is contained in:
469
backend/internal/checker/whois.go
Normal file
469
backend/internal/checker/whois.go
Normal file
@@ -0,0 +1,469 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user