Initial commit
This commit is contained in:
commit
c4bf833274
19 changed files with 2451 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-email-autoconfig
|
||||
checker-email-autoconfig.so
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
21
LICENSE
Normal 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
28
Makefile
Normal 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
26
NOTICE
Normal 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
99
README.md
Normal 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
450
checker/collect.go
Normal 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
100
checker/definition.go
Normal 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
85
checker/interactive.go
Normal 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
202
checker/parse.go
Normal 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
19
checker/provider.go
Normal 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
417
checker/report.go
Normal 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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="%[1]s">
|
||||
<domain>%[1]s</domain>
|
||||
<displayName>%[1]s Mail</displayName>
|
||||
<displayShortName>%[1]s</displayShortName>
|
||||
|
||||
<incomingServer type="imap">
|
||||
<hostname>imap.%[1]s</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%%EMAILADDRESS%%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</incomingServer>
|
||||
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>smtp.%[1]s</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%%EMAILADDRESS%%</username>
|
||||
<authentication>password-cleartext</authentication>
|
||||
</outgoingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>`
|
||||
return fmt.Sprintf(tpl, domain)
|
||||
}
|
||||
269
checker/report_template.go
Normal file
269
checker/report_template.go
Normal 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
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.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
148
checker/types.go
Normal 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
5
go.mod
Normal 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
2
go.sum
Normal 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
28
main.go
Normal 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
17
plugin/plugin.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue