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
294 lines
8.1 KiB
Go
294 lines
8.1 KiB
Go
package checker
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/intodns/backend/internal/resolver"
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// checkSOA runs the 7 SOA checks.
|
|
func checkSOA(domain string, r *resolver.Resolver) Category {
|
|
cat := Category{Name: "soa", Title: "SOA Record"}
|
|
|
|
domain = dns.Fqdn(domain)
|
|
|
|
// Get the SOA record.
|
|
resp, err := r.Query(domain, "8.8.8.8", dns.TypeSOA)
|
|
if err != nil || resp == nil {
|
|
cat.Checks = append(cat.Checks, CheckResult{
|
|
ID: "soa-present", Title: "SOA Present",
|
|
Status: StatusFail, Message: fmt.Sprintf("Failed to query SOA: %v", err),
|
|
})
|
|
return cat
|
|
}
|
|
|
|
var soa *dns.SOA
|
|
for _, rr := range resp.Answer {
|
|
if s, ok := rr.(*dns.SOA); ok {
|
|
soa = s
|
|
break
|
|
}
|
|
}
|
|
|
|
// 1. soa-present
|
|
cat.Checks = append(cat.Checks, checkSOAPresent(soa))
|
|
|
|
if soa == nil {
|
|
// Remaining checks require a SOA record.
|
|
return cat
|
|
}
|
|
|
|
// Get NS list for serial consistency check.
|
|
nsResp, _ := r.Query(domain, "8.8.8.8", dns.TypeNS)
|
|
var nsNames []string
|
|
if nsResp != nil {
|
|
for _, rr := range nsResp.Answer {
|
|
if ns, ok := rr.(*dns.NS); ok {
|
|
nsNames = appendUniqLower(nsNames, ns.Ns)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. soa-serial-consistent
|
|
cat.Checks = append(cat.Checks, checkSOASerialConsistent(domain, nsNames, r))
|
|
|
|
// 3. soa-mname-valid
|
|
cat.Checks = append(cat.Checks, checkSOAMnameValid(soa, r))
|
|
|
|
// 4. soa-rname-valid
|
|
cat.Checks = append(cat.Checks, checkSOARnameValid(soa))
|
|
|
|
// 5. soa-refresh
|
|
cat.Checks = append(cat.Checks, checkSOARefresh(soa))
|
|
|
|
// 6. soa-retry
|
|
cat.Checks = append(cat.Checks, checkSOARetry(soa))
|
|
|
|
// 7. soa-expire
|
|
cat.Checks = append(cat.Checks, checkSOAExpire(soa))
|
|
|
|
return cat
|
|
}
|
|
|
|
func checkSOAPresent(soa *dns.SOA) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "soa-present", Title: "SOA Present"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
if soa == nil {
|
|
res.Status = StatusFail
|
|
res.Message = "No SOA record found"
|
|
return res
|
|
}
|
|
|
|
res.Status = StatusPass
|
|
res.Message = "SOA record found"
|
|
res.Details = []string{
|
|
fmt.Sprintf("MNAME: %s", soa.Ns),
|
|
fmt.Sprintf("RNAME: %s", soa.Mbox),
|
|
fmt.Sprintf("Serial: %d", soa.Serial),
|
|
fmt.Sprintf("Refresh: %d", soa.Refresh),
|
|
fmt.Sprintf("Retry: %d", soa.Retry),
|
|
fmt.Sprintf("Expire: %d", soa.Expire),
|
|
fmt.Sprintf("Minimum TTL: %d", soa.Minttl),
|
|
}
|
|
return res
|
|
}
|
|
|
|
func checkSOASerialConsistent(domain string, nsNames []string, r *resolver.Resolver) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "soa-serial-consistent", Title: "SOA Serial Consistency"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
if len(nsNames) == 0 {
|
|
res.Status = StatusWarn
|
|
res.Message = "No nameservers to compare serials"
|
|
return res
|
|
}
|
|
|
|
serials := make(map[uint32][]string)
|
|
for _, ns := range nsNames {
|
|
ips := resolveNS(ns, r)
|
|
for _, ip := range ips {
|
|
resp, err := r.QueryNoRecurse(domain, ip, dns.TypeSOA)
|
|
if err != nil {
|
|
res.Details = append(res.Details, fmt.Sprintf("%s (%s): error", ns, ip))
|
|
continue
|
|
}
|
|
for _, rr := range resp.Answer {
|
|
if soa, ok := rr.(*dns.SOA); ok {
|
|
serials[soa.Serial] = append(serials[soa.Serial], fmt.Sprintf("%s (%s)", ns, ip))
|
|
res.Details = append(res.Details, fmt.Sprintf("%s (%s): serial %d", ns, ip, soa.Serial))
|
|
}
|
|
}
|
|
break // only first IP per NS
|
|
}
|
|
}
|
|
|
|
if len(serials) == 0 {
|
|
res.Status = StatusWarn
|
|
res.Message = "Could not retrieve SOA serial from any nameserver"
|
|
} else if len(serials) == 1 {
|
|
res.Status = StatusPass
|
|
for serial := range serials {
|
|
res.Message = fmt.Sprintf("All nameservers have consistent serial %d", serial)
|
|
}
|
|
} else {
|
|
res.Status = StatusFail
|
|
res.Message = fmt.Sprintf("Inconsistent SOA serials across nameservers (%d different values)", len(serials))
|
|
}
|
|
return res
|
|
}
|
|
|
|
func checkSOAMnameValid(soa *dns.SOA, r *resolver.Resolver) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "soa-mname-valid", Title: "SOA MNAME Valid"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
mname := soa.Ns
|
|
if mname == "" || mname == "." {
|
|
res.Status = StatusFail
|
|
res.Message = "MNAME is empty or root"
|
|
return res
|
|
}
|
|
|
|
res.Details = append(res.Details, fmt.Sprintf("MNAME: %s", mname))
|
|
|
|
// Check if it resolves to an A record.
|
|
resp, err := r.Query(mname, "8.8.8.8", dns.TypeA)
|
|
if err != nil {
|
|
res.Status = StatusWarn
|
|
res.Message = fmt.Sprintf("MNAME %s could not be resolved", mname)
|
|
return res
|
|
}
|
|
|
|
hasA := false
|
|
for _, rr := range resp.Answer {
|
|
if a, ok := rr.(*dns.A); ok {
|
|
hasA = true
|
|
res.Details = append(res.Details, fmt.Sprintf("Resolves to: %s", a.A.String()))
|
|
}
|
|
}
|
|
|
|
if hasA {
|
|
res.Status = StatusPass
|
|
res.Message = fmt.Sprintf("MNAME %s is valid and resolves", mname)
|
|
} else {
|
|
res.Status = StatusWarn
|
|
res.Message = fmt.Sprintf("MNAME %s does not have an A record", mname)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func checkSOARnameValid(soa *dns.SOA) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "soa-rname-valid", Title: "SOA RNAME Valid"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
rname := soa.Mbox
|
|
if rname == "" || rname == "." {
|
|
res.Status = StatusFail
|
|
res.Message = "RNAME is empty"
|
|
return res
|
|
}
|
|
|
|
// RNAME should be an email address encoded as DNS name.
|
|
// The first "." that is not escaped represents the "@".
|
|
parts := dns.SplitDomainName(rname)
|
|
if len(parts) < 2 {
|
|
res.Status = StatusWarn
|
|
res.Message = fmt.Sprintf("RNAME %s appears malformed", rname)
|
|
return res
|
|
}
|
|
|
|
// Convert to email format for display.
|
|
email := parts[0] + "@" + strings.Join(parts[1:], ".")
|
|
res.Details = append(res.Details, fmt.Sprintf("RNAME: %s (email: %s)", rname, email))
|
|
|
|
res.Status = StatusPass
|
|
res.Message = fmt.Sprintf("RNAME is properly formatted (%s)", email)
|
|
return res
|
|
}
|
|
|
|
func checkSOARefresh(soa *dns.SOA) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "soa-refresh", Title: "SOA Refresh"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
val := soa.Refresh
|
|
res.Details = append(res.Details, fmt.Sprintf("Refresh: %d seconds (%s)", val, humanDuration(val)))
|
|
|
|
if val >= 1200 && val <= 43200 {
|
|
res.Status = StatusPass
|
|
res.Message = fmt.Sprintf("Refresh value %d is within recommended range (1200-43200)", val)
|
|
} else {
|
|
res.Status = StatusWarn
|
|
if val < 1200 {
|
|
res.Message = fmt.Sprintf("Refresh value %d is too low (recommended minimum 1200)", val)
|
|
} else {
|
|
res.Message = fmt.Sprintf("Refresh value %d is too high (recommended maximum 43200)", val)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
func checkSOARetry(soa *dns.SOA) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "soa-retry", Title: "SOA Retry"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
val := soa.Retry
|
|
res.Details = append(res.Details, fmt.Sprintf("Retry: %d seconds (%s)", val, humanDuration(val)))
|
|
|
|
if val >= 120 && val <= 7200 {
|
|
res.Status = StatusPass
|
|
res.Message = fmt.Sprintf("Retry value %d is within recommended range (120-7200)", val)
|
|
} else {
|
|
res.Status = StatusWarn
|
|
if val < 120 {
|
|
res.Message = fmt.Sprintf("Retry value %d is too low (recommended minimum 120)", val)
|
|
} else {
|
|
res.Message = fmt.Sprintf("Retry value %d is too high (recommended maximum 7200)", val)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
func checkSOAExpire(soa *dns.SOA) CheckResult {
|
|
start := time.Now()
|
|
res := CheckResult{ID: "soa-expire", Title: "SOA Expire"}
|
|
defer func() { res.DurationMs = measureDuration(start) }()
|
|
|
|
val := soa.Expire
|
|
res.Details = append(res.Details, fmt.Sprintf("Expire: %d seconds (%s)", val, humanDuration(val)))
|
|
|
|
if val >= 604800 && val <= 4838400 {
|
|
res.Status = StatusPass
|
|
res.Message = fmt.Sprintf("Expire value %d is within recommended range (604800-4838400)", val)
|
|
} else {
|
|
res.Status = StatusWarn
|
|
if val < 604800 {
|
|
res.Message = fmt.Sprintf("Expire value %d is too low (recommended minimum 604800 = 1 week)", val)
|
|
} else {
|
|
res.Message = fmt.Sprintf("Expire value %d is too high (recommended maximum 4838400 = 8 weeks)", val)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
func humanDuration(seconds uint32) string {
|
|
if seconds < 60 {
|
|
return fmt.Sprintf("%d seconds", seconds)
|
|
}
|
|
if seconds < 3600 {
|
|
return fmt.Sprintf("%d minutes", seconds/60)
|
|
}
|
|
if seconds < 86400 {
|
|
return fmt.Sprintf("%.1f hours", float64(seconds)/3600)
|
|
}
|
|
return fmt.Sprintf("%.1f days", float64(seconds)/86400)
|
|
}
|