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: (RFC 5321 § 4.5.1).", codes: []string{CodePostmasterReject}, passCode: "smtp.postmaster.ok", passMessage: "Endpoints accept mail for .", }, &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, } }