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