188 lines
7.1 KiB
Go
188 lines
7.1 KiB
Go
// Package checker implements the SMTP (MX) server checker for happyDomain.
|
|
//
|
|
// It probes a domain's inbound-mail deployment (MX discovery, TCP
|
|
// reachability, ESMTP banner & EHLO, STARTTLS negotiation, open-relay
|
|
// posture, RFC 5321 § 4.5.1 postmaster acceptance, PTR / FCrDNS) and
|
|
// reports actionable findings.
|
|
//
|
|
// TLS certificate chain / SAN / expiry / cipher posture is intentionally
|
|
// out of scope: we publish each MX target as a DiscoveryEntry of type
|
|
// tls.endpoint.v1 with STARTTLS="smtp" so checker-tls picks up the
|
|
// connection and runs the TLS posture checks itself. The resulting
|
|
// observations flow back into our rule and HTML report via the SDK's
|
|
// ObservationGetter.GetRelated / ReportContext.Related path.
|
|
package checker
|
|
|
|
import (
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
// ObservationKeySMTP is the key under which this checker's observation
|
|
// payload is stored.
|
|
const ObservationKeySMTP sdk.ObservationKey = "smtp"
|
|
|
|
// SMTPData is the full observation stored per run.
|
|
type SMTPData struct {
|
|
Domain string `json:"domain"`
|
|
RunAt string `json:"run_at"`
|
|
|
|
MX MXLookup `json:"mx"`
|
|
Endpoints []EndpointProbe `json:"endpoints"`
|
|
Coverage Coverage `json:"coverage"`
|
|
}
|
|
|
|
// MXLookup captures the MX discovery step.
|
|
type MXLookup struct {
|
|
Records []MXRecord `json:"records,omitempty"`
|
|
// Error is a non-NXDOMAIN DNS failure (servfail, timeout, …).
|
|
Error string `json:"error,omitempty"`
|
|
// NullMX is true when the sole record is "." with preference 0 (RFC 7505).
|
|
NullMX bool `json:"null_mx,omitempty"`
|
|
// ImplicitMX is true when no MX was published and we fell back to the
|
|
// A/AAAA of the domain itself (RFC 5321 § 5.1, implicit MX).
|
|
ImplicitMX bool `json:"implicit_mx,omitempty"`
|
|
}
|
|
|
|
// MXRecord is a single MX RR target, expanded with per-target DNS checks.
|
|
type MXRecord struct {
|
|
Preference uint16 `json:"preference"`
|
|
Target string `json:"target"`
|
|
|
|
// Resolution.
|
|
IPv4 []string `json:"ipv4,omitempty"`
|
|
IPv6 []string `json:"ipv6,omitempty"`
|
|
|
|
// Error from A/AAAA resolution (empty string when OK).
|
|
ResolveError string `json:"resolve_error,omitempty"`
|
|
|
|
// IsCNAME flags targets pointed at via a CNAME chain, forbidden by
|
|
// RFC 5321 § 5.1 ("the domain name that appears in the RDATA SHOULD
|
|
// have an associated address record"): senders MAY reject or fail.
|
|
IsCNAME bool `json:"is_cname,omitempty"`
|
|
CNAMEChain []string `json:"cname_chain,omitempty"`
|
|
|
|
// IsIPLiteral flags targets that look like an IP address instead of a
|
|
// hostname (RFC 5321 § 5.1 forbids it).
|
|
IsIPLiteral bool `json:"is_ip_literal,omitempty"`
|
|
}
|
|
|
|
// EndpointProbe is the result of probing one (target, ip, port=25) tuple.
|
|
type EndpointProbe struct {
|
|
Target string `json:"target"`
|
|
Port uint16 `json:"port"`
|
|
IP string `json:"ip"`
|
|
IsIPv6 bool `json:"is_ipv6,omitempty"`
|
|
|
|
// Address is "ip:port"; used as a stable key.
|
|
Address string `json:"address"`
|
|
|
|
// Timing & errors.
|
|
ElapsedMS int64 `json:"elapsed_ms"`
|
|
Error string `json:"error,omitempty"`
|
|
|
|
// Connection stages.
|
|
TCPConnected bool `json:"tcp_connected"`
|
|
BannerReceived bool `json:"banner_received"`
|
|
BannerLine string `json:"banner_line,omitempty"`
|
|
BannerHostname string `json:"banner_hostname,omitempty"`
|
|
BannerCode int `json:"banner_code,omitempty"`
|
|
EHLOReceived bool `json:"ehlo_received"`
|
|
EHLOHostname string `json:"ehlo_hostname,omitempty"`
|
|
EHLOFallbackHELO bool `json:"ehlo_fallback_helo,omitempty"`
|
|
|
|
// Pre-TLS extensions.
|
|
Extensions []string `json:"extensions,omitempty"`
|
|
STARTTLSOffered bool `json:"starttls_offered"`
|
|
AUTHPreTLS []string `json:"auth_pre_tls,omitempty"`
|
|
SizeLimit uint64 `json:"size_limit,omitempty"`
|
|
HasPipelining bool `json:"has_pipelining,omitempty"`
|
|
Has8BITMIME bool `json:"has_8bitmime,omitempty"`
|
|
HasSMTPUTF8 bool `json:"has_smtputf8,omitempty"`
|
|
HasCHUNKING bool `json:"has_chunking,omitempty"`
|
|
HasDSN bool `json:"has_dsn,omitempty"`
|
|
HasENHANCEDCODE bool `json:"has_enhancedstatuscodes,omitempty"`
|
|
|
|
// STARTTLS negotiation.
|
|
STARTTLSUpgraded bool `json:"starttls_upgraded,omitempty"`
|
|
TLSVersion string `json:"tls_version,omitempty"`
|
|
TLSCipher string `json:"tls_cipher,omitempty"`
|
|
|
|
// Post-TLS extensions (typically identical or larger than pre-TLS).
|
|
PostTLSExtensions []string `json:"post_tls_extensions,omitempty"`
|
|
AUTHPostTLS []string `json:"auth_post_tls,omitempty"`
|
|
|
|
// Reverse DNS / FCrDNS.
|
|
PTR string `json:"ptr,omitempty"`
|
|
PTRError string `json:"ptr_error,omitempty"`
|
|
FCrDNSPass bool `json:"fcrdns_pass,omitempty"`
|
|
|
|
// Mail transaction probes.
|
|
NullSenderAccepted *bool `json:"null_sender_accepted,omitempty"`
|
|
NullSenderResponse string `json:"null_sender_response,omitempty"`
|
|
|
|
PostmasterAccepted *bool `json:"postmaster_accepted,omitempty"`
|
|
PostmasterResponse string `json:"postmaster_response,omitempty"`
|
|
|
|
OpenRelay *bool `json:"open_relay,omitempty"`
|
|
OpenRelayResponse string `json:"open_relay_response,omitempty"`
|
|
OpenRelayRecipient string `json:"open_relay_recipient,omitempty"`
|
|
}
|
|
|
|
// Coverage summarises which axes are working at the domain level.
|
|
type Coverage struct {
|
|
HasIPv4 bool `json:"has_ipv4"`
|
|
HasIPv6 bool `json:"has_ipv6"`
|
|
AnyReachable bool `json:"any_reachable"`
|
|
AnyBanner bool `json:"any_banner"`
|
|
AnyEHLO bool `json:"any_ehlo"`
|
|
AnySTARTTLS bool `json:"any_starttls"`
|
|
AllSTARTTLS bool `json:"all_starttls"`
|
|
AllAcceptMail bool `json:"all_accept_mail"`
|
|
}
|
|
|
|
// Issue is a structured finding, consumed by both the rule and the HTML report.
|
|
type Issue struct {
|
|
Code string `json:"code"`
|
|
Severity string `json:"severity"` // "info" | "warn" | "crit"
|
|
Message string `json:"message"`
|
|
Fix string `json:"fix,omitempty"`
|
|
Endpoint string `json:"endpoint,omitempty"`
|
|
Target string `json:"target,omitempty"`
|
|
}
|
|
|
|
// Severities (string for stable JSON, independent of sdk.Status numeric values).
|
|
const (
|
|
SeverityInfo = "info"
|
|
SeverityWarn = "warn"
|
|
SeverityCrit = "crit"
|
|
)
|
|
|
|
// Issue codes.
|
|
const (
|
|
CodeNoMX = "smtp.no_mx"
|
|
CodeMXLookupFailed = "smtp.mx.lookup_failed"
|
|
CodeNullMX = "smtp.null_mx"
|
|
CodeImplicitMX = "smtp.mx.implicit"
|
|
CodeMXCNAME = "smtp.mx.cname"
|
|
CodeMXIPLiteral = "smtp.mx.ip_literal"
|
|
CodeMXResolveFailed = "smtp.mx.resolve_failed"
|
|
CodeNoAddresses = "smtp.mx.no_addresses"
|
|
CodeTCPUnreachable = "smtp.tcp.unreachable"
|
|
CodeBannerMissing = "smtp.banner.missing"
|
|
CodeBannerInvalid = "smtp.banner.invalid"
|
|
CodeEHLOFailed = "smtp.ehlo.failed"
|
|
CodeEHLOFallback = "smtp.ehlo.fallback_helo"
|
|
CodeSTARTTLSMissing = "smtp.starttls.missing"
|
|
CodeSTARTTLSFailed = "smtp.starttls.failed"
|
|
CodeAUTHOverPlain = "smtp.auth.plaintext"
|
|
CodePTRMissing = "smtp.ptr.missing"
|
|
CodeFCrDNSMismatch = "smtp.fcrdns.mismatch"
|
|
CodeNullSenderReject = "smtp.null_sender.rejected"
|
|
CodePostmasterReject = "smtp.postmaster.rejected"
|
|
CodeOpenRelay = "smtp.open_relay"
|
|
CodeNoPipelining = "smtp.no_pipelining"
|
|
CodeNo8BITMIME = "smtp.no_8bitmime"
|
|
CodeNoIPv6 = "smtp.no_ipv6"
|
|
CodeAllEndpointsDown = "smtp.all_endpoints_down"
|
|
CodeAllNoSTARTTLS = "smtp.all_no_starttls"
|
|
)
|