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
470 lines
12 KiB
Go
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")
|
|
}
|