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
This commit is contained in:
329
backend/internal/checker/mail.go
Normal file
329
backend/internal/checker/mail.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/intodns/backend/internal/resolver"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// checkMail runs SPF, DKIM, and DMARC checks.
|
||||
func checkMail(domain string, r *resolver.Resolver) Category {
|
||||
cat := Category{Name: "mail-auth", Title: "Mail Authentication (SPF/DKIM/DMARC)"}
|
||||
|
||||
domain = dns.Fqdn(domain)
|
||||
|
||||
// 1. SPF
|
||||
cat.Checks = append(cat.Checks, checkSPF(domain, r))
|
||||
|
||||
// 2. SPF syntax
|
||||
cat.Checks = append(cat.Checks, checkSPFSyntax(domain, r))
|
||||
|
||||
// 3. DMARC
|
||||
cat.Checks = append(cat.Checks, checkDMARC(domain, r))
|
||||
|
||||
// 4. DMARC policy
|
||||
cat.Checks = append(cat.Checks, checkDMARCPolicy(domain, r))
|
||||
|
||||
// 5. DKIM (common selectors)
|
||||
cat.Checks = append(cat.Checks, checkDKIM(domain, r))
|
||||
|
||||
return cat
|
||||
}
|
||||
|
||||
func getTXTRecords(name string, r *resolver.Resolver) []string {
|
||||
resp, err := r.Query(name, "8.8.8.8", dns.TypeTXT)
|
||||
if err != nil || resp == nil {
|
||||
return nil
|
||||
}
|
||||
var records []string
|
||||
for _, rr := range resp.Answer {
|
||||
if txt, ok := rr.(*dns.TXT); ok {
|
||||
records = append(records, strings.Join(txt.Txt, ""))
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func findSPF(records []string) string {
|
||||
for _, r := range records {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(r)), "v=spf1") {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkSPF(domain string, r *resolver.Resolver) CheckResult {
|
||||
start := time.Now()
|
||||
res := CheckResult{ID: "spf-present", Title: "SPF Record"}
|
||||
defer func() { res.DurationMs = measureDuration(start) }()
|
||||
|
||||
txtRecords := getTXTRecords(domain, r)
|
||||
spf := findSPF(txtRecords)
|
||||
|
||||
if spf == "" {
|
||||
res.Status = StatusFail
|
||||
res.Message = "No SPF record found"
|
||||
if len(txtRecords) > 0 {
|
||||
res.Details = append(res.Details, fmt.Sprintf("%d TXT records found, but none is SPF", len(txtRecords)))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
res.Status = StatusPass
|
||||
res.Message = "SPF record found"
|
||||
res.Details = append(res.Details, spf)
|
||||
return res
|
||||
}
|
||||
|
||||
func checkSPFSyntax(domain string, r *resolver.Resolver) CheckResult {
|
||||
start := time.Now()
|
||||
res := CheckResult{ID: "spf-syntax", Title: "SPF Syntax"}
|
||||
defer func() { res.DurationMs = measureDuration(start) }()
|
||||
|
||||
txtRecords := getTXTRecords(domain, r)
|
||||
spf := findSPF(txtRecords)
|
||||
|
||||
if spf == "" {
|
||||
res.Status = StatusInfo
|
||||
res.Message = "No SPF record to validate"
|
||||
return res
|
||||
}
|
||||
|
||||
// Count SPF records — there should be exactly one.
|
||||
spfCount := 0
|
||||
for _, rec := range txtRecords {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=spf1") {
|
||||
spfCount++
|
||||
}
|
||||
}
|
||||
if spfCount > 1 {
|
||||
res.Status = StatusFail
|
||||
res.Message = fmt.Sprintf("Multiple SPF records found (%d) — only one is allowed per RFC 7208", spfCount)
|
||||
return res
|
||||
}
|
||||
|
||||
// Basic syntax checks.
|
||||
var warnings []string
|
||||
lower := strings.ToLower(spf)
|
||||
|
||||
if !strings.HasPrefix(lower, "v=spf1 ") && lower != "v=spf1" {
|
||||
warnings = append(warnings, "SPF version tag should be followed by a space")
|
||||
}
|
||||
|
||||
// Check for common mechanisms.
|
||||
mechanisms := []string{"all", "include:", "a", "mx", "ip4:", "ip6:", "redirect=", "exists:"}
|
||||
hasMechanism := false
|
||||
for _, m := range mechanisms {
|
||||
if strings.Contains(lower, m) {
|
||||
hasMechanism = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMechanism {
|
||||
warnings = append(warnings, "SPF record has no recognizable mechanisms")
|
||||
}
|
||||
|
||||
// Check for +all (too permissive).
|
||||
if strings.Contains(lower, "+all") {
|
||||
warnings = append(warnings, "SPF uses +all which allows anyone to send mail (too permissive)")
|
||||
}
|
||||
|
||||
// Check for ?all (neutral — not recommended).
|
||||
if strings.Contains(lower, "?all") {
|
||||
warnings = append(warnings, "SPF uses ?all (neutral) — consider using ~all or -all")
|
||||
}
|
||||
|
||||
// Count DNS lookups (include, a, mx, ptr, exists, redirect — max 10).
|
||||
lookups := 0
|
||||
parts := strings.Fields(lower)
|
||||
for _, p := range parts {
|
||||
p = strings.TrimLeft(p, "+-~?")
|
||||
if strings.HasPrefix(p, "include:") || strings.HasPrefix(p, "redirect=") ||
|
||||
p == "a" || strings.HasPrefix(p, "a:") ||
|
||||
p == "mx" || strings.HasPrefix(p, "mx:") ||
|
||||
strings.HasPrefix(p, "ptr") || strings.HasPrefix(p, "exists:") {
|
||||
lookups++
|
||||
}
|
||||
}
|
||||
if lookups > 10 {
|
||||
warnings = append(warnings, fmt.Sprintf("SPF record has %d DNS lookups (max 10 allowed per RFC 7208)", lookups))
|
||||
}
|
||||
res.Details = append(res.Details, spf)
|
||||
res.Details = append(res.Details, fmt.Sprintf("DNS lookup mechanisms: %d/10", lookups))
|
||||
|
||||
if len(warnings) > 0 {
|
||||
res.Status = StatusWarn
|
||||
res.Message = "SPF record has potential issues"
|
||||
res.Details = append(res.Details, warnings...)
|
||||
} else {
|
||||
res.Status = StatusPass
|
||||
res.Message = "SPF record syntax looks valid"
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func checkDMARC(domain string, r *resolver.Resolver) CheckResult {
|
||||
start := time.Now()
|
||||
res := CheckResult{ID: "dmarc-present", Title: "DMARC Record"}
|
||||
defer func() { res.DurationMs = measureDuration(start) }()
|
||||
|
||||
dmarcName := "_dmarc." + domain
|
||||
txtRecords := getTXTRecords(dmarcName, r)
|
||||
|
||||
var dmarcRecord string
|
||||
for _, rec := range txtRecords {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
||||
dmarcRecord = rec
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dmarcRecord == "" {
|
||||
res.Status = StatusFail
|
||||
res.Message = fmt.Sprintf("No DMARC record found at %s", dmarcName)
|
||||
return res
|
||||
}
|
||||
|
||||
res.Status = StatusPass
|
||||
res.Message = "DMARC record found"
|
||||
res.Details = append(res.Details, dmarcRecord)
|
||||
return res
|
||||
}
|
||||
|
||||
func checkDMARCPolicy(domain string, r *resolver.Resolver) CheckResult {
|
||||
start := time.Now()
|
||||
res := CheckResult{ID: "dmarc-policy", Title: "DMARC Policy"}
|
||||
defer func() { res.DurationMs = measureDuration(start) }()
|
||||
|
||||
dmarcName := "_dmarc." + domain
|
||||
txtRecords := getTXTRecords(dmarcName, r)
|
||||
|
||||
var dmarcRecord string
|
||||
for _, rec := range txtRecords {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(rec)), "v=dmarc1") {
|
||||
dmarcRecord = rec
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dmarcRecord == "" {
|
||||
res.Status = StatusInfo
|
||||
res.Message = "No DMARC record to evaluate"
|
||||
return res
|
||||
}
|
||||
|
||||
// Parse key=value tags.
|
||||
tags := parseDMARCTags(dmarcRecord)
|
||||
res.Details = append(res.Details, dmarcRecord)
|
||||
|
||||
policy := strings.ToLower(tags["p"])
|
||||
switch policy {
|
||||
case "reject":
|
||||
res.Status = StatusPass
|
||||
res.Message = "DMARC policy is 'reject' (strongest protection)"
|
||||
case "quarantine":
|
||||
res.Status = StatusPass
|
||||
res.Message = "DMARC policy is 'quarantine' (good protection)"
|
||||
case "none":
|
||||
res.Status = StatusWarn
|
||||
res.Message = "DMARC policy is 'none' (monitoring only, no enforcement)"
|
||||
default:
|
||||
res.Status = StatusWarn
|
||||
res.Message = fmt.Sprintf("DMARC policy tag missing or unrecognized: '%s'", policy)
|
||||
}
|
||||
|
||||
// Check subdomain policy.
|
||||
if sp, ok := tags["sp"]; ok {
|
||||
res.Details = append(res.Details, fmt.Sprintf("Subdomain policy (sp): %s", sp))
|
||||
}
|
||||
|
||||
// Check reporting.
|
||||
if rua, ok := tags["rua"]; ok {
|
||||
res.Details = append(res.Details, fmt.Sprintf("Aggregate reports (rua): %s", rua))
|
||||
} else {
|
||||
res.Details = append(res.Details, "No aggregate report address (rua) — consider adding one")
|
||||
}
|
||||
if ruf, ok := tags["ruf"]; ok {
|
||||
res.Details = append(res.Details, fmt.Sprintf("Forensic reports (ruf): %s", ruf))
|
||||
}
|
||||
|
||||
// Check percentage.
|
||||
if pct, ok := tags["pct"]; ok && pct != "100" {
|
||||
res.Details = append(res.Details, fmt.Sprintf("pct=%s — not all messages are subject to policy", pct))
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func parseDMARCTags(record string) map[string]string {
|
||||
tags := make(map[string]string)
|
||||
// Remove v=DMARC1 prefix.
|
||||
parts := strings.Split(record, ";")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if eq := strings.IndexByte(part, '='); eq > 0 {
|
||||
key := strings.TrimSpace(part[:eq])
|
||||
val := strings.TrimSpace(part[eq+1:])
|
||||
tags[strings.ToLower(key)] = val
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func checkDKIM(domain string, r *resolver.Resolver) CheckResult {
|
||||
start := time.Now()
|
||||
res := CheckResult{ID: "dkim-present", Title: "DKIM Records"}
|
||||
defer func() { res.DurationMs = measureDuration(start) }()
|
||||
|
||||
// DKIM selectors are not discoverable — check common ones.
|
||||
commonSelectors := []string{
|
||||
"default",
|
||||
"google",
|
||||
"selector1", // Microsoft 365
|
||||
"selector2", // Microsoft 365
|
||||
"k1", // Mailchimp
|
||||
"s1",
|
||||
"s2",
|
||||
"dkim",
|
||||
"mail",
|
||||
"smtp",
|
||||
"mandrill", // Mailchimp Transactional
|
||||
"cm", // Campaign Monitor
|
||||
"sig1",
|
||||
"amazonses", // Amazon SES
|
||||
}
|
||||
|
||||
var found []string
|
||||
for _, sel := range commonSelectors {
|
||||
dkimName := sel + "._domainkey." + domain
|
||||
txtRecords := getTXTRecords(dkimName, r)
|
||||
for _, rec := range txtRecords {
|
||||
lower := strings.ToLower(rec)
|
||||
if strings.Contains(lower, "v=dkim1") || strings.Contains(lower, "p=") {
|
||||
found = append(found, fmt.Sprintf("%s: %s", sel, truncate(rec, 80)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(found) > 0 {
|
||||
res.Status = StatusPass
|
||||
res.Message = fmt.Sprintf("DKIM record(s) found for %d selector(s)", len(found))
|
||||
res.Details = found
|
||||
} else {
|
||||
res.Status = StatusInfo
|
||||
res.Message = "No DKIM records found for common selectors (may use custom selector)"
|
||||
res.Details = append(res.Details, "Checked selectors: "+strings.Join(commonSelectors, ", "))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user