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
247 lines
6.1 KiB
Go
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)
|
|
}
|