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 }