288 lines
12 KiB
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
|
|
}
|