checker-smtp/checker/rule.go

214 lines
7 KiB
Go

package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the full list of CheckRules exposed by the SMTP checker.
// Each rule covers a single concern (MX present, STARTTLS offered, open
// relay, PTR/FCrDNS, …) so each shows up as an independent pass/fail line
// in the UI, instead of being buried under a single monolithic rule.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&nullMXRule{},
&simpleConcernRule{
name: "smtp.mx_present",
description: "Verifies the domain publishes at least one MX record (or a null MX).",
codes: []string{CodeMXLookupFailed, CodeImplicitMX, CodeNoMX},
passCode: "smtp.mx_present.ok",
passMessage: "Domain publishes explicit MX records.",
},
&simpleConcernRule{
name: "smtp.mx_sanity",
description: "Flags MX targets that violate RFC 5321 § 5.1 (IP literals, CNAME chains, unresolved names).",
codes: []string{CodeMXIPLiteral, CodeMXCNAME, CodeMXResolveFailed, CodeNoAddresses},
passCode: "smtp.mx_sanity.ok",
passMessage: "MX targets resolve cleanly and are regular hostnames.",
},
&simpleConcernRule{
name: "smtp.endpoint_reachable",
description: "Verifies every MX endpoint accepts a TCP connection on port 25.",
codes: []string{CodeTCPUnreachable, CodeAllEndpointsDown},
passCode: "smtp.endpoint_reachable.ok",
passMessage: "All MX endpoints are reachable on port 25.",
},
&simpleConcernRule{
name: "smtp.banner_sanity",
description: "Verifies every reachable endpoint emits a 220 SMTP greeting.",
codes: []string{CodeBannerMissing, CodeBannerInvalid},
passCode: "smtp.banner_sanity.ok",
passMessage: "Every reachable endpoint presents a valid 220 banner.",
},
&simpleConcernRule{
name: "smtp.ehlo_supported",
description: "Verifies every endpoint accepts EHLO (required for STARTTLS, PIPELINING, SIZE, …).",
codes: []string{CodeEHLOFailed, CodeEHLOFallback},
passCode: "smtp.ehlo_supported.ok",
passMessage: "Every endpoint accepts EHLO.",
},
&simpleConcernRule{
name: "smtp.starttls_offered",
description: "Verifies every endpoint advertises the STARTTLS extension.",
codes: []string{CodeSTARTTLSMissing, CodeAllNoSTARTTLS},
passCode: "smtp.starttls_offered.ok",
passMessage: "Every endpoint advertises STARTTLS.",
},
&simpleConcernRule{
name: "smtp.starttls_handshake",
description: "Verifies the STARTTLS handshake succeeds wherever STARTTLS is advertised.",
codes: []string{CodeSTARTTLSFailed},
passCode: "smtp.starttls_handshake.ok",
passMessage: "STARTTLS handshake succeeds on every endpoint that offers it.",
},
&simpleConcernRule{
name: "smtp.auth_posture",
description: "Flags endpoints that advertise SMTP AUTH before STARTTLS (cleartext credentials).",
codes: []string{CodeAUTHOverPlain},
passCode: "smtp.auth_posture.ok",
passMessage: "No endpoint advertises SMTP AUTH in cleartext.",
},
&simpleConcernRule{
name: "smtp.reverse_dns",
description: "Verifies every endpoint has a matching PTR record (FCrDNS).",
codes: []string{CodePTRMissing, CodeFCrDNSMismatch},
passCode: "smtp.reverse_dns.ok",
passMessage: "Every endpoint has a PTR record that forward-confirms.",
},
&simpleConcernRule{
name: "smtp.null_sender",
description: "Verifies endpoints accept the null sender MAIL FROM:<> (required for DSNs).",
codes: []string{CodeNullSenderReject},
passCode: "smtp.null_sender.ok",
passMessage: "Endpoints accept the RFC 5321 null sender.",
},
&simpleConcernRule{
name: "smtp.postmaster",
description: "Verifies endpoints accept RCPT TO:<postmaster@domain> (RFC 5321 § 4.5.1).",
codes: []string{CodePostmasterReject},
passCode: "smtp.postmaster.ok",
passMessage: "Endpoints accept mail for <postmaster>.",
},
&simpleConcernRule{
name: "smtp.open_relay",
description: "Flags endpoints that relay mail for recipients outside the tested domain.",
codes: []string{CodeOpenRelay},
passCode: "smtp.open_relay.ok",
passMessage: "No endpoint accepts relay for foreign recipients.",
},
&simpleConcernRule{
name: "smtp.extension_posture",
description: "Reports ESMTP extension posture (PIPELINING, 8BITMIME).",
codes: []string{CodeNoPipelining, CodeNo8BITMIME},
passCode: "smtp.extension_posture.ok",
passMessage: "Endpoints advertise the common ESMTP extensions.",
},
&simpleConcernRule{
name: "smtp.ipv6_reachable",
description: "Verifies at least one MX endpoint is reachable over IPv6.",
codes: []string{CodeNoIPv6},
passCode: "smtp.ipv6_reachable.ok",
passMessage: "At least one MX endpoint is reachable over IPv6.",
},
&tlsQualityRule{},
}
}
// loadSMTPData fetches the SMTP observation. On error, returns a CheckState
// the caller should emit to short-circuit its rule.
func loadSMTPData(ctx context.Context, obs sdk.ObservationGetter) (*SMTPData, *sdk.CheckState) {
var data SMTPData
if err := obs.Get(ctx, ObservationKeySMTP, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load SMTP observation: %v", err),
Code: "smtp.observation_error",
}
}
return &data, nil
}
// issuesByCodes returns derived issues whose Code is in the given set,
// preserving the order deriveIssues produces.
func issuesByCodes(data *SMTPData, codes ...string) []Issue {
if len(codes) == 0 {
return nil
}
set := make(map[string]struct{}, len(codes))
for _, c := range codes {
set[c] = struct{}{}
}
var out []Issue
for _, is := range deriveIssues(data) {
if _, ok := set[is.Code]; ok {
out = append(out, is)
}
}
return out
}
func statesFromIssues(issues []Issue) []sdk.CheckState {
out := make([]sdk.CheckState, 0, len(issues))
for _, is := range issues {
out = append(out, issueToState(is))
}
return out
}
func issueToState(is Issue) sdk.CheckState {
subject := is.Endpoint
if subject == "" {
subject = is.Target
}
meta := map[string]any{}
if is.Fix != "" {
meta["fix"] = is.Fix
}
if is.Endpoint != "" {
meta["endpoint"] = is.Endpoint
}
if is.Target != "" {
meta["target"] = is.Target
}
st := sdk.CheckState{
Status: severityToStatus(is.Severity),
Message: is.Message,
Code: is.Code,
Subject: subject,
}
if len(meta) > 0 {
st.Meta = meta
}
return st
}
func severityToStatus(sev string) sdk.Status {
switch sev {
case SeverityCrit:
return sdk.StatusCrit
case SeverityWarn:
return sdk.StatusWarn
case SeverityInfo:
return sdk.StatusInfo
default:
return sdk.StatusOK
}
}
func passState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusOK,
Message: message,
Code: code,
}
}
func notTestedState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusUnknown,
Message: message,
Code: code,
}
}