happyDeliver/pkg/analyzer/dns.go

719 lines
19 KiB
Go

// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// DNSAnalyzer analyzes DNS records for email domains
type DNSAnalyzer struct {
Timeout time.Duration
resolver *net.Resolver
}
// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout
func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
if timeout == 0 {
timeout = 10 * time.Second // Default timeout
}
return &DNSAnalyzer{
Timeout: timeout,
resolver: &net.Resolver{
PreferGo: true,
},
}
}
// DNSResults represents DNS validation results for an email
type DNSResults struct {
Domain string
MXRecords []MXRecord
SPFRecord *SPFRecord
DKIMRecords []DKIMRecord
DMARCRecord *DMARCRecord
BIMIRecord *BIMIRecord
Errors []string
}
// MXRecord represents an MX record
type MXRecord struct {
Host string
Priority uint16
Valid bool
Error string
}
// SPFRecord represents an SPF record
type SPFRecord struct {
Record string
Valid bool
Error string
}
// DKIMRecord represents a DKIM record
type DKIMRecord struct {
Selector string
Domain string
Record string
Valid bool
Error string
}
// DMARCRecord represents a DMARC record
type DMARCRecord struct {
Record string
Policy string // none, quarantine, reject
Valid bool
Error string
}
// BIMIRecord represents a BIMI record
type BIMIRecord struct {
Selector string
Domain string
Record string
LogoURL string // URL to the brand logo (SVG)
VMCURL string // URL to Verified Mark Certificate (optional)
Valid bool
Error string
}
// AnalyzeDNS performs DNS validation for the email's domain
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
// Extract domain from From address
domain := d.extractDomain(email)
if domain == "" {
return &DNSResults{
Errors: []string{"Unable to extract domain from email"},
}
}
results := &DNSResults{
Domain: domain,
}
// Check MX records
results.MXRecords = d.checkMXRecords(domain)
// Check SPF record
results.SPFRecord = d.checkSPFRecord(domain)
// Check DKIM records (from authentication results)
if authResults != nil && authResults.Dkim != nil {
for _, dkim := range *authResults.Dkim {
if dkim.Domain != nil && dkim.Selector != nil {
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
if dkimRecord != nil {
results.DKIMRecords = append(results.DKIMRecords, *dkimRecord)
}
}
}
}
// Check DMARC record
results.DMARCRecord = d.checkDMARCRecord(domain)
// Check BIMI record (using default selector)
results.BIMIRecord = d.checkBIMIRecord(domain, "default")
return results
}
// extractDomain extracts the domain from the email's From address
func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string {
if email.From != nil && email.From.Address != "" {
parts := strings.Split(email.From.Address, "@")
if len(parts) == 2 {
return strings.ToLower(strings.TrimSpace(parts[1]))
}
}
return ""
}
// checkMXRecords looks up MX records for a domain
func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
mxRecords, err := d.resolver.LookupMX(ctx, domain)
if err != nil {
return []MXRecord{
{
Valid: false,
Error: fmt.Sprintf("Failed to lookup MX records: %v", err),
},
}
}
if len(mxRecords) == 0 {
return []MXRecord{
{
Valid: false,
Error: "No MX records found",
},
}
}
var results []MXRecord
for _, mx := range mxRecords {
results = append(results, MXRecord{
Host: mx.Host,
Priority: mx.Pref,
Valid: true,
})
}
return results
}
// checkSPFRecord looks up and validates SPF record for a domain
func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
if err != nil {
return &SPFRecord{
Valid: false,
Error: fmt.Sprintf("Failed to lookup TXT records: %v", err),
}
}
// Find SPF record (starts with "v=spf1")
var spfRecord string
spfCount := 0
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=spf1") {
spfRecord = txt
spfCount++
}
}
if spfCount == 0 {
return &SPFRecord{
Valid: false,
Error: "No SPF record found",
}
}
if spfCount > 1 {
return &SPFRecord{
Record: spfRecord,
Valid: false,
Error: "Multiple SPF records found (RFC violation)",
}
}
// Basic validation
if !d.validateSPF(spfRecord) {
return &SPFRecord{
Record: spfRecord,
Valid: false,
Error: "SPF record appears malformed",
}
}
return &SPFRecord{
Record: spfRecord,
Valid: true,
}
}
// validateSPF performs basic SPF record validation
func (d *DNSAnalyzer) validateSPF(record string) bool {
// Must start with v=spf1
if !strings.HasPrefix(record, "v=spf1") {
return false
}
// Check for common syntax issues
// Should have a final mechanism (all, +all, -all, ~all, ?all)
validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
hasValidEnding := false
for _, ending := range validEndings {
if strings.HasSuffix(record, ending) {
hasValidEnding = true
break
}
}
return hasValidEnding
}
// checkDKIMRecord looks up and validates DKIM record for a domain and selector
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord {
// DKIM records are at: selector._domainkey.domain
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err),
}
}
if len(txtRecords) == 0 {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: "No DKIM record found",
}
}
// Concatenate all TXT record parts (DKIM can be split)
dkimRecord := strings.Join(txtRecords, "")
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
if !d.validateDKIM(dkimRecord) {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Record: dkimRecord,
Valid: false,
Error: "DKIM record appears malformed",
}
}
return &DKIMRecord{
Selector: selector,
Domain: domain,
Record: dkimRecord,
Valid: true,
}
}
// validateDKIM performs basic DKIM record validation
func (d *DNSAnalyzer) validateDKIM(record string) bool {
// Should contain p= tag (public key)
if !strings.Contains(record, "p=") {
return false
}
// Often contains v=DKIM1 but not required
// If v= is present, it should be DKIM1
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
return false
}
return true
}
// checkDMARCRecord looks up and validates DMARC record for a domain
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord {
// DMARC records are at: _dmarc.domain
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
if err != nil {
return &DMARCRecord{
Valid: false,
Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err),
}
}
// Find DMARC record (starts with "v=DMARC1")
var dmarcRecord string
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
dmarcRecord = txt
break
}
}
if dmarcRecord == "" {
return &DMARCRecord{
Valid: false,
Error: "No DMARC record found",
}
}
// Extract policy
policy := d.extractDMARCPolicy(dmarcRecord)
// Basic validation
if !d.validateDMARC(dmarcRecord) {
return &DMARCRecord{
Record: dmarcRecord,
Policy: policy,
Valid: false,
Error: "DMARC record appears malformed",
}
}
return &DMARCRecord{
Record: dmarcRecord,
Policy: policy,
Valid: true,
}
}
// extractDMARCPolicy extracts the policy from a DMARC record
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
// Look for p=none, p=quarantine, or p=reject
re := regexp.MustCompile(`p=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return matches[1]
}
return "unknown"
}
// validateDMARC performs basic DMARC record validation
func (d *DNSAnalyzer) validateDMARC(record string) bool {
// Must start with v=DMARC1
if !strings.HasPrefix(record, "v=DMARC1") {
return false
}
// Must have a policy tag
if !strings.Contains(record, "p=") {
return false
}
return true
}
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord {
// BIMI records are at: selector._bimi.domain
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
if err != nil {
return &BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err),
}
}
if len(txtRecords) == 0 {
return &BIMIRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: "No BIMI record found",
}
}
// Concatenate all TXT record parts (BIMI can be split)
bimiRecord := strings.Join(txtRecords, "")
// Extract logo URL and VMC URL
logoURL := d.extractBIMITag(bimiRecord, "l")
vmcURL := d.extractBIMITag(bimiRecord, "a")
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
if !d.validateBIMI(bimiRecord) {
return &BIMIRecord{
Selector: selector,
Domain: domain,
Record: bimiRecord,
LogoURL: logoURL,
VMCURL: vmcURL,
Valid: false,
Error: "BIMI record appears malformed",
}
}
return &BIMIRecord{
Selector: selector,
Domain: domain,
Record: bimiRecord,
LogoURL: logoURL,
VMCURL: vmcURL,
Valid: true,
}
}
// extractBIMITag extracts a tag value from a BIMI record
func (d *DNSAnalyzer) extractBIMITag(record, tag string) string {
// Look for tag=value pattern
re := regexp.MustCompile(tag + `=([^;]+)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return ""
}
// validateBIMI performs basic BIMI record validation
func (d *DNSAnalyzer) validateBIMI(record string) bool {
// Must start with v=BIMI1
if !strings.HasPrefix(record, "v=BIMI1") {
return false
}
// Must have a logo URL tag (l=)
if !strings.Contains(record, "l=") {
return false
}
return true
}
// GenerateDNSChecks generates check results for DNS validation
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// MX record check
checks = append(checks, d.generateMXCheck(results))
// SPF record check
if results.SPFRecord != nil {
checks = append(checks, d.generateSPFCheck(results.SPFRecord))
}
// DKIM record checks
for _, dkim := range results.DKIMRecords {
checks = append(checks, d.generateDKIMCheck(&dkim))
}
// DMARC record check
if results.DMARCRecord != nil {
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
}
// BIMI record check (optional)
if results.BIMIRecord != nil {
checks = append(checks, d.generateBIMICheck(results.BIMIRecord))
}
return checks
}
// generateMXCheck creates a check for MX records
func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
check := api.Check{
Category: api.Dns,
Name: "MX Records",
}
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityCritical)
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
check.Message = results.MXRecords[0].Error
} else {
check.Message = "No valid MX records found"
}
check.Advice = api.PtrTo("Configure MX records for your domain to receive email")
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
// Add details about MX records
var mxList []string
for _, mx := range results.MXRecords {
mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority))
}
details := strings.Join(mxList, ", ")
check.Details = &details
check.Advice = api.PtrTo("Your MX records are properly configured")
}
return check
}
// generateSPFCheck creates a check for SPF records
func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "SPF Record",
}
if !spf.Valid {
// If no record exists at all, it's a failure
if spf.Record == "" {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = spf.Error
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
} else {
// If record exists but is invalid, it's a warning
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Message = "SPF record found but appears invalid"
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
check.Details = &spf.Record
}
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "Valid SPF record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &spf.Record
check.Advice = api.PtrTo("Your SPF record is properly configured")
}
return check
}
// generateDKIMCheck creates a check for DKIM records
func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector),
}
if !dkim.Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "Valid DKIM record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
check.Details = &details
check.Advice = api.PtrTo("Your DKIM record is properly published")
}
return check
}
// generateDMARCCheck creates a check for DMARC records
func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "DMARC Record",
}
if !dmarc.Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = dmarc.Error
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &dmarc.Record
// Provide advice based on policy
switch dmarc.Policy {
case "none":
advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection"
check.Advice = &advice
case "quarantine":
advice := "DMARC policy is set to 'quarantine'. This provides good protection"
check.Advice = &advice
case "reject":
advice := "DMARC policy is set to 'reject'. This provides the strongest protection"
check.Advice = &advice
default:
advice := "Your DMARC record is properly configured"
check.Advice = &advice
}
}
return check
}
// generateBIMICheck creates a check for BIMI records
func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "BIMI Record",
}
if !bimi.Valid {
// BIMI is optional, so missing record is just informational
if bimi.Record == "" {
check.Status = api.CheckStatusInfo
check.Score = 0.0
check.Message = "No BIMI record found (optional)"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)")
} else {
// If record exists but is invalid
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)")
check.Details = &bimi.Record
}
} else {
check.Status = api.CheckStatusPass
check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
check.Message = "Valid BIMI record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
// Build details with logo and VMC URLs
var detailsParts []string
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector))
if bimi.LogoURL != "" {
detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL))
}
if bimi.VMCURL != "" {
detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL))
check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate")
} else {
check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust")
}
details := strings.Join(detailsParts, ", ")
check.Details = &details
}
return check
}