214 lines
7 KiB
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,
|
|
}
|
|
}
|