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

247 lines
6.1 KiB
Go

package checker
import (
"fmt"
"net"
"sort"
"strings"
"time"
"github.com/intodns/backend/internal/resolver"
"github.com/miekg/dns"
)
// isPublicIP returns true if the IP is a globally routable unicast address.
func isPublicIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return false
}
// Check private ranges.
privateRanges := []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
"100.64.0.0/10", // Carrier-grade NAT
"169.254.0.0/16", // Link-local
"192.0.0.0/24", // IETF protocol assignments
"198.18.0.0/15", // Benchmarking
}
for _, cidr := range privateRanges {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if network.Contains(ip) {
return false
}
}
return true
}
// reverseDNS builds the in-addr.arpa or ip6.arpa name for an IP.
func reverseDNS(ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ""
}
if ip4 := ip.To4(); ip4 != nil {
return fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa.", ip4[3], ip4[2], ip4[1], ip4[0])
}
// IPv6
full := ip.To16()
if full == nil {
return ""
}
var buf strings.Builder
for i := len(full) - 1; i >= 0; i-- {
b := full[i]
buf.WriteString(fmt.Sprintf("%x.%x.", b&0x0f, (b>>4)&0x0f))
}
buf.WriteString("ip6.arpa.")
return buf.String()
}
// findParentNS locates the parent zone's nameservers for the given domain by
// querying the TLD nameservers. Returns NS hostnames and their resolved IPs.
func findParentNS(domain string, r *resolver.Resolver) ([]string, []string) {
domain = dns.Fqdn(domain)
parts := dns.SplitDomainName(domain)
if len(parts) < 2 {
return nil, nil
}
// Build the parent zone (e.g., for "example.com." -> "com.")
parent := dns.Fqdn(strings.Join(parts[1:], "."))
// First, find the TLD/parent zone nameservers.
parentNS := findNSForZone(parent, r)
if len(parentNS) == 0 {
return nil, nil
}
// Resolve a parent NS to an IP so we can query it.
parentIPs := resolveNames(parentNS, r)
if len(parentIPs) == 0 {
return nil, nil
}
// Query a parent NS for the domain's NS records (non-recursive).
var nsNames []string
for _, pip := range parentIPs {
resp, err := r.QueryNoRecurse(domain, pip, dns.TypeNS)
if err != nil {
continue
}
// The NS records might be in the authority section (delegation) or answer.
for _, rr := range resp.Ns {
if nsRR, ok := rr.(*dns.NS); ok {
nsNames = appendUniqLower(nsNames, nsRR.Ns)
}
}
for _, rr := range resp.Answer {
if nsRR, ok := rr.(*dns.NS); ok {
nsNames = appendUniqLower(nsNames, nsRR.Ns)
}
}
if len(nsNames) > 0 {
break
}
}
// Collect glue / resolve NS IPs.
var nsIPs []string
for _, ns := range nsNames {
ips := resolveNS(ns, r)
nsIPs = append(nsIPs, ips...)
}
return nsNames, nsIPs
}
// findNSForZone returns the nameserver hostnames for a zone.
func findNSForZone(zone string, r *resolver.Resolver) []string {
// Use the system resolver (recursive) to find NS for the zone.
resp, err := r.Query(zone, "8.8.8.8", dns.TypeNS)
if err != nil {
return nil
}
var names []string
for _, rr := range resp.Answer {
if ns, ok := rr.(*dns.NS); ok {
names = appendUniqLower(names, ns.Ns)
}
}
return names
}
// resolveNS returns IP addresses for a nameserver hostname.
func resolveNS(name string, r *resolver.Resolver) []string {
var ips []string
resp, err := r.Query(name, "8.8.8.8", dns.TypeA)
if err == nil {
for _, rr := range resp.Answer {
if a, ok := rr.(*dns.A); ok {
ips = append(ips, a.A.String())
}
}
}
resp, err = r.Query(name, "8.8.8.8", dns.TypeAAAA)
if err == nil {
for _, rr := range resp.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
ips = append(ips, aaaa.AAAA.String())
}
}
}
return ips
}
// resolveNames resolves a list of hostnames to IPs.
func resolveNames(names []string, r *resolver.Resolver) []string {
var ips []string
for _, n := range names {
ips = append(ips, resolveNS(n, r)...)
}
return ips
}
// appendUniqLower adds s (lowered, FQDN) to the slice if not already present.
func appendUniqLower(slice []string, s string) []string {
s = strings.ToLower(dns.Fqdn(s))
for _, v := range slice {
if v == s {
return slice
}
}
return append(slice, s)
}
// measureDuration returns the elapsed time in milliseconds since start.
func measureDuration(start time.Time) int64 {
return time.Since(start).Milliseconds()
}
// sortedStrings returns a sorted copy.
func sortedStrings(s []string) []string {
c := make([]string, len(s))
copy(c, s)
sort.Strings(c)
return c
}
// nsNamesFromAuthNS queries the authoritative nameservers for the domain's NS
// records directly.
func nsNamesFromAuthNS(domain string, nsIP string, r *resolver.Resolver) []string {
resp, err := r.QueryNoRecurse(domain, nsIP, dns.TypeNS)
if err != nil {
return nil
}
var names []string
for _, rr := range resp.Answer {
if ns, ok := rr.(*dns.NS); ok {
names = appendUniqLower(names, ns.Ns)
}
}
return names
}
// subnet24 returns the /24 prefix string for an IPv4 address.
func subnet24(ip string) string {
parsed := net.ParseIP(ip)
if parsed == nil {
return ip
}
if v4 := parsed.To4(); v4 != nil {
return fmt.Sprintf("%d.%d.%d.0/24", v4[0], v4[1], v4[2])
}
// For IPv6 just use the first 48 bits as a rough grouping.
full := parsed.To16()
return fmt.Sprintf("%x:%x:%x::/48", uint16(full[0])<<8|uint16(full[1]),
uint16(full[2])<<8|uint16(full[3]), uint16(full[4])<<8|uint16(full[5]))
}
// subnet16 returns the /16 prefix string as a rough AS-diversity proxy.
func subnet16(ip string) string {
parsed := net.ParseIP(ip)
if parsed == nil {
return ip
}
if v4 := parsed.To4(); v4 != nil {
return fmt.Sprintf("%d.%d.0.0/16", v4[0], v4[1])
}
full := parsed.To16()
return fmt.Sprintf("%x:%x::/32", uint16(full[0])<<8|uint16(full[1]),
uint16(full[2])<<8|uint16(full[3]))
}
// isInBailiwick returns true if the NS hostname is under the domain.
func isInBailiwick(nsName, domain string) bool {
nsName = strings.ToLower(dns.Fqdn(nsName))
domain = strings.ToLower(dns.Fqdn(domain))
return dns.IsSubDomain(domain, nsName)
}