Initial commit
This commit is contained in:
commit
2d6cd4be33
19 changed files with 2451 additions and 0 deletions
518
checker/rule.go
Normal file
518
checker/rule.go
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
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.StatusInfo,
|
||||
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.StatusInfo,
|
||||
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.StatusInfo,
|
||||
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.StatusInfo,
|
||||
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.StatusInfo,
|
||||
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.StatusInfo,
|
||||
Message: "No Microsoft Autodiscover endpoint found (not required for Thunderbird-style clients).",
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue