checker-smtp/checker/issues.go

288 lines
12 KiB
Go

package checker
import (
"fmt"
"strings"
)
// deriveIssues distils observation data into a sorted list of findings
// that the rule reduces to a CheckState and the HTML report renders as
// the "What to fix" panel.
//
// The function is pure: it reads data and returns a slice, so it is
// trivially testable and stable across runs.
func deriveIssues(data *SMTPData) []Issue {
var issues []Issue
// 1. MX / DNS scope.
switch {
case data.MX.NullMX:
issues = append(issues, Issue{
Code: CodeNullMX,
Severity: SeverityInfo,
Message: "Domain advertises a null MX (RFC 7505): it explicitly refuses all email.",
Fix: "If this is intentional (the domain sends but does not receive mail), no action needed. Otherwise, remove the '.' MX record and publish real mail exchangers.",
})
return issues
case data.MX.Error != "":
issues = append(issues, Issue{
Code: CodeMXLookupFailed,
Severity: SeverityCrit,
Message: "MX lookup failed: " + data.MX.Error,
Fix: "Check the authoritative DNS servers for this domain.",
})
case data.MX.ImplicitMX:
issues = append(issues, Issue{
Code: CodeImplicitMX,
Severity: SeverityWarn,
Message: "No MX record published; senders will fall back to the A/AAAA of the bare domain (implicit MX).",
Fix: "Publish explicit MX records so you can separate web and mail servers and so anti-spam signals apply correctly.",
})
case len(data.MX.Records) == 0:
issues = append(issues, Issue{
Code: CodeNoMX,
Severity: SeverityCrit,
Message: "No MX record found for " + data.Domain + ".",
Fix: "Publish at least one MX record pointing to a reachable mail server, or a null MX ('.' with preference 0) if the domain must not receive mail.",
})
}
for _, rec := range data.MX.Records {
if rec.IsIPLiteral {
issues = append(issues, Issue{
Code: CodeMXIPLiteral,
Severity: SeverityCrit,
Message: fmt.Sprintf("MX target %q is an IP address; RFC 5321 § 5.1 requires a hostname.", rec.Target),
Fix: "Publish an A/AAAA record under a hostname (e.g. mail." + data.Domain + ") and point the MX at that name.",
Target: rec.Target,
})
}
if rec.IsCNAME {
issues = append(issues, Issue{
Code: CodeMXCNAME,
Severity: SeverityWarn,
Message: fmt.Sprintf("MX target %q is a CNAME (chain: %s). RFC 5321 § 5.1 forbids this.", rec.Target, strings.Join(rec.CNAMEChain, " → ")),
Fix: "Replace the CNAME with an A/AAAA record directly on the MX target, or point the MX at the CNAME's canonical name.",
Target: rec.Target,
})
}
if rec.ResolveError != "" {
issues = append(issues, Issue{
Code: CodeMXResolveFailed,
Severity: SeverityCrit,
Message: fmt.Sprintf("Failed to resolve MX target %q: %s", rec.Target, rec.ResolveError),
Fix: "Check that " + rec.Target + " has valid A/AAAA records.",
Target: rec.Target,
})
}
if !rec.IsIPLiteral && rec.ResolveError == "" && len(rec.IPv4) == 0 && len(rec.IPv6) == 0 {
issues = append(issues, Issue{
Code: CodeNoAddresses,
Severity: SeverityCrit,
Message: fmt.Sprintf("MX target %q has no A or AAAA records.", rec.Target),
Fix: "Add at least one A or AAAA record for " + rec.Target + ".",
Target: rec.Target,
})
}
}
// 2. Endpoint-level issues.
anyConnected := false
anySTARTTLS := false
for _, ep := range data.Endpoints {
if !ep.TCPConnected {
issues = append(issues, Issue{
Code: CodeTCPUnreachable,
Severity: SeverityCrit,
Message: fmt.Sprintf("Cannot reach %s (%s): %s.", ep.Address, ep.Target, ep.Error),
Fix: "Verify firewall rules and that an SMTP service listens on port 25 of " + ep.IP + ".",
Endpoint: ep.Address,
Target: ep.Target,
})
continue
}
anyConnected = true
if !ep.BannerReceived {
issues = append(issues, Issue{
Code: CodeBannerMissing,
Severity: SeverityCrit,
Message: fmt.Sprintf("No SMTP banner received on %s (%s).", ep.Address, ep.Target),
Fix: "Confirm that the service on port 25 is actually SMTP and is not rate-limiting or blackholing the probe.",
Endpoint: ep.Address,
Target: ep.Target,
})
} else if ep.BannerCode != 220 {
issues = append(issues, Issue{
Code: CodeBannerInvalid,
Severity: SeverityCrit,
Message: fmt.Sprintf("Banner on %s returned code %d (expected 220).", ep.Address, ep.BannerCode),
Fix: "A non-220 greeting means the MTA refuses the connection. Check logs for tarpit/rate-limit rules triggered by our EHLO hostname.",
Endpoint: ep.Address,
Target: ep.Target,
})
}
if ep.BannerReceived && !ep.EHLOReceived {
issues = append(issues, Issue{
Code: CodeEHLOFailed,
Severity: SeverityCrit,
Message: fmt.Sprintf("EHLO rejected on %s: %s.", ep.Address, ep.Error),
Fix: "Check the MTA's HELO access rules. Most servers require the EHLO name to resolve; run the checker with a working EHLO hostname.",
Endpoint: ep.Address,
Target: ep.Target,
})
} else if ep.EHLOFallbackHELO {
issues = append(issues, Issue{
Code: CodeEHLOFallback,
Severity: SeverityWarn,
Message: fmt.Sprintf("Server on %s only accepts HELO, not EHLO.", ep.Address),
Fix: "Upgrade to an ESMTP-capable configuration. EHLO is mandatory for STARTTLS, PIPELINING, SIZE, DSN, 8BITMIME, SMTPUTF8…",
Endpoint: ep.Address,
Target: ep.Target,
})
}
// STARTTLS posture: MX SMTP is opportunistic, but "no TLS at all"
// is a strong signal that the operator forgot to configure it.
if ep.EHLOReceived && !ep.STARTTLSOffered {
issues = append(issues, Issue{
Code: CodeSTARTTLSMissing,
Severity: SeverityCrit,
Message: fmt.Sprintf("STARTTLS not advertised on %s; inbound mail will be delivered in cleartext.", ep.Address),
Fix: "Enable STARTTLS in your MTA (Postfix: smtpd_tls_security_level=may and a valid cert; Exim: tls_advertise_hosts=*). This is a prerequisite for DANE / MTA-STS.",
Endpoint: ep.Address,
Target: ep.Target,
})
}
if ep.STARTTLSOffered && !ep.STARTTLSUpgraded {
issues = append(issues, Issue{
Code: CodeSTARTTLSFailed,
Severity: SeverityCrit,
Message: fmt.Sprintf("STARTTLS advertised but the TLS handshake failed on %s: %s.", ep.Address, ep.Error),
Fix: "Check the server certificate and protocol versions with the TLS checker. A common cause is an expired certificate or disabled TLS 1.0/1.1 on a client that only speaks older versions.",
Endpoint: ep.Address,
Target: ep.Target,
})
}
if ep.STARTTLSUpgraded {
anySTARTTLS = true
}
// AUTH offered without TLS is a classic misconfiguration:
// a client can send credentials in cleartext.
if len(ep.AUTHPreTLS) > 0 {
issues = append(issues, Issue{
Code: CodeAUTHOverPlain,
Severity: SeverityCrit,
Message: fmt.Sprintf("AUTH (%s) advertised on %s *before* STARTTLS; credentials can be observed on the wire.", strings.Join(ep.AUTHPreTLS, ","), ep.Address),
Fix: "Disable SMTP AUTH on port 25 entirely (it's not supposed to be used for submission) or gate it behind smtpd_tls_auth_only=yes. Submission belongs on port 587.",
Endpoint: ep.Address,
Target: ep.Target,
})
}
// PTR / FCrDNS, strongly weighted by anti-spam.
if ep.PTR == "" {
issues = append(issues, Issue{
Code: CodePTRMissing,
Severity: SeverityWarn,
Message: fmt.Sprintf("No PTR record for %s. Many receivers (Gmail, Outlook, Yahoo) reject mail from IPs without reverse DNS.", ep.IP),
Fix: "Set a PTR record on " + ep.IP + " at your hosting provider. It should match the EHLO name announced by the MTA.",
Endpoint: ep.Address,
Target: ep.Target,
})
} else if !ep.FCrDNSPass {
issues = append(issues, Issue{
Code: CodeFCrDNSMismatch,
Severity: SeverityWarn,
Message: fmt.Sprintf("FCrDNS fails on %s: PTR %q does not resolve back to this IP.", ep.IP, ep.PTR),
Fix: "Either fix the PTR to point at a hostname whose A/AAAA resolves to " + ep.IP + ", or add the missing A/AAAA on the existing PTR target.",
Endpoint: ep.Address,
Target: ep.Target,
})
}
// Null sender / postmaster / open relay.
if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted {
issues = append(issues, Issue{
Code: CodeNullSenderReject,
Severity: SeverityCrit,
Message: fmt.Sprintf("Server on %s rejects MAIL FROM:<> (response: %s).", ep.Address, ep.NullSenderResponse),
Fix: "RFC 5321 mandates that bounces (DSNs) use the null sender. Refusing it means you cannot receive bounce reports, and any legitimate DSN will be lost.",
Endpoint: ep.Address,
Target: ep.Target,
})
}
if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted {
issues = append(issues, Issue{
Code: CodePostmasterReject,
Severity: SeverityCrit,
Message: fmt.Sprintf("Server on %s rejects RCPT TO:<postmaster@%s> (response: %s).", ep.Address, data.Domain, ep.PostmasterResponse),
Fix: "RFC 5321 § 4.5.1 requires every SMTP receiver to accept mail for postmaster. Create the mailbox (or an alias to the team's inbox).",
Endpoint: ep.Address,
Target: ep.Target,
})
}
if ep.OpenRelay != nil && *ep.OpenRelay {
issues = append(issues, Issue{
Code: CodeOpenRelay,
Severity: SeverityCrit,
Message: fmt.Sprintf("OPEN RELAY: %s accepted RCPT TO:<%s> from the probe. Spammers can use this server to send arbitrary mail.", ep.Address, ep.OpenRelayRecipient),
Fix: "Restrict relaying to authenticated users only. In Postfix set smtpd_relay_restrictions accordingly; in Exim, require `acl_smtp_rcpt` to check local domains first.",
Endpoint: ep.Address,
Target: ep.Target,
})
}
// Minor posture issues.
if ep.EHLOReceived && !ep.HasPipelining {
issues = append(issues, Issue{
Code: CodeNoPipelining,
Severity: SeverityInfo,
Message: fmt.Sprintf("PIPELINING not advertised on %s.", ep.Address),
Fix: "Enable ESMTP PIPELINING; it materially reduces the number of network round-trips for each delivery.",
Endpoint: ep.Address,
Target: ep.Target,
})
}
if ep.EHLOReceived && !ep.Has8BITMIME {
issues = append(issues, Issue{
Code: CodeNo8BITMIME,
Severity: SeverityInfo,
Message: fmt.Sprintf("8BITMIME not advertised on %s.", ep.Address),
Fix: "Enable 8BITMIME support; without it, senders must MIME-encode non-ASCII bodies or risk rewrites.",
Endpoint: ep.Address,
Target: ep.Target,
})
}
}
if len(data.Endpoints) > 0 && !anyConnected {
issues = append(issues, Issue{
Code: CodeAllEndpointsDown,
Severity: SeverityCrit,
Message: "None of the MX targets accepted a TCP connection on port 25.",
Fix: "Confirm the mail servers are running and that their network path is open to the internet on port 25.",
})
}
if anyConnected && !anySTARTTLS {
issues = append(issues, Issue{
Code: CodeAllNoSTARTTLS,
Severity: SeverityCrit,
Message: "No MX endpoint advertises a working STARTTLS. All inbound mail is delivered in cleartext.",
Fix: "Enable STARTTLS on every MX endpoint (a valid certificate is needed). Once this is done, consider publishing MTA-STS and TLSA/DANE records for strict enforcement.",
})
}
// IPv6 coverage (info only, since IPv4 is still the dominant path).
if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 {
issues = append(issues, Issue{
Code: CodeNoIPv6,
Severity: SeverityInfo,
Message: "No MX endpoint reachable over IPv6.",
Fix: "Publish AAAA records for your MX targets; Gmail, Outlook and Yahoo prefer IPv6-capable receivers.",
})
}
return issues
}