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
314 lines
10 KiB
Go
314 lines
10 KiB
Go
package checker
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/intodns/backend/internal/resolver"
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// checkParent runs the 5 parent-delegation checks.
|
|
func checkParent(domain string, r *resolver.Resolver) Category {
|
|
cat := Category{Name: "parent", Title: "Parent Delegation"}
|
|
|
|
domain = dns.Fqdn(domain)
|
|
parts := dns.SplitDomainName(domain)
|
|
if len(parts) < 2 {
|
|
cat.Checks = append(cat.Checks, CheckResult{
|
|
ID: "parent-ns-records", Title: "Parent NS Records",
|
|
Status: StatusFail, Message: "Cannot determine parent zone",
|
|
})
|
|
return cat
|
|
}
|
|
|
|
parent := dns.Fqdn(strings.Join(parts[1:], "."))
|
|
|
|
// Find parent zone nameservers.
|
|
parentNSNames := findNSForZone(parent, r)
|
|
parentNSIPs := resolveNames(parentNSNames, r)
|
|
|
|
// Query a parent NS for delegation NS records.
|
|
var delegationNS []string
|
|
var glueA []string
|
|
var glueAAAA []string
|
|
var queryServer string
|
|
|
|
for _, pip := range parentNSIPs {
|
|
resp, err := r.QueryNoRecurse(domain, pip, dns.TypeNS)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, rr := range resp.Ns {
|
|
if ns, ok := rr.(*dns.NS); ok {
|
|
delegationNS = appendUniqLower(delegationNS, ns.Ns)
|
|
}
|
|
}
|
|
for _, rr := range resp.Answer {
|
|
if ns, ok := rr.(*dns.NS); ok {
|
|
delegationNS = appendUniqLower(delegationNS, ns.Ns)
|
|
}
|
|
}
|
|
// Collect glue records from additional section.
|
|
for _, rr := range resp.Extra {
|
|
switch v := rr.(type) {
|
|
case *dns.A:
|
|
glueA = append(glueA, fmt.Sprintf("%s -> %s", v.Hdr.Name, v.A.String()))
|
|
case *dns.AAAA:
|
|
glueAAAA = append(glueAAAA, fmt.Sprintf("%s -> %s", v.Hdr.Name, v.AAAA.String()))
|
|
}
|
|
}
|
|
if len(delegationNS) > 0 {
|
|
queryServer = pip
|
|
break
|
|
}
|
|
}
|
|
|
|
// 1. parent-ns-records
|
|
cat.Checks = append(cat.Checks, checkParentNSRecords(delegationNS, queryServer))
|
|
|
|
// 2. parent-ns-ips
|
|
cat.Checks = append(cat.Checks, checkParentNSIPs(delegationNS, r))
|
|
|
|
// 3. parent-glue
|
|
cat.Checks = append(cat.Checks, checkParentGlue(delegationNS, domain, glueA, glueAAAA, r))
|
|
|
|
// 4. parent-ns-count
|
|
cat.Checks = append(cat.Checks, checkParentNSCount(delegationNS))
|
|
|
|
// 5. parent-consistency
|
|
cat.Checks = append(cat.Checks, checkParentConsistency(domain, delegationNS, r))
|
|
|
|
return cat
|
|
}
|
|
|
|
func checkParentNSRecords(delegationNS []string, server string) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "parent-ns-records", Title: "Parent NS Records"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
if len(delegationNS) == 0 {
|
|
res.Status = StatusFail
|
|
res.Message = "Your domain does not have NS records at the parent zone. This means your domain cannot be resolved. You need to configure NS records at your domain registrar."
|
|
return res
|
|
}
|
|
|
|
res.Status = StatusPass
|
|
res.Message = fmt.Sprintf("Found %d nameservers at parent zone (queried %s): %s", len(delegationNS), server, strings.Join(delegationNS, ", "))
|
|
for _, ns := range delegationNS {
|
|
res.Details = append(res.Details, ns)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func checkParentNSIPs(delegationNS []string, r *resolver.Resolver) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "parent-ns-ips", Title: "Parent NS IP Addresses"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
if len(delegationNS) == 0 {
|
|
res.Status = StatusFail
|
|
res.Message = "No parent NS to check"
|
|
return res
|
|
}
|
|
|
|
allResolved := true
|
|
var unresolvedNS []string
|
|
for _, ns := range delegationNS {
|
|
ips := resolveNS(ns, r)
|
|
if len(ips) == 0 {
|
|
allResolved = false
|
|
unresolvedNS = append(unresolvedNS, ns)
|
|
res.Details = append(res.Details, fmt.Sprintf("%s has no A/AAAA records — cannot be reached", ns))
|
|
} else {
|
|
res.Details = append(res.Details, fmt.Sprintf("%s resolves to %s", ns, strings.Join(ips, ", ")))
|
|
}
|
|
}
|
|
|
|
if allResolved {
|
|
res.Status = StatusPass
|
|
res.Message = "All nameservers listed at parent have resolvable IP addresses"
|
|
} else {
|
|
res.Status = StatusFail
|
|
res.Message = fmt.Sprintf("The following nameservers cannot be resolved to IP addresses: %s. Make sure these hostnames have A or AAAA records configured.", strings.Join(unresolvedNS, ", "))
|
|
}
|
|
return res
|
|
}
|
|
|
|
func checkParentGlue(delegationNS []string, domain string, glueA, glueAAAA []string, r *resolver.Resolver) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "parent-glue", Title: "Glue Records"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
// Check which NS are in-bailiwick.
|
|
var inBailiwick []string
|
|
for _, ns := range delegationNS {
|
|
if isInBailiwick(ns, domain) {
|
|
inBailiwick = append(inBailiwick, ns)
|
|
}
|
|
}
|
|
|
|
if len(inBailiwick) == 0 {
|
|
res.Status = StatusInfo
|
|
res.Message = "Your nameservers are not under your domain, so glue records are not required"
|
|
return res
|
|
}
|
|
|
|
allGlue := append(glueA, glueAAAA...)
|
|
if len(allGlue) == 0 {
|
|
res.Status = StatusFail
|
|
res.Message = fmt.Sprintf("Your nameservers (%s) are under your own domain (in-bailiwick), which requires glue records at the parent zone. No glue records were found. Contact your registrar to add glue (A) records for your nameservers.", strings.Join(inBailiwick, ", "))
|
|
return res
|
|
}
|
|
|
|
// Compare glue IPs with actual IPs from the nameservers themselves.
|
|
glueMap := make(map[string][]string) // ns -> glue IPs
|
|
for _, g := range glueA {
|
|
parts := strings.SplitN(g, " -> ", 2)
|
|
if len(parts) == 2 {
|
|
glueMap[strings.ToLower(parts[0])] = append(glueMap[strings.ToLower(parts[0])], parts[1])
|
|
}
|
|
}
|
|
|
|
var mismatches []string
|
|
for _, ns := range inBailiwick {
|
|
nsLower := strings.ToLower(ns)
|
|
glueIPs := glueMap[nsLower]
|
|
actualIPs := resolveNS(ns, r)
|
|
if len(glueIPs) > 0 && len(actualIPs) > 0 {
|
|
glueSorted := sortedStrings(glueIPs)
|
|
// Only compare A records (filter out IPv6 from actual).
|
|
var actualV4 []string
|
|
for _, ip := range actualIPs {
|
|
if !strings.Contains(ip, ":") {
|
|
actualV4 = append(actualV4, ip)
|
|
}
|
|
}
|
|
actualSorted := sortedStrings(actualV4)
|
|
if strings.Join(glueSorted, ",") != strings.Join(actualSorted, ",") {
|
|
mismatches = append(mismatches, fmt.Sprintf("For %s the parent reported: %v and your nameservers reported: %v", ns, glueSorted, actualSorted))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(mismatches) > 0 {
|
|
res.Status = StatusWarn
|
|
res.Message = "The A records (glue) from the parent zone are different from those reported by your nameservers. Make sure your parent zone has the same IP addresses for your nameservers as your actual DNS configuration."
|
|
res.Details = mismatches
|
|
res.Details = append(res.Details, "")
|
|
res.Details = append(res.Details, "Glue records from parent:")
|
|
res.Details = append(res.Details, allGlue...)
|
|
return res
|
|
}
|
|
|
|
res.Status = StatusPass
|
|
res.Message = fmt.Sprintf("Glue records present and consistent for %d in-bailiwick nameservers", len(inBailiwick))
|
|
res.Details = allGlue
|
|
return res
|
|
}
|
|
|
|
func checkParentNSCount(delegationNS []string) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "parent-ns-count", Title: "Parent NS Count"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
count := len(delegationNS)
|
|
switch {
|
|
case count == 0:
|
|
res.Status = StatusFail
|
|
res.Message = "No nameservers found at parent zone. Your domain will not resolve. Add NS records at your registrar."
|
|
case count == 1:
|
|
res.Status = StatusWarn
|
|
res.Message = fmt.Sprintf("You only have 1 nameserver (%s). RFC 2182 recommends at least 2 nameservers for redundancy. If this server goes down, your domain will be unreachable.", delegationNS[0])
|
|
res.Details = delegationNS
|
|
default:
|
|
res.Status = StatusPass
|
|
res.Message = fmt.Sprintf("You have %d nameservers, which is good for redundancy", count)
|
|
res.Details = delegationNS
|
|
}
|
|
return res
|
|
}
|
|
|
|
func checkParentConsistency(domain string, delegationNS []string, r *resolver.Resolver) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "parent-consistency", Title: "Parent/Auth NS Consistency"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
if len(delegationNS) == 0 {
|
|
res.Status = StatusFail
|
|
res.Message = "No parent NS to compare"
|
|
return res
|
|
}
|
|
|
|
// Get authoritative NS set by querying one of the delegated NS.
|
|
var authNS []string
|
|
for _, ns := range delegationNS {
|
|
ips := resolveNS(ns, r)
|
|
for _, ip := range ips {
|
|
authNS = nsNamesFromAuthNS(domain, ip, r)
|
|
if len(authNS) > 0 {
|
|
break
|
|
}
|
|
}
|
|
if len(authNS) > 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if len(authNS) == 0 {
|
|
res.Status = StatusWarn
|
|
res.Message = "Could not retrieve authoritative NS set for comparison"
|
|
return res
|
|
}
|
|
|
|
parentSet := sortedStrings(delegationNS)
|
|
authSet := sortedStrings(authNS)
|
|
|
|
parentStr := strings.Join(parentSet, ",")
|
|
authStr := strings.Join(authSet, ",")
|
|
|
|
if parentStr == authStr {
|
|
res.Status = StatusPass
|
|
res.Message = "The NS records at the parent zone match the NS records your nameservers return. Everything is consistent."
|
|
res.Details = append(res.Details, fmt.Sprintf("NS at parent: %s", strings.Join(parentSet, ", ")))
|
|
res.Details = append(res.Details, fmt.Sprintf("NS at auth: %s", strings.Join(authSet, ", ")))
|
|
} else {
|
|
res.Status = StatusWarn
|
|
res.Message = "The NS records at the parent zone do NOT match what your nameservers report. You need to update either your registrar's NS records or your zone's NS records so they are consistent."
|
|
|
|
onlyParent := diff(parentSet, authSet)
|
|
onlyAuth := diff(authSet, parentSet)
|
|
if len(onlyParent) > 0 {
|
|
for _, ns := range onlyParent {
|
|
res.Details = append(res.Details, fmt.Sprintf("%s is listed at the parent zone but NOT in your zone — either add it to your zone or remove it from the parent (registrar)", ns))
|
|
}
|
|
}
|
|
if len(onlyAuth) > 0 {
|
|
for _, ns := range onlyAuth {
|
|
res.Details = append(res.Details, fmt.Sprintf("%s is in your zone but NOT at the parent — either add it at your registrar or remove it from your zone", ns))
|
|
}
|
|
}
|
|
res.Details = append(res.Details, fmt.Sprintf("Parent reports: %s", strings.Join(parentSet, ", ")))
|
|
res.Details = append(res.Details, fmt.Sprintf("Your zone reports: %s", strings.Join(authSet, ", ")))
|
|
}
|
|
return res
|
|
}
|
|
|
|
func diff(a, b []string) []string {
|
|
sort.Strings(a)
|
|
sort.Strings(b)
|
|
set := make(map[string]bool)
|
|
for _, v := range b {
|
|
set[v] = true
|
|
}
|
|
var result []string
|
|
for _, v := range a {
|
|
if !set[v] {
|
|
result = append(result, v)
|
|
}
|
|
}
|
|
return result
|
|
}
|