package checker import ( "context" "fmt" "sort" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) func getData(ctx context.Context, obs sdk.ObservationGetter) (*Data, *sdk.CheckState) { var d Data if err := obs.Get(ctx, ObservationKeyAutoconfig, &d); err != nil { return nil, &sdk.CheckState{ Status: sdk.StatusError, Message: fmt.Sprintf("failed to get autoconfig data: %v", err), Code: "autoconfig_error", } } return &d, nil } func single(s sdk.CheckState) []sdk.CheckState { return []sdk.CheckState{s} } // ── Rule: at least one discovery method works ─────────────────────────────── type presenceRule struct{} func PresenceRule() sdk.CheckRule { return &presenceRule{} } func (r *presenceRule) Name() string { return "autoconfig_presence" } func (r *presenceRule) Description() string { return "Checks that at least one email-autoconfiguration discovery method answers for the domain." } func (r *presenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { d, errState := getData(ctx, obs) if errState != nil { return single(*errState) } for _, p := range d.Autoconfig { if p.Parsed != nil { return single(sdk.CheckState{ Status: sdk.StatusOK, Message: fmt.Sprintf("Autoconfig served via %s (%s)", p.Source, p.Result.URL), Code: "autoconfig_found", }) } } // Autodiscover is acceptable as a fallback. for _, p := range d.Autodiscover { if p.Parsed != nil { return single(sdk.CheckState{ Status: sdk.StatusWarn, Message: fmt.Sprintf("Only Microsoft Autodiscover responds (%s); publishing a Thunderbird clientConfig is recommended for broader client support.", p.Result.URL), Code: "autoconfig_only_autodiscover", }) } } // Just SRV records? Flag but do not call it a full failure. if hasUsableSRV(d.SRV) { return single(sdk.CheckState{ Status: sdk.StatusWarn, Message: "Only RFC 6186 SRV records are published; modern clients still need a clientConfig XML to learn the authentication method to use.", Code: "autoconfig_only_srv", }) } return single(sdk.CheckState{ Status: sdk.StatusCrit, Message: "No email autoconfiguration discovered: autoconfig, .well-known, Autodiscover and SRV all failed.", Code: "autoconfig_missing", }) } // ── Rule: the preferred endpoint (autoconfig.) is reachable ───────── type preferredEndpointRule struct{} func PreferredEndpointRule() sdk.CheckRule { return &preferredEndpointRule{} } func (r *preferredEndpointRule) Name() string { return "autoconfig_preferred_endpoint" } func (r *preferredEndpointRule) Description() string { return "Checks that https://autoconfig./mail/config-v1.1.xml (the primary endpoint recommended by the draft) is reachable and serves a valid clientConfig." } func (r *preferredEndpointRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { d, errState := getData(ctx, obs) if errState != nil { return single(*errState) } var autoconfigProbe, wellKnownProbe *AutoconfigProbe anyOK := false for i := range d.Autoconfig { p := &d.Autoconfig[i] if p.Parsed != nil { anyOK = true } switch p.Source { case "autoconfig": autoconfigProbe = p case "wellknown": wellKnownProbe = p } } // When nothing works, let the presence rule drive the verdict. if !anyOK { return single(sdk.CheckState{ Status: sdk.StatusUnknown, Message: "No autoconfig responded; primary endpoint not evaluated.", Code: "autoconfig_preferred_skip", }) } if autoconfigProbe != nil && autoconfigProbe.Parsed != nil { return single(sdk.CheckState{ Status: sdk.StatusOK, Message: "Primary endpoint autoconfig." + d.Domain + " is live and serves a valid clientConfig.", Code: "autoconfig_preferred_ok", }) } if wellKnownProbe != nil && wellKnownProbe.Parsed != nil { return single(sdk.CheckState{ Status: sdk.StatusWarn, Message: "Primary endpoint autoconfig." + d.Domain + " is missing; only the .well-known fallback is reachable. Thunderbird tries autoconfig. first, so publish it to avoid the extra attempt.", Code: "autoconfig_preferred_missing", }) } // The rest (ISPDB / MX-based) is a weaker signal. return single(sdk.CheckState{ Status: sdk.StatusWarn, Message: "Autoconfig is only served by a fallback (ISPDB or MX host). Publishing https://autoconfig." + d.Domain + "/mail/config-v1.1.xml gives clients a deterministic match for your domain.", Code: "autoconfig_preferred_fallback", }) } // ── Rule: TLS health of the autoconfig endpoints ──────────────────────────── type tlsRule struct{} func TLSRule() sdk.CheckRule { return &tlsRule{} } func (r *tlsRule) Name() string { return "autoconfig_tls" } func (r *tlsRule) Description() string { return "Checks that autoconfig endpoints are served over HTTPS with a valid TLS certificate." } func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { d, errState := getData(ctx, obs) if errState != nil { return single(*errState) } var out []sdk.CheckState for _, p := range d.Autoconfig { if p.Source == "ispdb" || p.Source == "mx-ispdb" { continue } // Skip probes that did not actually connect (nothing to say about TLS). if p.Result.StatusCode == 0 && p.Result.TLSError == "" { continue } subject := p.Source switch { case p.Result.TLSError != "": out = append(out, sdk.CheckState{ Status: sdk.StatusCrit, Subject: subject, Message: "TLS failure: " + p.Result.TLSError, Code: "autoconfig_tls_invalid", }) case strings.HasPrefix(p.Result.URL, "http://") && p.Result.StatusCode >= 200 && p.Result.StatusCode < 300: out = append(out, sdk.CheckState{ Status: sdk.StatusWarn, Subject: subject, Message: "Served over plain HTTP (" + p.Result.URL + "). Thunderbird accepts this only when HTTPS has already failed; serve the file over HTTPS.", Code: "autoconfig_tls_plaintext", }) case p.Parsed != nil: out = append(out, sdk.CheckState{ Status: sdk.StatusOK, Subject: subject, Message: "HTTPS handshake succeeded and clientConfig was parsed.", Code: "autoconfig_tls_ok", }) } } if len(out) == 0 { return single(sdk.CheckState{ Status: sdk.StatusUnknown, Message: "No autoconfig probe reached an endpoint; TLS not assessed.", Code: "autoconfig_tls_skip", }) } return out } // ── Rule: advertised servers actually encrypt mail ────────────────────────── type encryptionRule struct{} func EncryptionRule() sdk.CheckRule { return &encryptionRule{} } func (r *encryptionRule) Name() string { return "autoconfig_server_encryption" } func (r *encryptionRule) Description() string { return "Checks that servers advertised by autoconfig use SSL or STARTTLS and a non-cleartext auth method where appropriate." } func (r *encryptionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { d, errState := getData(ctx, obs) if errState != nil { return single(*errState) } if d.ClientConfig == nil { return single(sdk.CheckState{ Status: sdk.StatusUnknown, Message: "No clientConfig parsed; encryption check skipped.", Code: "autoconfig_encryption_skip", }) } servers := make([]ServerConfig, 0, len(d.ClientConfig.Incoming)+len(d.ClientConfig.Outgoing)) servers = append(servers, d.ClientConfig.Incoming...) servers = append(servers, d.ClientConfig.Outgoing...) var out []sdk.CheckState for _, s := range servers { subject := fmt.Sprintf("%s %s:%d", s.Type, s.Hostname, s.Port) switch { case !isEncryptedSocket(s.SocketType): out = append(out, sdk.CheckState{ Status: sdk.StatusCrit, Subject: subject, Message: fmt.Sprintf("Advertised as plaintext (socketType=%q). Serve with SSL or STARTTLS.", s.SocketType), Code: "autoconfig_plaintext_server", }) case strings.EqualFold(s.Authentication, "password-cleartext"): // Cleartext password is fine here because the transport is encrypted. out = append(out, sdk.CheckState{ Status: sdk.StatusOK, Subject: subject, Message: "Encrypted transport (" + s.SocketType + ") carries cleartext-password auth; acceptable.", Code: "autoconfig_encryption_ok", }) default: out = append(out, sdk.CheckState{ Status: sdk.StatusOK, Subject: subject, Message: "Encrypted via " + s.SocketType + ", auth=" + s.Authentication + ".", Code: "autoconfig_encryption_ok", }) } } if len(out) == 0 { return single(sdk.CheckState{ Status: sdk.StatusUnknown, Message: "clientConfig declares no server to evaluate.", Code: "autoconfig_encryption_skip", }) } return out } // ── Rule: cross-source consistency ────────────────────────────────────────── type consistencyRule struct{} func ConsistencyRule() sdk.CheckRule { return &consistencyRule{} } func (r *consistencyRule) Name() string { return "autoconfig_consistency" } func (r *consistencyRule) Description() string { return "Cross-checks hostnames and ports reported by autoconfig, Autodiscover and SRV records." } func (r *consistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { d, errState := getData(ctx, obs) if errState != nil { return single(*errState) } if d.ClientConfig == nil { return single(sdk.CheckState{ Status: sdk.StatusUnknown, Message: "No clientConfig to compare.", Code: "autoconfig_consistency_skip", }) } var out []sdk.CheckState if !clientConfigCoversDomain(d.ClientConfig, d.Domain) { out = append(out, sdk.CheckState{ Status: sdk.StatusWarn, Subject: "domain-claim", Message: fmt.Sprintf("clientConfig does not claim domain %q (emailProvider id=%q, domains=%v)", d.Domain, d.ClientConfig.EmailProviderID, d.ClientConfig.Domains), Code: "autoconfig_inconsistent", }) } for _, m := range srvVersusServers(d.SRV, d.ClientConfig.Incoming, d.ClientConfig.Outgoing) { out = append(out, sdk.CheckState{ Status: sdk.StatusWarn, Subject: m.service, Message: m.message, Code: "autoconfig_inconsistent", }) } if len(out) == 0 { return single(sdk.CheckState{ Status: sdk.StatusOK, Message: "Autoconfig data is self-consistent (domain claim and SRV match).", Code: "autoconfig_consistent", }) } return out } // ── Rule: RFC 6186 SRV presence ───────────────────────────────────────────── type srvRule struct{} func SRVRule() sdk.CheckRule { return &srvRule{} } func (r *srvRule) Name() string { return "autoconfig_srv_records" } func (r *srvRule) Description() string { return "Checks that RFC 6186 SRV records (_imaps._tcp, _submissions._tcp, …) complement the autoconfig XML." } func (r *srvRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { d, errState := getData(ctx, obs) if errState != nil { return single(*errState) } var incoming, submission bool for _, rec := range d.SRV { if rec.Skip { continue } inc, sub := classifySRV(rec.Service) incoming = incoming || inc submission = submission || sub } switch { case incoming && submission: return single(sdk.CheckState{ Status: sdk.StatusOK, Message: "RFC 6186 SRV records cover incoming and submission.", Code: "autoconfig_srv_complete", }) case incoming || submission: missing := "submission" if !incoming { missing = "incoming" } return single(sdk.CheckState{ Status: sdk.StatusWarn, Message: "RFC 6186 SRV records miss " + missing + "; clients without autoconfig XML cannot fully bootstrap.", Code: "autoconfig_srv_partial", }) default: return single(sdk.CheckState{ Status: sdk.StatusInfo, Message: "No RFC 6186 SRV records published. Not mandatory, but a cheap safety net.", Code: "autoconfig_srv_missing", }) } } // ── Rule: Autodiscover behaviour ──────────────────────────────────────────── type autodiscoverRule struct{} func AutodiscoverRule() sdk.CheckRule { return &autodiscoverRule{} } func (r *autodiscoverRule) Name() string { return "autoconfig_autodiscover" } func (r *autodiscoverRule) Description() string { return "Reports whether Microsoft Autodiscover (POX) responds on the domain." } func (r *autodiscoverRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { d, errState := getData(ctx, obs) if errState != nil { return single(*errState) } if d.AutodiscoverResult != nil { if d.AutodiscoverResult.RedirectAddr != "" || d.AutodiscoverResult.RedirectURL != "" { return single(sdk.CheckState{ Status: sdk.StatusOK, Message: "Autodiscover answers with a redirect.", Code: "autoconfig_autodiscover_redirect", }) } return single(sdk.CheckState{ Status: sdk.StatusOK, Message: fmt.Sprintf("Autodiscover returns %d protocol definition(s).", len(d.AutodiscoverResult.Protocols)), Code: "autoconfig_autodiscover_ok", }) } return single(sdk.CheckState{ Status: sdk.StatusWarn, Message: "No Microsoft Autodiscover endpoint found; Outlook and other Autodiscover-based clients cannot bootstrap automatically.", Code: "autoconfig_autodiscover_missing", }) } // ── helpers ───────────────────────────────────────────────────────────────── func hasUsableSRV(rs []SRVRecord) bool { for _, r := range rs { if !r.Skip && r.Target != "" { return true } } return false } func classifySRV(service string) (incoming, submission bool) { switch service { case "_imaps._tcp", "_imap._tcp", "_pop3s._tcp", "_pop3._tcp": return true, false case "_submissions._tcp", "_submission._tcp": return false, true } return false, false } func isEncryptedSocket(s string) bool { switch strings.ToUpper(strings.TrimSpace(s)) { case "SSL", "STARTTLS": return true } return false } func clientConfigCoversDomain(cfg *ClientConfig, domain string) bool { if cfg == nil { return false } domain = strings.ToLower(strings.TrimSuffix(domain, ".")) if strings.EqualFold(cfg.EmailProviderID, domain) { return true } for _, d := range cfg.Domains { if strings.EqualFold(strings.TrimSuffix(d, "."), domain) { return true } } return false } type srvMismatch struct { service string message string } // srvVersusServers returns one entry per mismatching SRV service. func srvVersusServers(srv []SRVRecord, incoming, outgoing []ServerConfig) []srvMismatch { byType := map[string][]SRVRecord{} for _, r := range srv { if r.Skip { continue } byType[r.Service] = append(byType[r.Service], r) } services := make([]string, 0, len(byType)) for svc := range byType { services = append(services, svc) } sort.Strings(services) var out []srvMismatch for _, svc := range services { recs := byType[svc] var configs []ServerConfig switch svc { case "_imaps._tcp", "_imap._tcp": configs = filterType(incoming, "imap") case "_pop3s._tcp", "_pop3._tcp": configs = filterType(incoming, "pop3") case "_submissions._tcp", "_submission._tcp": configs = filterType(outgoing, "smtp") } if len(configs) == 0 { continue } match := false for _, rec := range recs { for _, c := range configs { if strings.EqualFold(strings.TrimSuffix(rec.Target, "."), c.Hostname) && (rec.Port == 0 || int(rec.Port) == c.Port) { match = true break } } } if !match { out = append(out, srvMismatch{ service: svc, message: fmt.Sprintf("No SRV %s record matches any clientConfig %s server (host/port)", svc, configs[0].Type), }) } } return out } func filterType(in []ServerConfig, t string) []ServerConfig { var out []ServerConfig for _, s := range in { if strings.EqualFold(s.Type, t) { out = append(out, s) } } return out }