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

330 lines
8.8 KiB
Go

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] + "..."
}