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.
518 lines
16 KiB
Go
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
|
|
}
|