// 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" )