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

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)
}