Initial commit

This commit is contained in:
nemunaire 2026-04-23 17:45:37 +07:00
commit c4bf833274
19 changed files with 2451 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-email-autoconfig
checker-email-autoconfig.so

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-autoconfig .
FROM scratch
COPY --from=builder /checker-autoconfig /checker-autoconfig
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
EXPOSE 8080
ENTRYPOINT ["/checker-autoconfig"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-email-autoconfig
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

26
NOTICE Normal file
View file

@ -0,0 +1,26 @@
checker-autoconfig
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software developed as part of the happyDomain
project (https://happydomain.org).
Portions of this code were originally written for the happyDomain
server (licensed under AGPL-3.0 and a commercial license) and are
made available there under the Apache License, Version 2.0 to enable
a permissively licensed ecosystem of checker plugins.
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

99
README.md Normal file
View file

@ -0,0 +1,99 @@
# checker-email-autoconfig
Email autoconfiguration checker for [happyDomain](https://www.happydomain.org/).
Verifies that a domain publishes discoverable email-client configuration
through the mechanisms used by real-world mail clients:
- **Thunderbird autoconfig** (Bucksch draft, `draft-bucksch-autoconfig-00`)
- `https://autoconfig.<domain>/mail/config-v1.1.xml` (primary)
- `https://<domain>/.well-known/autoconfig/mail/config-v1.1.xml` (apex fallback)
- `http://autoconfig.<domain>/...` (optional; surfaced as a warning)
- Mozilla ISPDB fallback (`autoconfig.thunderbird.net`)
- MX-parent fallbacks for hosted domains
- **Microsoft Autodiscover** POX (`https://autodiscover.<domain>/autodiscover/autodiscover.xml`)
- **RFC 6186 SRV records** (`_imaps`, `_imap`, `_pop3s`, `_pop3`,
`_submissions`, `_submission`, `_autodiscover`)
- MX resolution (for context and MX-based discovery)
The checker parses every response, cross-checks the servers advertised
by the different sources, and produces a rich HTML report with
**paste-ready remediation snippets** for the most common failure modes.
## Rules produced
| Rule | What it checks |
|---------------------------------------|----------------------------------------------------------------------|
| `autoconfig_presence` | At least one discovery method serves a valid clientConfig. |
| `autoconfig_preferred_endpoint` | `autoconfig.<domain>` (Thunderbird's first try) is reachable. |
| `autoconfig_tls` | HTTPS is mandatory and certificates validate. |
| `autoconfig_server_encryption` | Advertised IMAP/SMTP servers use SSL/STARTTLS, not plaintext. |
| `autoconfig_consistency` | clientConfig claims the queried domain and agrees with SRV. |
| `autoconfig_srv_records` | RFC 6186 SRV records cover incoming + submission. |
| `autoconfig_autodiscover` | Microsoft Autodiscover responds (informational). |
## Common failure modes the HTML report addresses
When a check fails, the report's "Fix this first" section provides
ready-to-copy snippets:
- **Nothing is published** → sample `config-v1.1.xml` for the domain and
the two canonical URLs to serve it from.
- **Only `.well-known` answers** → nudge to add the `autoconfig.`
subdomain (primary URL per the draft).
- **Plain HTTP fallback responds** → redirect to HTTPS.
- **TLS validation failed** → hint at covering `autoconfig.<domain>`
with a valid certificate.
- **Advertised servers are plaintext** → port cheat-sheet (SSL 993/465,
STARTTLS 143/587).
- **No RFC 6186 SRV records** → ready-to-paste zone excerpt.
## Usage
### Standalone
```bash
make
./checker-email-autoconfig -listen :8080
```
Exposes:
- `GET /health`, `GET /definition`
- `POST /collect`: run the full discovery probe.
- `POST /evaluate`: apply rules to a previously collected observation.
- `POST /report`: returns HTML when `Accept: text/html` is set,
otherwise JSON metrics.
### Docker
```bash
make docker
docker run -p 8080:8080 happydomain/checker-email-autoconfig
```
### happyDomain plugin
```bash
make plugin
# produces checker-email-autoconfig.so, loadable by happyDomain.
```
## Options
### Per-user
- `probeEmail`: local-part used in the autoconfig URL query string
(default `test`).
- `httpTimeout`: per-request timeout in seconds (default 8).
- `tryISPDB`: query Mozilla's Thunderbird ISPDB as a fallback (default `true`).
- `tryHTTPAutoconfig`: also probe the plain-HTTP variant (default `false`).
- `tryAutodiscoverPost`: probe the Microsoft Autodiscover POX
endpoints (default `true`).
### Admin
- `ispdbURL`: override the ISPDB base URL.
- `userAgent`: User-Agent announced in every probe.
## License
MIT. See `LICENSE` and `NOTICE`.

450
checker/collect.go Normal file
View file

@ -0,0 +1,450 @@
package checker
import (
"context"
"crypto/tls"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Real autoconfig/autodiscover documents are tiny; anything bigger is
// misconfigured or hostile.
const maxBodyBytes = 256 * 1024
func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain_name")
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
if domain == "" {
return nil, fmt.Errorf("domain_name is required")
}
domain, err := validateDomain(domain)
if err != nil {
return nil, err
}
localPart, _ := sdk.GetOption[string](opts, "probeEmail")
if localPart == "" {
localPart = "test"
}
email := localPart + "@" + domain
httpTimeout := time.Duration(sdk.GetFloatOption(opts, "httpTimeout", 8)) * time.Second
if httpTimeout <= 0 {
httpTimeout = 8 * time.Second
}
ispdbURL, _ := sdk.GetOption[string](opts, "ispdbURL")
if ispdbURL == "" {
ispdbURL = "https://autoconfig.thunderbird.net/v1.1/"
}
if !strings.HasSuffix(ispdbURL, "/") {
ispdbURL += "/"
}
ispdbParsed, err := url.Parse(ispdbURL)
if err != nil || (ispdbParsed.Scheme != "http" && ispdbParsed.Scheme != "https") || ispdbParsed.Host == "" {
return nil, fmt.Errorf("invalid ispdbURL: must be an absolute http(s) URL")
}
if _, err := validateDomain(ispdbParsed.Hostname()); err != nil {
return nil, fmt.Errorf("invalid ispdbURL host: %w", err)
}
userAgent, _ := sdk.GetOption[string](opts, "userAgent")
if userAgent == "" {
userAgent = "happyDomain-autoconfig/1.0 (+https://happydomain.org)"
}
tryISPDB := sdk.GetBoolOption(opts, "tryISPDB", true)
tryHTTPAutoconfig := sdk.GetBoolOption(opts, "tryHTTPAutoconfig", false)
tryAutodiscover := sdk.GetBoolOption(opts, "tryAutodiscoverPost", true)
client := newHTTPClient(httpTimeout)
data := &Data{
Domain: domain,
Email: email,
CollectedAt: time.Now().UTC(),
}
// SRV and Autodiscover are independent of MX; run them in background.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
data.SRV = collectSRV(ctx, domain, httpTimeout)
}()
if tryAutodiscover {
wg.Add(1)
go func() {
defer wg.Done()
data.Autodiscover = collectAutodiscover(ctx, client, userAgent, domain, email)
for _, p := range data.Autodiscover {
if p.Parsed != nil {
data.AutodiscoverResult = p.Parsed
break
}
}
}()
}
// MX lookup: result feeds into collectAutoconfig below.
mxResolveCtx, cancel := context.WithTimeout(ctx, httpTimeout)
mx, mxErr := net.DefaultResolver.LookupMX(mxResolveCtx, domain)
cancel()
if mxErr != nil {
data.MXError = mxErr.Error()
}
for _, r := range mx {
data.MX = append(data.MX, MXRecord{
Host: strings.TrimSuffix(r.Host, "."),
Preference: r.Pref,
})
}
// Runs synchronously: needs MX, but overlaps the SRV/Autodiscover goroutines.
data.Autoconfig = collectAutoconfig(ctx, client, userAgent, domain, email, data.MX, tryISPDB, tryHTTPAutoconfig, ispdbURL)
for _, p := range data.Autoconfig {
if p.Parsed != nil {
data.ClientConfig = p.Parsed
data.ClientConfigSource = p.Source
break
}
}
wg.Wait()
return data, nil
}
func newHTTPClient(timeout time.Duration) *http.Client {
// Keep cert validation ON; failures are surfaced as soft probe errors
// so the rule engine can flag them.
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 8,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
return &http.Client{
Transport: tr,
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return http.ErrUseLastResponse
}
return nil
},
}
}
func fetch(ctx context.Context, client *http.Client, userAgent, method, rawURL string, body io.Reader, contentType string) (ProbeResult, []byte) {
res := ProbeResult{URL: rawURL, Method: method}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
res.Error = err.Error()
res.DurationMs = time.Since(start).Milliseconds()
return res, nil
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/xml, text/xml, */*;q=0.8")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := client.Do(req)
res.DurationMs = time.Since(start).Milliseconds()
if err != nil {
res.Error = err.Error()
var tlsErr *tls.CertificateVerificationError
if errors.As(err, &tlsErr) {
res.TLSError = err.Error()
}
return res, nil
}
defer resp.Body.Close()
res.StatusCode = resp.StatusCode
res.ContentType = resp.Header.Get("Content-Type")
res.FinalURL = resp.Request.URL.String()
res.Redirected = res.FinalURL != rawURL
if resp.TLS != nil {
res.TLSServerName = resp.TLS.ServerName
if len(resp.TLS.PeerCertificates) > 0 {
leaf := resp.TLS.PeerCertificates[0]
res.TLSSubject = leaf.Subject.CommonName
res.TLSIssuer = leaf.Issuer.CommonName
res.TLSNotAfter = leaf.NotAfter.UTC().Format(time.RFC3339)
}
}
limit := io.LimitReader(resp.Body, maxBodyBytes+1)
raw, rerr := io.ReadAll(limit)
if rerr != nil {
res.Error = rerr.Error()
return res, nil
}
res.BodyBytes = len(raw)
if len(raw) > maxBodyBytes {
res.Error = fmt.Sprintf("response truncated at %d bytes", maxBodyBytes)
raw = raw[:maxBodyBytes]
}
return res, raw
}
func collectAutoconfig(ctx context.Context, client *http.Client, userAgent, domain, email string, mx []MXRecord, tryISPDB, tryHTTPAutoconfig bool, ispdbURL string) []AutoconfigProbe {
encoded := url.QueryEscape(email)
type target struct {
source string
url string
}
targets := []target{
{"autoconfig", fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml?emailaddress=%s", domain, encoded)},
{"wellknown", fmt.Sprintf("https://%s/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=%s", domain, encoded)},
}
if tryHTTPAutoconfig {
targets = append(targets, target{"http-autoconfig", fmt.Sprintf("http://autoconfig.%s/mail/config-v1.1.xml?emailaddress=%s", domain, encoded)})
}
if tryISPDB {
targets = append(targets, target{"ispdb", ispdbURL + domain})
}
// MX fallback catches gmail/MS365-hosted domains. Bucksch suggests
// iterating every MX; the lowest-preference one is enough in practice.
if mxParent := pickMXParent(mx); mxParent != "" && mxParent != domain {
targets = append(targets, target{"mx-autoconfig", fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml?emailaddress=%s", mxParent, encoded)})
if tryISPDB {
targets = append(targets, target{"mx-ispdb", ispdbURL + mxParent})
}
}
probes := make([]AutoconfigProbe, len(targets))
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
go func(i int, source, rawURL string) {
defer wg.Done()
probes[i] = runAutoconfigProbe(ctx, client, userAgent, source, rawURL)
}(i, t.source, t.url)
}
wg.Wait()
return probes
}
func runAutoconfigProbe(ctx context.Context, client *http.Client, userAgent, source, rawURL string) AutoconfigProbe {
res, body := fetch(ctx, client, userAgent, http.MethodGet, rawURL, nil, "")
probe := AutoconfigProbe{Source: source, Result: res}
if res.Error != "" || res.StatusCode < 200 || res.StatusCode >= 300 || len(body) == 0 {
return probe
}
cfg, err := parseClientConfig(body)
if err != nil {
probe.Result.ParseError = err.Error()
return probe
}
probe.Parsed = cfg
return probe
}
// validateDomain rejects anything that could escape URL interpolation
// (path/query injection, IP literals). IP-range filtering is left to the
// network layer.
func validateDomain(domain string) (string, error) {
domain = strings.ToLower(domain)
if len(domain) == 0 || len(domain) > 253 {
return "", fmt.Errorf("invalid domain name: length must be 1..253")
}
if net.ParseIP(domain) != nil {
return "", fmt.Errorf("invalid domain name: IP literals are not accepted")
}
labels := strings.Split(domain, ".")
if len(labels) < 2 {
return "", fmt.Errorf("invalid domain name: must contain at least one dot")
}
for _, label := range labels {
if len(label) == 0 || len(label) > 63 {
return "", fmt.Errorf("invalid domain name: label length must be 1..63")
}
if label[0] == '-' || label[len(label)-1] == '-' {
return "", fmt.Errorf("invalid domain name: label %q cannot start or end with '-'", label)
}
for i := 0; i < len(label); i++ {
c := label[i]
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
return "", fmt.Errorf("invalid domain name: label %q contains forbidden character", label)
}
}
}
return domain, nil
}
// pickMXParent returns the parent domain of the lowest-preference MX, or
// empty when no suitable MX is present.
func pickMXParent(mx []MXRecord) string {
if len(mx) == 0 {
return ""
}
best := mx[0]
for _, r := range mx[1:] {
if r.Preference < best.Preference {
best = r
}
}
return registrableDomain(best.Host)
}
// registrableDomain approximates a PSL lookup with last-two-labels (or
// three when the SLD looks like a ccTLD second level, e.g. co.uk). Good
// enough for the gmail / MS365 MX-fallback case we actually care about.
func registrableDomain(host string) string {
host = strings.TrimSuffix(strings.ToLower(host), ".")
parts := strings.Split(host, ".")
if len(parts) < 2 {
return host
}
n := 2
// Very rough country-code second-level heuristic.
if len(parts) >= 3 && len(parts[len(parts)-2]) <= 3 && len(parts[len(parts)-1]) == 2 {
n = 3
}
if len(parts) < n {
return host
}
return strings.Join(parts[len(parts)-n:], ".")
}
// ── RFC 6186 SRV ─────────────────────────────────────────────────────────────
var rfc6186Services = []string{
"_imaps._tcp",
"_imap._tcp",
"_pop3s._tcp",
"_pop3._tcp",
"_submissions._tcp",
"_submission._tcp",
"_autodiscover._tcp",
}
func collectSRV(ctx context.Context, domain string, timeout time.Duration) []SRVRecord {
type indexedResult struct {
idx int
recs []SRVRecord
}
ch := make(chan indexedResult, len(rfc6186Services))
for i, svc := range rfc6186Services {
go func(idx int, svc string) {
c, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
_, addrs, err := net.DefaultResolver.LookupSRV(c, "", "", svc+"."+domain)
if err != nil {
ch <- indexedResult{idx, nil}
return
}
var recs []SRVRecord
for _, a := range addrs {
target := strings.TrimSuffix(a.Target, ".")
rec := SRVRecord{
Service: svc,
Target: target,
Port: a.Port,
Priority: a.Priority,
Weight: a.Weight,
}
// RFC 2782 "service not provided at this domain" sentinel.
if target == "" || target == "." {
rec.Skip = true
}
recs = append(recs, rec)
}
ch <- indexedResult{idx, recs}
}(i, svc)
}
results := make([][]SRVRecord, len(rfc6186Services))
for range rfc6186Services {
r := <-ch
results[r.idx] = r.recs
}
var out []SRVRecord
for _, recs := range results {
out = append(out, recs...)
}
return out
}
// ── Microsoft Autodiscover (POX) ─────────────────────────────────────────────
const autodiscoverRequestTemplate = `<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
<Request>
<EMailAddress>%s</EMailAddress>
<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
</Request>
</Autodiscover>`
func collectAutodiscover(ctx context.Context, client *http.Client, userAgent, domain, email string) []AutodiscoverProbe {
body := fmt.Sprintf(autodiscoverRequestTemplate, xmlEscape(email))
type target struct {
source string
url string
}
targets := []target{
{"subdomain", fmt.Sprintf("https://autodiscover.%s/autodiscover/autodiscover.xml", domain)},
{"root", fmt.Sprintf("https://%s/autodiscover/autodiscover.xml", domain)},
}
probes := make([]AutodiscoverProbe, len(targets))
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
go func(i int, source, rawURL string) {
defer wg.Done()
probes[i] = runAutodiscoverProbe(ctx, client, userAgent, source, rawURL, body)
}(i, t.source, t.url)
}
wg.Wait()
return probes
}
func runAutodiscoverProbe(ctx context.Context, client *http.Client, userAgent, source, rawURL, requestBody string) AutodiscoverProbe {
res, body := fetch(ctx, client, userAgent, http.MethodPost, rawURL, strings.NewReader(requestBody), "text/xml; charset=utf-8")
probe := AutodiscoverProbe{Source: source, Result: res}
if res.Error != "" || res.StatusCode < 200 || res.StatusCode >= 300 || len(body) == 0 {
return probe
}
parsed, err := parseAutodiscoverResponse(body)
if err != nil {
probe.Result.ParseError = err.Error()
return probe
}
probe.Parsed = parsed
return probe
}
func xmlEscape(s string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}

100
checker/definition.go Normal file
View file

@ -0,0 +1,100 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is overridden at link time by the build, or by the plugin loader.
var Version = "built-in"
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "email-autoconfig",
Name: "Email Autoconfiguration",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyAutoconfig},
HasHTMLReport: true,
Options: sdk.CheckerOptionsDocumentation{
DomainOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Domain name",
AutoFill: sdk.AutoFillDomainName,
Required: true,
},
},
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "probeEmail",
Type: "string",
Label: "Local-part used in probes",
Description: "Local part sent in the autoconfig URL query string (before @). Most servers ignore it, but some providers branch on the user.",
Default: "test",
},
{
Id: "httpTimeout",
Type: "number",
Label: "HTTP timeout (seconds)",
Description: "Per-request timeout when probing autoconfig endpoints.",
Default: float64(8),
},
{
Id: "tryISPDB",
Type: "bool",
Label: "Try Mozilla ISPDB fallback",
Description: "When the domain itself does not publish an autoconfig file, try Mozilla's public Thunderbird ISPDB as an additional probe.",
Default: true,
},
{
Id: "tryHTTPAutoconfig",
Type: "bool",
Label: "Allow plain-HTTP fallback probe",
Description: "Also attempt the plain-HTTP variant of autoconfig.<domain> (the draft lists it as optional). Useful to spot providers still serving over HTTP.",
Default: false,
},
{
Id: "tryAutodiscoverPost",
Type: "bool",
Label: "Probe Microsoft Autodiscover (POST)",
Description: "Probe the Exchange/Outlook Autodiscover endpoints. Disable to check only the Thunderbird flow.",
Default: true,
},
},
AdminOpts: []sdk.CheckerOptionDocumentation{
{
Id: "ispdbURL",
Type: "string",
Label: "Mozilla ISPDB base URL",
Default: "https://autoconfig.thunderbird.net/v1.1/",
Description: "Base URL for Mozilla's autoconfig fallback database.",
},
{
Id: "userAgent",
Type: "string",
Label: "User-Agent used in probes",
Default: "happyDomain-autoconfig/1.0 (+https://happydomain.org)",
Description: "Identifies the checker in probe HTTP logs.",
},
},
},
Rules: []sdk.CheckRule{
PresenceRule(),
PreferredEndpointRule(),
TLSRule(),
EncryptionRule(),
ConsistencyRule(),
SRVRule(),
AutodiscoverRule(),
},
Interval: &sdk.CheckIntervalSpec{
Min: 15 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
}
}

85
checker/interactive.go Normal file
View file

@ -0,0 +1,85 @@
//go:build standalone
package checker
import (
"errors"
"fmt"
"net/http"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm describes the standalone /check page inputs.
func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: sdk.AutoFillDomainName,
Type: "string",
Label: "Domain name",
Placeholder: "example.com",
Required: true,
Description: "Domain to probe for email autoconfiguration.",
},
{
Id: "probeEmail",
Type: "string",
Label: "Local-part used in probes",
Placeholder: "test",
Default: "test",
Description: "Local part sent in the autoconfig URL query string (before @).",
},
{
Id: "tryISPDB",
Type: "bool",
Label: "Try Mozilla ISPDB fallback",
Default: true,
Description: "Probe Mozilla's public Thunderbird ISPDB when the domain itself does not publish autoconfig.",
},
{
Id: "tryHTTPAutoconfig",
Type: "bool",
Label: "Allow plain-HTTP fallback probe",
Default: false,
Description: "Also attempt the plain-HTTP variant of autoconfig.<domain>.",
},
{
Id: "tryAutodiscoverPost",
Type: "bool",
Label: "Probe Microsoft Autodiscover (POST)",
Default: true,
Description: "Exercise Exchange/Outlook Autodiscover endpoints as well.",
},
}
}
// ParseForm builds CheckerOptions from the submitted form. All probing is
// deferred to Collect, so we only validate the domain shape here.
func (p *autoconfigProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
if err := r.ParseForm(); err != nil {
return nil, fmt.Errorf("parse form: %w", err)
}
domain := strings.TrimSuffix(strings.TrimSpace(r.FormValue(sdk.AutoFillDomainName)), ".")
if domain == "" {
return nil, errors.New("domain name is required")
}
opts := sdk.CheckerOptions{
sdk.AutoFillDomainName: domain,
}
if v := strings.TrimSpace(r.FormValue("probeEmail")); v != "" {
opts["probeEmail"] = v
}
// HTML omits unchecked boxes; treat absence as "use the documented default".
for _, key := range []string{"tryISPDB", "tryHTTPAutoconfig", "tryAutodiscoverPost"} {
if _, ok := r.Form[key]; !ok {
continue
}
opts[key] = r.FormValue(key) == "true"
}
return opts, nil
}

202
checker/parse.go Normal file
View file

@ -0,0 +1,202 @@
package checker
import (
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
)
// ── Thunderbird autoconfig (clientConfig) ────────────────────────────────────
type rawClientConfig struct {
XMLName xml.Name `xml:"clientConfig"`
Version string `xml:"version,attr"`
EmailProvider rawEmailProvider `xml:"emailProvider"`
}
type rawEmailProvider struct {
ID string `xml:"id,attr"`
DisplayName string `xml:"displayName"`
ShortName string `xml:"displayShortName"`
Domains []string `xml:"domain"`
Incoming []rawServer `xml:"incomingServer"`
Outgoing []rawServer `xml:"outgoingServer"`
AddressBook []rawDav `xml:"addressBook"`
Calendar []rawDav `xml:"calendar"`
WebMail *rawWebMail `xml:"webMail"`
Documentation []rawDocuments `xml:"documentation"`
}
type rawServer struct {
Type string `xml:"type,attr"`
Hostname string `xml:"hostname"`
Port string `xml:"port"`
SocketType string `xml:"socketType"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
}
type rawDav struct {
Type string `xml:"type,attr"`
Username string `xml:"username"`
Authentication string `xml:"authentication"`
ServerURL string `xml:"serverURL"`
}
type rawWebMail struct {
LoginPage struct {
URL string `xml:"url,attr"`
} `xml:"loginPage"`
}
type rawDocuments struct {
URL string `xml:"url,attr"`
Descr string `xml:"descr"`
}
// parseClientConfig decodes a clientConfig document.
func parseClientConfig(body []byte) (*ClientConfig, error) {
body = bytes.TrimSpace(body)
if len(body) == 0 {
return nil, fmt.Errorf("empty body")
}
// Cheap reject before invoking the XML decoder.
if !bytes.Contains(body, []byte("<clientConfig")) {
return nil, fmt.Errorf("not a clientConfig document")
}
var raw rawClientConfig
if err := xml.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("XML decode: %w", err)
}
if len(raw.EmailProvider.Incoming) == 0 && len(raw.EmailProvider.Outgoing) == 0 {
return nil, fmt.Errorf("no incoming/outgoing server defined")
}
cfg := &ClientConfig{
Version: raw.Version,
EmailProviderID: raw.EmailProvider.ID,
DisplayName: raw.EmailProvider.DisplayName,
ShortName: raw.EmailProvider.ShortName,
Domains: raw.EmailProvider.Domains,
}
for _, s := range raw.EmailProvider.Incoming {
cfg.Incoming = append(cfg.Incoming, convertServer(s))
}
for _, s := range raw.EmailProvider.Outgoing {
cfg.Outgoing = append(cfg.Outgoing, convertServer(s))
}
for _, d := range raw.EmailProvider.AddressBook {
cfg.AddressBook = append(cfg.AddressBook, convertDav(d))
}
for _, d := range raw.EmailProvider.Calendar {
cfg.Calendar = append(cfg.Calendar, convertDav(d))
}
if raw.EmailProvider.WebMail != nil && raw.EmailProvider.WebMail.LoginPage.URL != "" {
cfg.WebMail = &WebMail{LoginPage: raw.EmailProvider.WebMail.LoginPage.URL}
}
for _, d := range raw.EmailProvider.Documentation {
cfg.Documentation = append(cfg.Documentation, Documentation{URL: d.URL, Descr: d.Descr})
}
return cfg, nil
}
func convertDav(d rawDav) DavServer {
return DavServer{
Type: d.Type,
Username: d.Username,
Authentication: d.Authentication,
ServerURL: d.ServerURL,
}
}
func convertServer(s rawServer) ServerConfig {
port, _ := strconv.Atoi(strings.TrimSpace(s.Port))
return ServerConfig{
Type: strings.ToLower(strings.TrimSpace(s.Type)),
Hostname: strings.TrimSpace(s.Hostname),
Port: port,
SocketType: strings.TrimSpace(s.SocketType),
Username: strings.TrimSpace(s.Username),
Authentication: strings.TrimSpace(s.Authentication),
}
}
// ── Microsoft Autodiscover POX response ──────────────────────────────────────
type rawAutodiscover struct {
XMLName xml.Name `xml:"Autodiscover"`
Response rawADResponse `xml:"Response"`
}
type rawADResponse struct {
User rawADUser `xml:"User"`
Account rawADAccount `xml:"Account"`
}
type rawADUser struct {
DisplayName string `xml:"DisplayName"`
}
type rawADAccount struct {
Action string `xml:"Action"`
RedirectAddr string `xml:"RedirectAddr"`
RedirectURL string `xml:"RedirectUrl"`
Protocols []rawADProto `xml:"Protocol"`
}
type rawADProto struct {
Type string `xml:"Type"`
Server string `xml:"Server"`
Port string `xml:"Port"`
Encryption string `xml:"Encryption"`
SSL string `xml:"SSL"`
LoginName string `xml:"LoginName"`
DomainRequired string `xml:"DomainRequired"`
AuthRequired string `xml:"AuthRequired"`
}
func parseAutodiscoverResponse(body []byte) (*AutodiscoverResponse, error) {
body = bytes.TrimSpace(body)
if len(body) == 0 {
return nil, fmt.Errorf("empty body")
}
if !bytes.Contains(body, []byte("Autodiscover")) {
return nil, fmt.Errorf("not an Autodiscover document")
}
var raw rawAutodiscover
dec := xml.NewDecoder(bytes.NewReader(body))
// Exchange responses are namespaced; we accept any.
dec.Strict = false
if err := dec.Decode(&raw); err != nil {
return nil, fmt.Errorf("XML decode: %w", err)
}
r := &AutodiscoverResponse{
DisplayName: strings.TrimSpace(raw.Response.User.DisplayName),
RedirectAddr: strings.TrimSpace(raw.Response.Account.RedirectAddr),
RedirectURL: strings.TrimSpace(raw.Response.Account.RedirectURL),
}
for _, p := range raw.Response.Account.Protocols {
port, _ := strconv.Atoi(strings.TrimSpace(p.Port))
r.Protocols = append(r.Protocols, AutodiscoverProtocol{
Type: strings.ToUpper(strings.TrimSpace(p.Type)),
Server: strings.TrimSpace(p.Server),
Port: port,
Encryption: strings.TrimSpace(p.Encryption),
SSL: strings.TrimSpace(p.SSL),
LoginName: strings.TrimSpace(p.LoginName),
DomainRequired: strings.TrimSpace(p.DomainRequired),
AuthRequired: strings.TrimSpace(p.AuthRequired),
})
}
// A bare redirect is valid; accept it.
if len(r.Protocols) == 0 && r.RedirectAddr == "" && r.RedirectURL == "" {
return nil, fmt.Errorf("Autodiscover response has no protocol or redirect")
}
return r, nil
}

19
checker/provider.go Normal file
View file

@ -0,0 +1,19 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
func Provider() sdk.ObservationProvider {
return &autoconfigProvider{}
}
type autoconfigProvider struct{}
func (p *autoconfigProvider) Key() sdk.ObservationKey {
return ObservationKeyAutoconfig
}
func (p *autoconfigProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

417
checker/report.go Normal file
View file

@ -0,0 +1,417 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type reportStatus string
const (
statusOK reportStatus = "ok"
statusWarn reportStatus = "warn"
statusFail reportStatus = "fail"
statusInfo reportStatus = "info"
statusSkip reportStatus = "skip"
)
type reportProbe struct {
ProbeResult
Source string
Verdict reportStatus
VerdictText string
}
type reportServer struct {
Type string
Hostname string
Port int
SocketType string
Authentication string
Encrypted bool
AuthSafe bool
}
type reportRemediation struct {
Title string
Body template.HTML
}
type reportData struct {
Domain string
Email string
HeadlineBadge string
HeadlineClass reportStatus
HeadlineText string
Summary []reportSummaryItem
Autoconfig []reportProbe
Autodiscover []reportProbe
SRVRecords []SRVRecord
MX []string
ConfigServers struct {
Incoming []reportServer
Outgoing []reportServer
CardDAV []DavServer
CalDAV []DavServer
}
Remediations []reportRemediation
ExampleXML template.HTML
}
type reportSummaryItem struct {
Label string
Status reportStatus
Message string
}
// GetHTMLReport implements sdk.CheckerHTMLReporter.
func (p *autoconfigProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var d Data
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
return "", fmt.Errorf("decode autoconfig data: %w", err)
}
data := buildReport(&d)
var buf strings.Builder
buf.Grow(32 * 1024)
if err := autoconfigHTMLTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("render: %w", err)
}
return buf.String(), nil
}
func buildReport(d *Data) reportData {
r := reportData{
Domain: d.Domain,
Email: d.Email,
}
for _, m := range d.MX {
r.MX = append(r.MX, fmt.Sprintf("%s (pref %d)", m.Host, m.Preference))
}
hasParsed := false
hasAutoconfigOK := false
hasWellKnownOK := false
plaintextOK := false
tlsFailures := 0
for _, p := range d.Autoconfig {
rp := reportProbe{ProbeResult: p.Result}
switch {
case p.Parsed != nil:
rp.Verdict = statusOK
rp.VerdictText = "Parsed OK"
hasParsed = true
if p.Source == "autoconfig" {
hasAutoconfigOK = true
}
if p.Source == "wellknown" {
hasWellKnownOK = true
}
if strings.HasPrefix(p.Result.URL, "http://") {
plaintextOK = true
}
case p.Result.TLSError != "":
rp.Verdict = statusFail
rp.VerdictText = "TLS error"
tlsFailures++
case p.Result.Error != "":
rp.Verdict = statusFail
rp.VerdictText = "Unreachable"
case p.Result.ParseError != "":
rp.Verdict = statusFail
rp.VerdictText = "XML parse error"
case p.Result.StatusCode >= 400:
rp.Verdict = statusFail
rp.VerdictText = fmt.Sprintf("HTTP %d", p.Result.StatusCode)
default:
rp.Verdict = statusWarn
rp.VerdictText = fmt.Sprintf("HTTP %d (no config)", p.Result.StatusCode)
}
rp.Source = probeSourceLabel(p.Source)
r.Autoconfig = append(r.Autoconfig, rp)
}
hasAutodiscover := false
for _, p := range d.Autodiscover {
rp := reportProbe{ProbeResult: p.Result}
switch {
case p.Parsed != nil:
rp.Verdict = statusOK
rp.VerdictText = "Parsed OK"
hasAutodiscover = true
case p.Result.Error != "":
rp.Verdict = statusSkip
rp.VerdictText = "Unreachable"
case p.Result.ParseError != "":
rp.Verdict = statusFail
rp.VerdictText = "XML parse error"
case p.Result.StatusCode >= 400:
rp.Verdict = statusSkip
rp.VerdictText = fmt.Sprintf("HTTP %d", p.Result.StatusCode)
default:
rp.Verdict = statusWarn
rp.VerdictText = fmt.Sprintf("HTTP %d", p.Result.StatusCode)
}
rp.Source = probeSourceLabel(p.Source)
r.Autodiscover = append(r.Autodiscover, rp)
}
hasIncomingSRV, hasSubmissionSRV := false, false
for _, s := range d.SRV {
if !s.Skip {
inc, sub := classifySRV(s.Service)
hasIncomingSRV = hasIncomingSRV || inc
hasSubmissionSRV = hasSubmissionSRV || sub
}
}
r.SRVRecords = d.SRV
if d.ClientConfig != nil {
for _, s := range d.ClientConfig.Incoming {
r.ConfigServers.Incoming = append(r.ConfigServers.Incoming, toReportServer(s))
}
for _, s := range d.ClientConfig.Outgoing {
r.ConfigServers.Outgoing = append(r.ConfigServers.Outgoing, toReportServer(s))
}
r.ConfigServers.CardDAV = d.ClientConfig.AddressBook
r.ConfigServers.CalDAV = d.ClientConfig.Calendar
}
switch {
case hasParsed && hasAutoconfigOK && tlsFailures == 0 && !plaintextOK:
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "OK", statusOK, "Email autoconfiguration is published and healthy."
case hasParsed && tlsFailures == 0:
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "PARTIAL", statusWarn, "Autoconfig works but has room for improvement (see remediation)."
case hasParsed && tlsFailures > 0:
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "TLS ISSUE", statusFail, "Autoconfig answers but at least one endpoint has a TLS problem."
case hasAutodiscover:
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "LEGACY ONLY", statusWarn, "Only Microsoft Autodiscover answers. Thunderbird/K9/Evolution users will see manual setup."
case hasIncomingSRV && hasSubmissionSRV:
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "SRV ONLY", statusWarn, "Only RFC 6186 SRV records; most clients still need a clientConfig XML."
default:
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "NOT FOUND", statusFail, "No email autoconfiguration was discovered for this domain."
}
r.Summary = []reportSummaryItem{
{"Primary autoconfig (autoconfig." + d.Domain + ")", boolStatus(hasAutoconfigOK), primaryMsg(hasAutoconfigOK)},
{".well-known/autoconfig", boolStatus(hasWellKnownOK), wellKnownMsg(hasWellKnownOK)},
{"Microsoft Autodiscover", boolStatus(hasAutodiscover), autodiscoverMsg(hasAutodiscover)},
{"RFC 6186 SRV records", srvStatus(hasIncomingSRV, hasSubmissionSRV), srvMsg(hasIncomingSRV, hasSubmissionSRV)},
}
r.Remediations = buildRemediations(d, hasParsed, hasAutoconfigOK, hasWellKnownOK, plaintextOK, tlsFailures, hasIncomingSRV, hasSubmissionSRV)
r.ExampleXML = template.HTML(exampleClientConfig(d.Domain))
return r
}
func boolStatus(ok bool) reportStatus {
if ok {
return statusOK
}
return statusFail
}
func primaryMsg(ok bool) string {
if ok {
return "Reachable and serves a valid clientConfig."
}
return "Not reachable. This is the first URL Thunderbird tries."
}
func wellKnownMsg(ok bool) string {
if ok {
return "Answers on the domain apex."
}
return "Not exposed. Optional, but useful when you cannot add a subdomain."
}
func autodiscoverMsg(ok bool) string {
if ok {
return "Responds. Microsoft Outlook / mobile clients will find your server."
}
return "Silent. Outlook-for-Windows and iOS Mail rely on this."
}
func srvStatus(inc, sub bool) reportStatus {
switch {
case inc && sub:
return statusOK
case inc || sub:
return statusWarn
}
return statusInfo
}
func srvMsg(inc, sub bool) string {
switch {
case inc && sub:
return "Incoming and submission SRV records published."
case inc:
return "Submission SRV missing (_submissions._tcp)."
case sub:
return "Incoming SRV missing (_imaps._tcp / _pop3s._tcp)."
}
return "No RFC 6186 SRV records. Not mandatory, but a cheap safety net."
}
var probeLabelMap = map[string]string{
"autoconfig": "autoconfig.<domain>",
"wellknown": ".well-known/autoconfig",
"http-autoconfig": "http://autoconfig.<domain>",
"ispdb": "Mozilla ISPDB",
"mx-autoconfig": "MX-parent autoconfig",
"mx-ispdb": "MX-parent ISPDB",
"subdomain": "autodiscover.<domain>",
"root": "apex /autodiscover",
}
func probeSourceLabel(src string) string {
if label, ok := probeLabelMap[src]; ok {
return label
}
return src
}
func toReportServer(s ServerConfig) reportServer {
return reportServer{
Type: s.Type,
Hostname: s.Hostname,
Port: s.Port,
SocketType: s.SocketType,
Authentication: s.Authentication,
Encrypted: isEncryptedSocket(s.SocketType),
AuthSafe: !strings.EqualFold(s.Authentication, "password-cleartext") || isEncryptedSocket(s.SocketType),
}
}
// ── Remediation snippets ────────────────────────────────────────────────────
func buildRemediations(d *Data, hasParsed, hasAutoconfig, hasWellKnown, plaintextOK bool, tlsFailures int, hasIncomingSRV, hasSubmissionSRV bool) []reportRemediation {
var out []reportRemediation
if !hasParsed {
out = append(out, reportRemediation{
Title: "Publish an autoconfig XML file",
Body: template.HTML(fmt.Sprintf(`
<p>Clients such as Thunderbird, K-9 Mail, Evolution and KMail will query
<code>https://autoconfig.%[1]s/mail/config-v1.1.xml</code> when the user
types <code>user@%[1]s</code>. Publishing the file removes the need for
manual IMAP/SMTP setup.</p>
<ol>
<li>Create a subdomain <code>autoconfig.%[1]s</code> pointing to a TLS-enabled web server (a 200-byte static file is enough).</li>
<li>Drop the XML below at <code>/mail/config-v1.1.xml</code> and make sure it is served with <code>Content-Type: text/xml</code>.</li>
<li>Do the same at <code>https://%[1]s/.well-known/autoconfig/mail/config-v1.1.xml</code> so users who cannot add a subdomain still get configured.</li>
</ol>`, d.Domain)),
})
}
if hasParsed && !hasAutoconfig && hasWellKnown {
out = append(out, reportRemediation{
Title: "Add the autoconfig.<domain> subdomain",
Body: template.HTML(fmt.Sprintf(`
<p>Only the <code>.well-known</code> fallback responded. Thunderbird tries
<code>autoconfig.%[1]s</code> <em>first</em>, so adding the subdomain is a
cheap win. Copy the XML from your apex, expose it on
<code>https://autoconfig.%[1]s/mail/config-v1.1.xml</code>.</p>`, d.Domain)),
})
}
if plaintextOK {
out = append(out, reportRemediation{
Title: "Stop serving autoconfig over HTTP",
Body: template.HTML(`
<p>The draft requires clients to ignore plaintext responses unless HTTPS
fails. Clients will still warn the user. Redirect HTTP to HTTPS and drop
the plaintext virtualhost entirely.</p>`),
})
}
if tlsFailures > 0 {
out = append(out, reportRemediation{
Title: "Fix the TLS certificate on the autoconfig endpoint",
Body: template.HTML(fmt.Sprintf(`
<p>At least one autoconfig endpoint failed certificate verification
(expired, self-signed, hostname mismatch or unknown CA). Clients will
refuse the document outright.</p>
<p>Issue a certificate that covers <code>autoconfig.%[1]s</code> (and
<code>%[1]s</code> if you serve <code>.well-known</code>). Let's Encrypt
works out of the box.</p>`, d.Domain)),
})
}
if d.ClientConfig != nil {
servers := make([]ServerConfig, 0, len(d.ClientConfig.Incoming)+len(d.ClientConfig.Outgoing))
servers = append(servers, d.ClientConfig.Incoming...)
servers = append(servers, d.ClientConfig.Outgoing...)
for _, s := range servers {
if !isEncryptedSocket(s.SocketType) {
out = append(out, reportRemediation{
Title: "Remove plaintext server definitions",
Body: template.HTML(fmt.Sprintf(`
<p>The server <code>%s %s:%d</code> is advertised with
<code>socketType=%s</code>. Clients that apply your config will send the
password in clear. Switch to:</p>
<ul>
<li><code>SSL</code> on IMAP 993 / POP3 995 / SMTP submission 465.</li>
<li><code>STARTTLS</code> on IMAP 143 or SMTP submission 587.</li>
</ul>`, s.Type, s.Hostname, s.Port, s.SocketType)),
})
break
}
}
}
if !hasIncomingSRV || !hasSubmissionSRV {
out = append(out, reportRemediation{
Title: "Publish RFC 6186 SRV records",
Body: template.HTML(fmt.Sprintf(`
<p>SRV records are a cheap safety net for clients that do not fetch an
autoconfig XML. Advertise IMAPS and submission:</p>
<pre>_imaps._tcp.%[1]s. IN SRV 0 1 993 imap.%[1]s.
_submissions._tcp.%[1]s. IN SRV 0 1 465 smtp.%[1]s.</pre>
<p>Use target <code>.</code> to explicitly declare a service as unsupported (e.g. <code>_pop3._tcp</code>).</p>`, d.Domain)),
})
}
return out
}
// exampleClientConfig returns a paste-ready XML snippet for the domain.
func exampleClientConfig(domain string) string {
if domain == "" {
domain = "example.com"
}
tpl := `&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;clientConfig version=&quot;1.1&quot;&gt;
&lt;emailProvider id=&quot;%[1]s&quot;&gt;
&lt;domain&gt;%[1]s&lt;/domain&gt;
&lt;displayName&gt;%[1]s Mail&lt;/displayName&gt;
&lt;displayShortName&gt;%[1]s&lt;/displayShortName&gt;
&lt;incomingServer type=&quot;imap&quot;&gt;
&lt;hostname&gt;imap.%[1]s&lt;/hostname&gt;
&lt;port&gt;993&lt;/port&gt;
&lt;socketType&gt;SSL&lt;/socketType&gt;
&lt;username&gt;%%EMAILADDRESS%%&lt;/username&gt;
&lt;authentication&gt;password-cleartext&lt;/authentication&gt;
&lt;/incomingServer&gt;
&lt;outgoingServer type=&quot;smtp&quot;&gt;
&lt;hostname&gt;smtp.%[1]s&lt;/hostname&gt;
&lt;port&gt;465&lt;/port&gt;
&lt;socketType&gt;SSL&lt;/socketType&gt;
&lt;username&gt;%%EMAILADDRESS%%&lt;/username&gt;
&lt;authentication&gt;password-cleartext&lt;/authentication&gt;
&lt;/outgoingServer&gt;
&lt;/emailProvider&gt;
&lt;/clientConfig&gt;`
return fmt.Sprintf(tpl, domain)
}

269
checker/report_template.go Normal file
View file

@ -0,0 +1,269 @@
package checker
import "html/template"
var templateFuncs = template.FuncMap{
"string": func(s reportStatus) string { return string(s) },
"badgeClass": func(s reportStatus) string {
switch s {
case statusOK:
return "badge-ok"
case statusWarn:
return "badge-warn"
case statusFail:
return "badge-fail"
case statusInfo:
return "badge-info"
}
return "badge-skip"
},
"chkClass": func(s reportStatus) string {
switch s {
case statusOK:
return "chk-ok"
case statusWarn:
return "chk-warn"
case statusFail:
return "chk-fail"
case statusInfo:
return "chk-info"
}
return "chk-skip"
},
"chkIcon": func(s reportStatus) string {
switch s {
case statusOK:
return "✓"
case statusWarn:
return "!"
case statusFail:
return "✗"
case statusInfo:
return "i"
}
return "·"
},
}
var autoconfigHTMLTemplate = template.Must(template.New("autoconfig").Funcs(templateFuncs).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Autoconfiguration Report: {{.Domain}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
pre { background: #f3f4f6; padding: .6rem .85rem; border-radius: 6px; overflow-x: auto; font-size: .82rem; }
h1 { margin: 0 0 .4rem; font-size: 1.25rem; }
h2 { margin: 0 0 .6rem; font-size: 1rem; }
h3 { margin: 0 0 .4rem; font-size: .9rem; }
.hd, .section { background: #fff; border-radius: 10px; padding: 1rem 1.2rem; margin-bottom: .75rem; box-shadow: 0 1px 3px rgba(0,0,0,.07); }
.badge { display: inline-flex; align-items: center; padding: .2em .7em; border-radius: 9999px; font-size: .78rem; font-weight: 700; letter-spacing: .02em; }
.badge-ok { background: #d1fae5; color: #065f46; }
.badge-warn { background: #fef3c7; color: #92400e; }
.badge-fail { background: #fee2e2; color: #991b1b; }
.badge-info { background: #dbeafe; color: #1e40af; }
.badge-skip { background: #e5e7eb; color: #4b5563; }
.summary-grid { display: grid; grid-template-columns: minmax(200px, auto) 1fr; row-gap: .4rem; column-gap: 1rem; align-items: center; }
.summary-grid > .s-label { font-weight: 600; font-size: .9rem; }
.summary-grid > .s-text { font-size: .87rem; color: #4b5563; }
details { border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: .45rem; overflow: hidden; }
summary { display: flex; gap: .5rem; align-items: center; padding: .55rem .85rem; cursor: pointer; user-select: none; }
summary::-webkit-details-marker { display: none; }
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; }
details[open] > summary::before { transform: rotate(90deg); }
.probe-source { font-weight: 600; flex: 1; }
.probe-url { color: #6b7280; font-size: .78rem; word-break: break-all; }
.details-body { padding: .5rem 1rem .8rem; border-top: 1px solid #f3f4f6; font-size: .85rem; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; margin-top: .25rem; font-size: .82rem; }
.kv > dt { color: #6b7280; font-weight: 600; }
.kv > dd { margin: 0; word-break: break-all; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .35rem .55rem; border-bottom: 1px solid #f3f4f6; }
th { color: #6b7280; font-weight: 600; }
.row-strike { color: #9ca3af; text-decoration: line-through; }
.tip { border-left: 4px solid #3b82f6; padding: .75rem 1rem; background: #eff6ff; border-radius: 0 6px 6px 0; margin-bottom: .6rem; }
.tip-title { font-weight: 700; margin-bottom: .3rem; }
.tip p, .tip ul, .tip ol, .tip pre { margin: .3rem 0; }
.chk { margin-right: .3rem; }
.chk-ok { color: #059669; }
.chk-warn { color: #b45309; }
.chk-fail { color: #dc2626; }
.chk-skip { color: #6b7280; }
.chk-info { color: #1e40af; }
.mini { color: #6b7280; font-size: .82rem; }
</style>
</head>
<body>
<div class="hd">
<h1>Email Autoconfiguration: <code>{{.Domain}}</code></h1>
<span class="badge {{badgeClass .HeadlineClass}}">{{.HeadlineBadge}}</span>
<p class="mini">{{.HeadlineText}}</p>
{{if .MX}}<p class="mini">MX: {{range $i, $m := .MX}}{{if $i}}, {{end}}<code>{{$m}}</code>{{end}}</p>{{end}}
</div>
<div class="section">
<h2>Summary</h2>
<div class="summary-grid">
{{range .Summary}}
<div class="s-label">
<span class="chk {{chkClass .Status}}">{{chkIcon .Status}}</span>{{.Label}}
</div>
<div class="s-text">{{.Message}}</div>
{{end}}
</div>
</div>
{{if .Remediations}}
<div class="section">
<h2>Fix this first</h2>
{{range .Remediations}}
<div class="tip">
<div class="tip-title">{{.Title}}</div>
{{.Body}}
</div>
{{end}}
</div>
{{end}}
<div class="section">
<h2>Thunderbird-style probes ({{len .Autoconfig}})</h2>
{{range .Autoconfig}}
<details{{if eq (string .Verdict) "fail"}} open{{end}}>
<summary>
<span class="probe-source">{{.Source}}</span>
<span class="probe-url"><code>{{.URL}}</code></span>
<span class="badge {{badgeClass .Verdict}}">{{.VerdictText}}</span>
</summary>
<div class="details-body">
<dl class="kv">
{{if .StatusCode}}<dt>HTTP</dt><dd>{{.StatusCode}}</dd>{{end}}
{{if .ContentType}}<dt>Content-Type</dt><dd>{{.ContentType}}</dd>{{end}}
{{if .DurationMs}}<dt>Duration</dt><dd>{{.DurationMs}} ms</dd>{{end}}
{{if .BodyBytes}}<dt>Body size</dt><dd>{{.BodyBytes}} bytes</dd>{{end}}
{{if .Redirected}}<dt>Final URL</dt><dd><code>{{.FinalURL}}</code></dd>{{end}}
{{if .TLSSubject}}<dt>TLS subject</dt><dd>{{.TLSSubject}}</dd>{{end}}
{{if .TLSIssuer}}<dt>TLS issuer</dt><dd>{{.TLSIssuer}}</dd>{{end}}
{{if .TLSNotAfter}}<dt>Expires</dt><dd>{{.TLSNotAfter}}</dd>{{end}}
{{if .TLSError}}<dt>TLS error</dt><dd style="color:#dc2626">{{.TLSError}}</dd>{{end}}
{{if .Error}}<dt>Error</dt><dd style="color:#dc2626">{{.Error}}</dd>{{end}}
{{if .ParseError}}<dt>Parse error</dt><dd style="color:#dc2626">{{.ParseError}}</dd>{{end}}
</dl>
</div>
</details>
{{end}}
</div>
{{if .ConfigServers.Incoming}}
<div class="section">
<h2>Servers advertised by clientConfig</h2>
<h3>Incoming</h3>
<table>
<tr><th>Type</th><th>Hostname</th><th>Port</th><th>Socket</th><th>Auth</th></tr>
{{range .ConfigServers.Incoming}}
<tr>
<td>{{.Type}}</td>
<td><code>{{.Hostname}}</code></td>
<td>{{.Port}}</td>
<td>{{if .Encrypted}}<span class="chk-ok">{{.SocketType}}</span>{{else}}<span class="chk-fail">{{.SocketType}}</span>{{end}}</td>
<td>{{if .AuthSafe}}{{.Authentication}}{{else}}<span class="chk-fail">{{.Authentication}}</span>{{end}}</td>
</tr>
{{end}}
</table>
{{if .ConfigServers.Outgoing}}
<h3 style="margin-top:.7rem">Outgoing</h3>
<table>
<tr><th>Type</th><th>Hostname</th><th>Port</th><th>Socket</th><th>Auth</th></tr>
{{range .ConfigServers.Outgoing}}
<tr>
<td>{{.Type}}</td>
<td><code>{{.Hostname}}</code></td>
<td>{{.Port}}</td>
<td>{{if .Encrypted}}<span class="chk-ok">{{.SocketType}}</span>{{else}}<span class="chk-fail">{{.SocketType}}</span>{{end}}</td>
<td>{{if .AuthSafe}}{{.Authentication}}{{else}}<span class="chk-fail">{{.Authentication}}</span>{{end}}</td>
</tr>
{{end}}
</table>
{{end}}
{{if or .ConfigServers.CardDAV .ConfigServers.CalDAV}}
<h3 style="margin-top:.7rem">Personal data (xDAV)</h3>
<ul>
{{range .ConfigServers.CardDAV}}<li>CardDAV: <code>{{.ServerURL}}</code></li>{{end}}
{{range .ConfigServers.CalDAV}}<li>CalDAV: <code>{{.ServerURL}}</code></li>{{end}}
</ul>
{{end}}
</div>
{{end}}
<div class="section">
<h2>RFC 6186 SRV records</h2>
{{if .SRVRecords}}
<table>
<tr><th>Service</th><th>Target</th><th>Port</th><th>Prio</th><th>Weight</th></tr>
{{range .SRVRecords}}
<tr{{if .Skip}} class="row-strike"{{end}}>
<td><code>{{.Service}}</code></td>
<td>{{if .Skip}}<em>disabled (.)</em>{{else}}<code>{{.Target}}</code>{{end}}</td>
<td>{{.Port}}</td>
<td>{{.Priority}}</td>
<td>{{.Weight}}</td>
</tr>
{{end}}
</table>
{{else}}
<p class="mini">No SRV records found.</p>
{{end}}
</div>
<div class="section">
<h2>Microsoft Autodiscover ({{len .Autodiscover}})</h2>
{{if .Autodiscover}}
{{range .Autodiscover}}
<details{{if eq (string .Verdict) "fail"}} open{{end}}>
<summary>
<span class="probe-source">{{.Source}}</span>
<span class="probe-url"><code>{{.URL}}</code></span>
<span class="badge {{badgeClass .Verdict}}">{{.VerdictText}}</span>
</summary>
<div class="details-body">
<dl class="kv">
{{if .StatusCode}}<dt>HTTP</dt><dd>{{.StatusCode}}</dd>{{end}}
{{if .DurationMs}}<dt>Duration</dt><dd>{{.DurationMs}} ms</dd>{{end}}
{{if .TLSSubject}}<dt>TLS subject</dt><dd>{{.TLSSubject}}</dd>{{end}}
{{if .Error}}<dt>Error</dt><dd style="color:#dc2626">{{.Error}}</dd>{{end}}
{{if .ParseError}}<dt>Parse error</dt><dd style="color:#dc2626">{{.ParseError}}</dd>{{end}}
</dl>
</div>
</details>
{{end}}
{{else}}
<p class="mini">Autodiscover probes were disabled.</p>
{{end}}
</div>
<div class="section">
<h2>Example <code>config-v1.1.xml</code></h2>
<p class="mini">Paste-ready starting point. Adjust hostnames and ports before publishing.</p>
<pre>{{.ExampleXML}}</pre>
</div>
</body>
</html>`))

518
checker/rule.go Normal file
View 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.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
}

148
checker/types.go Normal file
View file

@ -0,0 +1,148 @@
// Package checker probes a domain for the three competing email
// autoconfiguration mechanisms (Bucksch autoconfig, Microsoft Autodiscover
// POX, RFC 6186 SRV) and cross-checks them.
package checker
import (
"time"
)
// ObservationKeyAutoconfig is the observation key for autoconfig data.
const ObservationKeyAutoconfig = "email_autoconfig"
// Data is the full collected payload.
type Data struct {
Domain string `json:"domain"`
Email string `json:"email"`
CollectedAt time.Time `json:"collected_at"`
MX []MXRecord `json:"mx,omitempty"`
MXError string `json:"mx_error,omitempty"`
SRV []SRVRecord `json:"srv,omitempty"`
Autoconfig []AutoconfigProbe `json:"autoconfig,omitempty"`
Autodiscover []AutodiscoverProbe `json:"autodiscover,omitempty"`
// First successful autoconfig parse, promoted here for rules to consume.
ClientConfig *ClientConfig `json:"client_config,omitempty"`
ClientConfigSource string `json:"client_config_source,omitempty"`
AutodiscoverResult *AutodiscoverResponse `json:"autodiscover_result,omitempty"`
}
// MXRecord is a single MX record.
type MXRecord struct {
Host string `json:"host"`
Preference uint16 `json:"preference"`
}
// SRVRecord is a single RFC 6186 SRV record observation.
type SRVRecord struct {
Service string `json:"service"` // RFC 6186 tag, e.g. "_imaps._tcp"
Target string `json:"target"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"`
// Skip means the service is explicitly disabled (RFC 6186 target ".").
Skip bool `json:"skip,omitempty"`
}
// ProbeResult captures the outcome of a single HTTP probe.
type ProbeResult struct {
URL string `json:"url"`
Method string `json:"method,omitempty"`
StatusCode int `json:"status_code,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
ContentType string `json:"content_type,omitempty"`
BodyBytes int `json:"body_bytes,omitempty"`
Redirected bool `json:"redirected,omitempty"`
FinalURL string `json:"final_url,omitempty"`
TLSServerName string `json:"tls_server_name,omitempty"`
TLSIssuer string `json:"tls_issuer,omitempty"`
TLSSubject string `json:"tls_subject,omitempty"`
TLSNotAfter string `json:"tls_not_after,omitempty"`
TLSError string `json:"tls_error,omitempty"`
Error string `json:"error,omitempty"`
ParseError string `json:"parse_error,omitempty"`
}
// AutoconfigProbe is one probe attempt for Thunderbird-style autoconfig.
type AutoconfigProbe struct {
Source string `json:"source"` // "autoconfig", "wellknown", "http-autoconfig", "ispdb", "mx-autoconfig", "mx-ispdb"
Result ProbeResult `json:"result"`
Parsed *ClientConfig `json:"parsed,omitempty"`
}
// AutodiscoverProbe is one probe attempt for MS Autodiscover (POX).
type AutodiscoverProbe struct {
Source string `json:"source"` // "root", "subdomain", "srv", "redirect"
Result ProbeResult `json:"result"`
Parsed *AutodiscoverResponse `json:"parsed,omitempty"`
}
// ClientConfig is the parsed Thunderbird-style clientConfig document.
type ClientConfig struct {
Version string `json:"version,omitempty"`
EmailProviderID string `json:"email_provider_id,omitempty"`
DisplayName string `json:"display_name,omitempty"`
ShortName string `json:"short_name,omitempty"`
Domains []string `json:"domains,omitempty"`
Incoming []ServerConfig `json:"incoming,omitempty"`
Outgoing []ServerConfig `json:"outgoing,omitempty"`
AddressBook []DavServer `json:"address_book,omitempty"`
Calendar []DavServer `json:"calendar,omitempty"`
WebMail *WebMail `json:"webmail,omitempty"`
Documentation []Documentation `json:"documentation,omitempty"`
}
// ServerConfig is one incoming/outgoing server definition.
type ServerConfig struct {
Type string `json:"type"` // imap, pop3, smtp
Hostname string `json:"hostname"`
Port int `json:"port"`
SocketType string `json:"socket_type"` // plain, SSL, STARTTLS
Username string `json:"username"` // may contain %EMAIL% placeholders
Authentication string `json:"authentication"` // password-cleartext, password-encrypted, OAuth2, ...
}
// DavServer is a CardDAV or CalDAV server reference.
type DavServer struct {
Type string `json:"type"`
Username string `json:"username,omitempty"`
Authentication string `json:"authentication,omitempty"`
ServerURL string `json:"server_url,omitempty"`
}
// WebMail holds webmail configuration (if any).
type WebMail struct {
LoginPage string `json:"login_page,omitempty"`
}
// Documentation is a clientConfig <documentation> entry.
type Documentation struct {
URL string `json:"url,omitempty"`
Descr string `json:"descr,omitempty"`
}
// AutodiscoverResponse is the parsed POX response.
type AutodiscoverResponse struct {
DisplayName string `json:"display_name,omitempty"`
// Set when Action is redirectAddr / redirectUrl; mutually exclusive with Protocols.
RedirectAddr string `json:"redirect_addr,omitempty"`
RedirectURL string `json:"redirect_url,omitempty"`
Protocols []AutodiscoverProtocol `json:"protocols,omitempty"`
}
// AutodiscoverProtocol covers IMAP/POP/SMTP fields; Exchange-only protocols
// (EXCH/EXPR/MobileSync) are stored but not analysed.
type AutodiscoverProtocol struct {
Type string `json:"type"` // IMAP, POP3, SMTP, EXCH, EXPR, WEB, MobileSync
Server string `json:"server,omitempty"`
Port int `json:"port,omitempty"`
Encryption string `json:"encryption,omitempty"` // SSL, TLS, None
SSL string `json:"ssl,omitempty"` // on/off
LoginName string `json:"login_name,omitempty"`
DomainRequired string `json:"domain_required,omitempty"`
AuthRequired string `json:"auth_required,omitempty"`
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module git.happydns.org/checker-autoconfig
go 1.25.0
require git.happydns.org/checker-sdk-go v1.3.0

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A=
git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=

28
main.go Normal file
View file

@ -0,0 +1,28 @@
package main
import (
"flag"
"log"
autoconfig "git.happydns.org/checker-autoconfig/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
// Version is the standalone binary's version. It defaults to "custom-build"
// and is meant to be overridden by the CI at link time:
//
// go build -ldflags "-X main.Version=1.2.3" .
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
autoconfig.Version = Version
srv := server.New(autoconfig.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

17
plugin/plugin.go Normal file
View file

@ -0,0 +1,17 @@
// Command plugin is the happyDomain plugin entrypoint for the autoconfig checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
autoconfig "git.happydns.org/checker-autoconfig/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
autoconfig.Version = Version
return autoconfig.Definition(), autoconfig.Provider(), nil
}