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
330 lines
8.8 KiB
Go
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] + "..."
|
|
}
|