checker-email-autoconfig/checker/rule.go
Pierre-Olivier Mercier d73502b0e2 checker: report skipped rules as StatusUnknown
Skipped tests that are not problematic should be UNKNOWN rather
than INFO; the affected rules cannot evaluate without their input,
so they are non-evaluations, not findings.
2026-04-26 09:50:13 +07:00

518 lines
16 KiB
Go

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.<domain>) 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.<domain>/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.<domain> 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
}