Files
dnstest/backend/internal/checker/ns.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

714 lines
19 KiB
Go

package checker
import (
"fmt"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
"github.com/miekg/dns"
)
// checkNS runs the 17 nameserver checks.
func checkNS(domain string, r *resolver.Resolver) Category {
cat := Category{Name: "nameservers", Title: "Nameservers"}
domain = dns.Fqdn(domain)
// Resolve the NS set via a recursive query.
resp, err := r.Query(domain, "8.8.8.8", dns.TypeNS)
if err != nil || resp == nil {
cat.Checks = append(cat.Checks, CheckResult{
ID: "ns-count", Title: "NS Count",
Status: StatusFail, Message: fmt.Sprintf("Failed to query NS records: %v", err),
})
return cat
}
var nsNames []string
for _, rr := range resp.Answer {
if ns, ok := rr.(*dns.NS); ok {
nsNames = appendUniqLower(nsNames, ns.Ns)
}
}
// Resolve all NS to IPs.
var nsInfos []nsEntry
var allIPs []string
for _, ns := range nsNames {
ips := resolveNS(ns, r)
nsInfos = append(nsInfos, nsEntry{Name: ns, IPs: ips})
allIPs = append(allIPs, ips...)
}
// 1. ns-count
cat.Checks = append(cat.Checks, checkNSCount(nsNames))
// 2. ns-reachable — also builds a filtered list of reachable NS
reachableCheck, reachableInfos := checkNSReachableFiltered(domain, nsInfos, r)
cat.Checks = append(cat.Checks, reachableCheck)
// Use reachable NS for checks that query NS directly to avoid timeouts.
// Checks that only need names/IPs (no direct queries) still use full nsInfos.
// 3. ns-auth
cat.Checks = append(cat.Checks, checkNSAuth(domain, reachableInfos, r))
// 4. ns-recursion
cat.Checks = append(cat.Checks, checkNSRecursion(domain, reachableInfos, r))
// 5. ns-identical
cat.Checks = append(cat.Checks, checkNSIdentical(domain, reachableInfos, r))
// 6. ns-ip-unique
cat.Checks = append(cat.Checks, checkNSIPUnique(nsInfos))
// 7. ns-subnet-diversity
cat.Checks = append(cat.Checks, checkNSSubnetDiversity(allIPs))
// 8. ns-as-diversity
cat.Checks = append(cat.Checks, checkNSASDiversity(allIPs))
// 9. ns-tcp
cat.Checks = append(cat.Checks, checkNSTCP(domain, reachableInfos, r))
// 10. ns-udp
cat.Checks = append(cat.Checks, checkNSUDP(domain, reachableInfos, r))
// 11. ns-no-cname
cat.Checks = append(cat.Checks, checkNSNoCNAME(nsNames, r))
// 12. ns-a-records
cat.Checks = append(cat.Checks, checkNSARecords(nsInfos, r))
// 13. ns-aaaa-records
cat.Checks = append(cat.Checks, checkNSAAAARecords(nsNames, r))
// 14. ns-version
cat.Checks = append(cat.Checks, checkNSVersion(reachableInfos, r))
// 15. ns-zone-transfer
cat.Checks = append(cat.Checks, checkNSZoneTransfer(domain, reachableInfos, r))
// 16. ns-lame
cat.Checks = append(cat.Checks, checkNSLame(domain, reachableInfos, r))
// 17. ns-response-size
cat.Checks = append(cat.Checks, checkNSResponseSize(domain, reachableInfos, r))
return cat
}
func checkNSCount(nsNames []string) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-count", Title: "NS Count"}
defer func() { res.DurationMs = measureDuration(start) }()
count := len(nsNames)
switch {
case count == 0:
res.Status = StatusFail
res.Message = "No NS records found"
case count == 1:
res.Status = StatusWarn
res.Message = "Only 1 nameserver; at least 2 recommended"
res.Details = nsNames
default:
res.Status = StatusPass
res.Message = fmt.Sprintf("%d nameservers found", count)
res.Details = nsNames
}
return res
}
type nsEntry struct {
Name string
IPs []string
}
// checkNSReachableFiltered checks reachability and returns both the check result
// and a filtered list of only reachable NS entries (with only reachable IPs).
func checkNSReachableFiltered(domain string, infos []nsEntry, r *resolver.Resolver) (CheckResult, []nsEntry) {
start := time.Now()
res := CheckResult{ID: "ns-reachable", Title: "NS Reachability"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res, nil
}
var reachable []nsEntry
allOK := true
for _, ns := range infos {
if len(ns.IPs) == 0 {
allOK = false
res.Details = append(res.Details, fmt.Sprintf("%s: no IPs resolved", ns.Name))
continue
}
var reachableIPs []string
for _, ip := range ns.IPs {
_, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err == nil {
reachableIPs = append(reachableIPs, ip)
res.Details = append(res.Details, fmt.Sprintf("%s (%s): OK", ns.Name, ip))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): unreachable", ns.Name, ip))
}
}
if len(reachableIPs) > 0 {
reachable = append(reachable, nsEntry{Name: ns.Name, IPs: reachableIPs})
} else {
allOK = false
}
}
if allOK {
res.Status = StatusPass
res.Message = "All nameservers respond to DNS queries"
} else {
var unreachableNames []string
for _, ns := range infos {
found := false
for _, r := range reachable {
if r.Name == ns.Name {
found = true
break
}
}
if !found {
unreachableNames = append(unreachableNames, ns.Name)
}
}
res.Status = StatusFail
res.Message = fmt.Sprintf("The following nameservers are not responding to DNS queries: %s. This means part of your DNS infrastructure is down. Check that these servers are running and accessible on port 53 (UDP/TCP).", strings.Join(unreachableNames, ", "))
}
return res, reachable
}
func checkNSAuth(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-auth", Title: "NS Authoritative"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
allAuth := true
for _, ns := range infos {
for _, ip := range ns.IPs {
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err != nil {
allAuth = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): error %v", ns.Name, ip, err))
continue
}
if resp.Authoritative {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): authoritative (AA=1)", ns.Name, ip))
} else {
allAuth = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): NOT authoritative (AA=0)", ns.Name, ip))
}
break // only check first reachable IP per NS
}
}
if allAuth {
res.Status = StatusPass
res.Message = "All nameservers are authoritative"
} else {
res.Status = StatusFail
res.Message = "Some nameservers are not authoritative"
}
return res
}
func checkNSRecursion(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-recursion", Title: "NS Recursion"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
anyRecursive := false
for _, ns := range infos {
for _, ip := range ns.IPs {
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err != nil {
continue
}
if resp.RecursionAvailable {
anyRecursive = true
res.Details = append(res.Details, fmt.Sprintf("%s (%s): recursion available (RA=1)", ns.Name, ip))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): no recursion (RA=0)", ns.Name, ip))
}
break
}
}
if anyRecursive {
res.Status = StatusWarn
res.Message = "Some nameservers offer recursion; this is a security risk"
} else {
res.Status = StatusPass
res.Message = "No nameservers offer recursion"
}
return res
}
func checkNSIdentical(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-identical", Title: "NS Identical Sets"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
var sets []string
allSame := true
var referenceSet string
for _, ns := range infos {
for _, ip := range ns.IPs {
names := nsNamesFromAuthNS(domain, ip, r)
sorted := sortedStrings(names)
setStr := strings.Join(sorted, ",")
sets = append(sets, setStr)
res.Details = append(res.Details, fmt.Sprintf("%s: %s", ns.Name, strings.Join(sorted, ", ")))
if referenceSet == "" {
referenceSet = setStr
} else if setStr != referenceSet {
allSame = false
}
break
}
}
if allSame {
res.Status = StatusPass
res.Message = "All nameservers return the same NS set"
} else {
res.Status = StatusFail
res.Message = "Nameservers return different NS sets"
}
return res
}
func checkNSIPUnique(infos []nsEntry) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-ip-unique", Title: "NS IP Uniqueness"}
defer func() { res.DurationMs = measureDuration(start) }()
seen := make(map[string]string) // ip -> ns name
duplicates := false
for _, ns := range infos {
for _, ip := range ns.IPs {
if prev, ok := seen[ip]; ok {
duplicates = true
res.Details = append(res.Details, fmt.Sprintf("Duplicate IP %s shared by %s and %s", ip, prev, ns.Name))
} else {
seen[ip] = ns.Name
}
}
}
if duplicates {
res.Status = StatusWarn
res.Message = "Some nameservers share IP addresses"
} else {
res.Status = StatusPass
res.Message = "All nameserver IPs are unique"
}
return res
}
func checkNSSubnetDiversity(allIPs []string) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-subnet-diversity", Title: "NS Subnet Diversity"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(allIPs) == 0 {
res.Status = StatusWarn
res.Message = "No NS IPs to check"
return res
}
subnets := make(map[string]bool)
for _, ip := range allIPs {
subnets[subnet24(ip)] = true
}
for s := range subnets {
res.Details = append(res.Details, s)
}
if len(subnets) >= 2 {
res.Status = StatusPass
res.Message = fmt.Sprintf("Nameservers span %d /24 subnets", len(subnets))
} else {
res.Status = StatusWarn
res.Message = "All nameservers are in the same /24 subnet"
}
return res
}
func checkNSASDiversity(allIPs []string) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-as-diversity", Title: "NS AS Diversity"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(allIPs) == 0 {
res.Status = StatusInfo
res.Message = "No NS IPs to check"
return res
}
networks := make(map[string]bool)
for _, ip := range allIPs {
networks[subnet16(ip)] = true
}
for n := range networks {
res.Details = append(res.Details, n)
}
if len(networks) >= 2 {
res.Status = StatusInfo
res.Message = fmt.Sprintf("Nameservers appear to span %d /16 networks (rough AS diversity proxy)", len(networks))
} else {
res.Status = StatusInfo
res.Message = "All nameservers appear to be in the same /16 network"
}
return res
}
func checkNSTCP(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-tcp", Title: "NS TCP Support"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
allOK := true
for _, ns := range infos {
for _, ip := range ns.IPs {
_, err := r.QueryTCP(domain, ip, dns.TypeSOA)
if err != nil {
allOK = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): TCP failed: %v", ns.Name, ip, err))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): TCP OK", ns.Name, ip))
}
break
}
}
if allOK {
res.Status = StatusPass
res.Message = "All nameservers respond over TCP"
} else {
res.Status = StatusFail
res.Message = "Some nameservers do not respond over TCP"
}
return res
}
func checkNSUDP(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-udp", Title: "NS UDP Support"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
allOK := true
for _, ns := range infos {
for _, ip := range ns.IPs {
_, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err != nil {
allOK = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): UDP failed: %v", ns.Name, ip, err))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): UDP OK", ns.Name, ip))
}
break
}
}
if allOK {
res.Status = StatusPass
res.Message = "All nameservers respond over UDP"
} else {
res.Status = StatusFail
res.Message = "Some nameservers do not respond over UDP"
}
return res
}
func checkNSNoCNAME(nsNames []string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-no-cname", Title: "NS No CNAME"}
defer func() { res.DurationMs = measureDuration(start) }()
hasCNAME := false
for _, ns := range nsNames {
resp, err := r.Query(ns, "8.8.8.8", dns.TypeCNAME)
if err != nil {
continue
}
for _, rr := range resp.Answer {
if cname, ok := rr.(*dns.CNAME); ok {
hasCNAME = true
res.Details = append(res.Details, fmt.Sprintf("%s is a CNAME to %s", ns, cname.Target))
}
}
}
if hasCNAME {
res.Status = StatusFail
res.Message = "Some NS names resolve to CNAMEs (RFC violation)"
} else {
res.Status = StatusPass
res.Message = "No NS names resolve to CNAMEs"
}
return res
}
func checkNSARecords(infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-a-records", Title: "NS A Records"}
defer func() { res.DurationMs = measureDuration(start) }()
allHaveA := true
for _, ns := range infos {
resp, err := r.Query(ns.Name, "8.8.8.8", dns.TypeA)
if err != nil {
allHaveA = false
res.Details = append(res.Details, fmt.Sprintf("%s: query error %v", ns.Name, err))
continue
}
found := false
for _, rr := range resp.Answer {
if a, ok := rr.(*dns.A); ok {
found = true
res.Details = append(res.Details, fmt.Sprintf("%s: %s", ns.Name, a.A.String()))
}
}
if !found {
allHaveA = false
res.Details = append(res.Details, fmt.Sprintf("%s: no A record", ns.Name))
}
}
if allHaveA {
res.Status = StatusPass
res.Message = "All nameservers have A records"
} else {
res.Status = StatusWarn
res.Message = "Some nameservers lack A records"
}
return res
}
func checkNSAAAARecords(nsNames []string, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-aaaa-records", Title: "NS AAAA Records"}
defer func() { res.DurationMs = measureDuration(start) }()
anyAAAA := false
for _, ns := range nsNames {
resp, err := r.Query(ns, "8.8.8.8", dns.TypeAAAA)
if err != nil {
res.Details = append(res.Details, fmt.Sprintf("%s: query error", ns))
continue
}
found := false
for _, rr := range resp.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
found = true
anyAAAA = true
res.Details = append(res.Details, fmt.Sprintf("%s: %s", ns, aaaa.AAAA.String()))
}
}
if !found {
res.Details = append(res.Details, fmt.Sprintf("%s: no AAAA record", ns))
}
}
if anyAAAA {
res.Status = StatusInfo
res.Message = "Some nameservers have AAAA records (IPv6)"
} else {
res.Status = StatusInfo
res.Message = "No nameservers have AAAA records"
}
return res
}
func checkNSVersion(infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-version", Title: "NS Version"}
defer func() { res.DurationMs = measureDuration(start) }()
for _, ns := range infos {
for _, ip := range ns.IPs {
ver, err := r.QueryVersionBind(ip)
if err != nil || ver == "" {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): not disclosed", ns.Name, ip))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): %s", ns.Name, ip, ver))
}
break
}
}
res.Status = StatusInfo
res.Message = "Version information (version.bind)"
return res
}
func checkNSZoneTransfer(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-zone-transfer", Title: "NS Zone Transfer (AXFR)"}
defer func() { res.DurationMs = measureDuration(start) }()
anyAllowed := false
for _, ns := range infos {
for _, ip := range ns.IPs {
allowed, _ := r.QueryAXFR(domain, ip)
if allowed {
anyAllowed = true
res.Details = append(res.Details, fmt.Sprintf("%s (%s): AXFR ALLOWED (dangerous!)", ns.Name, ip))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): AXFR refused (good)", ns.Name, ip))
}
break
}
}
if anyAllowed {
res.Status = StatusFail
res.Message = "Zone transfer (AXFR) is allowed on some nameservers"
} else {
res.Status = StatusPass
res.Message = "Zone transfer (AXFR) is refused on all nameservers"
}
return res
}
func checkNSLame(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-lame", Title: "NS Lame Delegation"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
anyLame := false
for _, ns := range infos {
for _, ip := range ns.IPs {
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
if err != nil {
anyLame = true
res.Details = append(res.Details, fmt.Sprintf("%s (%s): lame (unreachable)", ns.Name, ip))
break
}
if !resp.Authoritative {
anyLame = true
res.Details = append(res.Details, fmt.Sprintf("%s (%s): lame (not authoritative)", ns.Name, ip))
} else {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): OK (authoritative)", ns.Name, ip))
}
break
}
}
if anyLame {
res.Status = StatusFail
res.Message = "Lame delegation detected"
} else {
res.Status = StatusPass
res.Message = "No lame delegations"
}
return res
}
func checkNSResponseSize(domain string, infos []nsEntry, r *resolver.Resolver) CheckResult {
start := time.Now()
res := CheckResult{ID: "ns-response-size", Title: "NS Response Size"}
defer func() { res.DurationMs = measureDuration(start) }()
if len(infos) == 0 {
res.Status = StatusFail
res.Message = "No NS to check"
return res
}
allOK := true
for _, ns := range infos {
for _, ip := range ns.IPs {
// Try a normal query and check the response size.
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeNS)
if err != nil {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): error", ns.Name, ip))
break
}
packed, err := resp.Pack()
if err != nil {
break
}
size := len(packed)
if size <= 512 {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): %d bytes (fits in 512)", ns.Name, ip, size))
} else {
// Check EDNS support.
ednsResp, ednsErr := r.QueryEDNS(domain, ip, dns.TypeNS, 4096)
if ednsErr != nil {
allOK = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): %d bytes, no EDNS support", ns.Name, ip, size))
} else {
opt := ednsResp.IsEdns0()
if opt != nil {
res.Details = append(res.Details, fmt.Sprintf("%s (%s): %d bytes, EDNS supported (bufsize %d)", ns.Name, ip, size, opt.UDPSize()))
} else {
allOK = false
res.Details = append(res.Details, fmt.Sprintf("%s (%s): %d bytes, EDNS not in response", ns.Name, ip, size))
}
}
}
break
}
}
if allOK {
res.Status = StatusPass
res.Message = "Responses fit in 512 bytes or EDNS is supported"
} else {
res.Status = StatusWarn
res.Message = "Some responses exceed 512 bytes without EDNS support"
}
return res
}