405 lines
13 KiB
Go
405 lines
13 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Severity mirrors the table in PLAN.md section 15.
|
|
type Severity string
|
|
|
|
const (
|
|
SeverityCritical Severity = "CRITICAL"
|
|
SeverityHigh Severity = "HIGH"
|
|
SeverityMedium Severity = "MEDIUM"
|
|
)
|
|
|
|
// CheckStatus is the outcome of one preflight check.
|
|
type CheckStatus string
|
|
|
|
const (
|
|
CheckPassed CheckStatus = "PASS"
|
|
CheckFailed CheckStatus = "FAIL"
|
|
CheckSkipped CheckStatus = "SKIP"
|
|
)
|
|
|
|
// CheckResult is one line of the preflight report.
|
|
type CheckResult struct {
|
|
Name string
|
|
Status CheckStatus
|
|
Severity Severity
|
|
Detail string
|
|
// Remedy is the operator action that fixes it. Several of these checks
|
|
// cover steps PLAN.md section 7.4 lists as not automatable, so telling
|
|
// the operator exactly what to do is the point of the check.
|
|
Remedy string
|
|
}
|
|
|
|
// PreflightReport aggregates the checks.
|
|
type PreflightReport struct {
|
|
Results []CheckResult
|
|
}
|
|
|
|
// Blocking reports whether any critical check failed. PLAN.md section 15 uses
|
|
// this to keep a domain out of the VERIFIED state.
|
|
func (r PreflightReport) Blocking() bool {
|
|
for _, result := range r.Results {
|
|
if result.Status == CheckFailed && result.Severity == SeverityCritical {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (r PreflightReport) String() string {
|
|
var sb strings.Builder
|
|
for _, result := range r.Results {
|
|
fmt.Fprintf(&sb, "[%-4s] %-10s %s", result.Status, result.Severity, result.Name)
|
|
if result.Detail != "" {
|
|
fmt.Fprintf(&sb, "\n %s", result.Detail)
|
|
}
|
|
if result.Status == CheckFailed && result.Remedy != "" {
|
|
fmt.Fprintf(&sb, "\n remedy: %s", result.Remedy)
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// PreflightDeps are the environment probes the checks need. They are injected
|
|
// so the whole preflight is testable without touching the network.
|
|
type PreflightDeps struct {
|
|
// DialPort25 reports whether outbound port 25 reaches a known MX.
|
|
DialPort25 func(ctx context.Context, address string) error
|
|
// LookupAddr resolves the PTR of an IP.
|
|
LookupAddr func(ctx context.Context, ip string) ([]string, error)
|
|
// LookupHost resolves a name to addresses.
|
|
LookupHost func(ctx context.Context, host string) ([]string, error)
|
|
// LookupTXT resolves TXT records, used for the DNSBL checks.
|
|
LookupTXT func(ctx context.Context, name string) ([]string, error)
|
|
// HasCertificateFor reports whether a certificate is loaded for a host.
|
|
HasCertificateFor func(host string) bool
|
|
// FetchMtaSts fetches the served MTA-STS policy over HTTPS.
|
|
FetchMtaSts func(ctx context.Context, domain string) error
|
|
// FreeDiskPercent reports the free space on the mail spool.
|
|
FreeDiskPercent func() (float64, error)
|
|
// Now is the local clock; drift against NTP breaks DKIM and TOTP.
|
|
Now func() time.Time
|
|
// NetworkTime returns a trusted time to compare the local clock against.
|
|
NetworkTime func(ctx context.Context) (time.Time, error)
|
|
}
|
|
|
|
// PreflightInput describes the deployment being checked.
|
|
type PreflightInput struct {
|
|
PrimaryMailHost string
|
|
MailDomain string
|
|
PublicIPv4 string
|
|
PublicIPv6 string
|
|
DaneEnabled bool
|
|
TLSMode string
|
|
SmarthostSet bool
|
|
}
|
|
|
|
// PreflightUseCase implements PLAN.md section 15.
|
|
type PreflightUseCase struct {
|
|
deps PreflightDeps
|
|
}
|
|
|
|
func NewPreflightUseCase(deps PreflightDeps) *PreflightUseCase {
|
|
return &PreflightUseCase{deps: deps}
|
|
}
|
|
|
|
// wellKnownMX is the probe target of PLAN.md section 15. Google is used
|
|
// because it is the destination most likely to matter and least likely to be
|
|
// down.
|
|
const wellKnownMX = "gmail-smtp-in.l.google.com:25"
|
|
|
|
// dnsblZones are the reputation lists PLAN.md section 5.2 names.
|
|
var dnsblZones = map[string]string{
|
|
"zen.spamhaus.org": "Spamhaus ZEN",
|
|
"b.barracudacentral.org": "Barracuda",
|
|
"dnsbl.sorbs.net": "SORBS",
|
|
}
|
|
|
|
func (uc *PreflightUseCase) Execute(ctx context.Context, input PreflightInput) PreflightReport {
|
|
report := PreflightReport{}
|
|
add := func(r CheckResult) { report.Results = append(report.Results, r) }
|
|
|
|
add(uc.checkPort25(ctx, input))
|
|
add(uc.checkReverseDNS(ctx, input))
|
|
add(uc.checkForwardConfirmedReverseDNS(ctx, input))
|
|
add(uc.checkBlocklists(ctx, input))
|
|
add(uc.checkCertificate(input))
|
|
add(uc.checkDaneRequiresAcmeMode(input))
|
|
add(uc.checkMtaStsEndpoint(ctx, input))
|
|
add(uc.checkClockDrift(ctx))
|
|
add(uc.checkDiskSpace())
|
|
add(uc.checkIPv6Coherence(ctx, input))
|
|
|
|
return report
|
|
}
|
|
|
|
func (uc *PreflightUseCase) checkPort25(ctx context.Context, input PreflightInput) CheckResult {
|
|
const name = "Outbound port 25 reaches a public MX"
|
|
|
|
if input.SmarthostSet {
|
|
return CheckResult{
|
|
Name: name, Status: CheckSkipped, Severity: SeverityCritical,
|
|
Detail: "a smarthost is configured, so direct delivery on port 25 is not used",
|
|
}
|
|
}
|
|
if uc.deps.DialPort25 == nil {
|
|
return skipped(name, SeverityCritical)
|
|
}
|
|
|
|
if err := uc.deps.DialPort25(ctx, wellKnownMX); err != nil {
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
|
Detail: fmt.Sprintf("could not reach %s: %v", wellKnownMX, err),
|
|
Remedy: "most VPS providers block egress on port 25 by default; ask the provider to unblock it, or configure RELAY_HOST to deliver through a smarthost",
|
|
}
|
|
}
|
|
return passed(name, SeverityCritical, "")
|
|
}
|
|
|
|
func (uc *PreflightUseCase) checkReverseDNS(ctx context.Context, input PreflightInput) CheckResult {
|
|
const name = "PTR of the public IP resolves to the mail host"
|
|
|
|
if uc.deps.LookupAddr == nil || input.PublicIPv4 == "" {
|
|
return skipped(name, SeverityCritical)
|
|
}
|
|
|
|
names, err := uc.deps.LookupAddr(ctx, input.PublicIPv4)
|
|
if err != nil || len(names) == 0 {
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
|
Detail: fmt.Sprintf("no PTR record for %s", input.PublicIPv4),
|
|
Remedy: "set the PTR in the hosting provider's panel; reverse delegation belongs to the owner of the IP block, not to the DNS provider",
|
|
}
|
|
}
|
|
|
|
for _, candidate := range names {
|
|
if strings.EqualFold(strings.TrimSuffix(candidate, "."), input.PrimaryMailHost) {
|
|
return passed(name, SeverityCritical, "")
|
|
}
|
|
}
|
|
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
|
Detail: fmt.Sprintf("PTR of %s is %v, want %s", input.PublicIPv4, names, input.PrimaryMailHost),
|
|
Remedy: "point the PTR at " + input.PrimaryMailHost + " in the hosting provider's panel",
|
|
}
|
|
}
|
|
|
|
func (uc *PreflightUseCase) checkForwardConfirmedReverseDNS(ctx context.Context, input PreflightInput) CheckResult {
|
|
const name = "FCrDNS: the mail host resolves back to the public IP"
|
|
|
|
if uc.deps.LookupHost == nil || input.PublicIPv4 == "" {
|
|
return skipped(name, SeverityCritical)
|
|
}
|
|
|
|
addresses, err := uc.deps.LookupHost(ctx, input.PrimaryMailHost)
|
|
if err != nil {
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
|
Detail: fmt.Sprintf("cannot resolve %s: %v", input.PrimaryMailHost, err),
|
|
Remedy: "publish the A record for " + input.PrimaryMailHost,
|
|
}
|
|
}
|
|
|
|
for _, address := range addresses {
|
|
if address == input.PublicIPv4 {
|
|
return passed(name, SeverityCritical, "")
|
|
}
|
|
}
|
|
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
|
Detail: fmt.Sprintf("%s resolves to %v, not to %s", input.PrimaryMailHost, addresses, input.PublicIPv4),
|
|
Remedy: "make the A record and the PTR agree; Gmail and Outlook reject or spam-file mail without forward-confirmed reverse DNS",
|
|
}
|
|
}
|
|
|
|
func (uc *PreflightUseCase) checkBlocklists(ctx context.Context, input PreflightInput) CheckResult {
|
|
const name = "Public IP is not on a major blocklist"
|
|
|
|
if uc.deps.LookupTXT == nil || input.PublicIPv4 == "" {
|
|
return skipped(name, SeverityCritical)
|
|
}
|
|
|
|
reversed := reverseIPv4(input.PublicIPv4)
|
|
if reversed == "" {
|
|
return skipped(name, SeverityCritical)
|
|
}
|
|
|
|
var listed []string
|
|
// Sorted so the report is stable between runs.
|
|
zones := make([]string, 0, len(dnsblZones))
|
|
for zone := range dnsblZones {
|
|
zones = append(zones, zone)
|
|
}
|
|
sort.Strings(zones)
|
|
|
|
for _, zone := range zones {
|
|
if records, err := uc.deps.LookupTXT(ctx, reversed+"."+zone); err == nil && len(records) > 0 {
|
|
listed = append(listed, dnsblZones[zone])
|
|
}
|
|
}
|
|
|
|
if len(listed) > 0 {
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
|
Detail: fmt.Sprintf("%s is listed on: %s", input.PublicIPv4, strings.Join(listed, ", ")),
|
|
Remedy: "request delisting with each provider before sending, or move to an IP with clean reputation",
|
|
}
|
|
}
|
|
return passed(name, SeverityCritical, "")
|
|
}
|
|
|
|
func (uc *PreflightUseCase) checkCertificate(input PreflightInput) CheckResult {
|
|
const name = "A TLS certificate is loaded for the mail host"
|
|
|
|
if uc.deps.HasCertificateFor == nil {
|
|
return skipped(name, SeverityCritical)
|
|
}
|
|
if uc.deps.HasCertificateFor(input.PrimaryMailHost) {
|
|
return passed(name, SeverityCritical, "")
|
|
}
|
|
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
|
Detail: fmt.Sprintf("no certificate for %s", input.PrimaryMailHost),
|
|
Remedy: "Traefik only requests certificates for hosts it routes; declare an HTTP router with Host(`" + input.PrimaryMailHost + "`) so ACME issues one",
|
|
}
|
|
}
|
|
|
|
func (uc *PreflightUseCase) checkDaneRequiresAcmeMode(input PreflightInput) CheckResult {
|
|
const name = "DANE is only enabled in self-managed ACME mode"
|
|
|
|
if !input.DaneEnabled {
|
|
return CheckResult{
|
|
Name: name, Status: CheckSkipped, Severity: SeverityCritical,
|
|
Detail: "DANE is disabled",
|
|
}
|
|
}
|
|
if strings.EqualFold(input.TLSMode, "acme") {
|
|
return passed(name, SeverityCritical, "")
|
|
}
|
|
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityCritical,
|
|
Detail: "DANE is enabled while certificates come from Traefik",
|
|
Remedy: "Traefik cannot sign a CSR with a pre-generated key, so the TLSA record would stop matching at the next renewal and every validating MTA would reject mail; set TLS_MODE=acme or disable DANE",
|
|
}
|
|
}
|
|
|
|
func (uc *PreflightUseCase) checkMtaStsEndpoint(ctx context.Context, input PreflightInput) CheckResult {
|
|
const name = "MTA-STS policy is served over HTTPS with a valid certificate"
|
|
|
|
if uc.deps.FetchMtaSts == nil || input.MailDomain == "" {
|
|
return skipped(name, SeverityHigh)
|
|
}
|
|
if err := uc.deps.FetchMtaSts(ctx, input.MailDomain); err != nil {
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityHigh,
|
|
Detail: fmt.Sprintf("cannot fetch the policy for %s: %v", input.MailDomain, err),
|
|
Remedy: "publish the mta-sts host and route it through the proxy with a valid certificate; senders that cannot fetch the policy fall back to unprotected delivery",
|
|
}
|
|
}
|
|
return passed(name, SeverityHigh, "")
|
|
}
|
|
|
|
func (uc *PreflightUseCase) checkClockDrift(ctx context.Context) CheckResult {
|
|
const name = "System clock is synchronised"
|
|
const maxDrift = 5 * time.Second
|
|
|
|
if uc.deps.NetworkTime == nil || uc.deps.Now == nil {
|
|
return skipped(name, SeverityHigh)
|
|
}
|
|
|
|
networkTime, err := uc.deps.NetworkTime(ctx)
|
|
if err != nil {
|
|
return skipped(name, SeverityHigh)
|
|
}
|
|
|
|
drift := uc.deps.Now().Sub(networkTime)
|
|
if drift < 0 {
|
|
drift = -drift
|
|
}
|
|
if drift > maxDrift {
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityHigh,
|
|
Detail: fmt.Sprintf("local clock is off by %v", drift.Round(time.Millisecond)),
|
|
Remedy: "enable NTP on the host; DKIM signature timestamps and TOTP codes both depend on an accurate clock",
|
|
}
|
|
}
|
|
return passed(name, SeverityHigh, fmt.Sprintf("drift %v", drift.Round(time.Millisecond)))
|
|
}
|
|
|
|
func (uc *PreflightUseCase) checkDiskSpace() CheckResult {
|
|
const name = "Mail spool has more than 20% free space"
|
|
|
|
if uc.deps.FreeDiskPercent == nil {
|
|
return skipped(name, SeverityMedium)
|
|
}
|
|
|
|
free, err := uc.deps.FreeDiskPercent()
|
|
if err != nil {
|
|
return skipped(name, SeverityMedium)
|
|
}
|
|
if free < 20 {
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityMedium,
|
|
Detail: fmt.Sprintf("%.1f%% free", free),
|
|
Remedy: "free space or grow the volume; a full spool means accepted mail cannot be written and has to be refused",
|
|
}
|
|
}
|
|
return passed(name, SeverityMedium, fmt.Sprintf("%.1f%% free", free))
|
|
}
|
|
|
|
func (uc *PreflightUseCase) checkIPv6Coherence(ctx context.Context, input PreflightInput) CheckResult {
|
|
const name = "IPv6 is either fully configured or disabled"
|
|
|
|
if input.PublicIPv6 == "" {
|
|
return passed(name, SeverityMedium, "IPv6 is disabled")
|
|
}
|
|
if uc.deps.LookupAddr == nil {
|
|
return skipped(name, SeverityMedium)
|
|
}
|
|
|
|
names, err := uc.deps.LookupAddr(ctx, input.PublicIPv6)
|
|
if err != nil || len(names) == 0 {
|
|
return CheckResult{
|
|
Name: name, Status: CheckFailed, Severity: SeverityMedium,
|
|
Detail: fmt.Sprintf("no PTR for %s", input.PublicIPv6),
|
|
Remedy: "publish a PTR for the IPv6 address or unset PUBLIC_IPV6; half-configured IPv6 is a common cause of rejection",
|
|
}
|
|
}
|
|
return passed(name, SeverityMedium, "")
|
|
}
|
|
|
|
func passed(name string, severity Severity, detail string) CheckResult {
|
|
return CheckResult{Name: name, Status: CheckPassed, Severity: severity, Detail: detail}
|
|
}
|
|
|
|
func skipped(name string, severity Severity) CheckResult {
|
|
return CheckResult{
|
|
Name: name, Status: CheckSkipped, Severity: severity,
|
|
Detail: "not enough configuration to run this check",
|
|
}
|
|
}
|
|
|
|
// reverseIPv4 turns "192.0.2.10" into "10.2.0.192", the form DNSBL queries
|
|
// take. It returns an empty string for anything that is not an IPv4 address.
|
|
func reverseIPv4(address string) string {
|
|
ip := net.ParseIP(address).To4()
|
|
if ip == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%d.%d.%d.%d", ip[3], ip[2], ip[1], ip[0])
|
|
}
|