From c4bf83327427c60ad0a24687ea4eeb2684805eb7 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 23 Apr 2026 17:45:37 +0700 Subject: [PATCH] Initial commit --- .gitignore | 2 + Dockerfile | 15 ++ LICENSE | 21 ++ Makefile | 28 ++ NOTICE | 26 ++ README.md | 99 +++++++ checker/collect.go | 450 ++++++++++++++++++++++++++++++++ checker/definition.go | 100 +++++++ checker/interactive.go | 85 ++++++ checker/parse.go | 202 +++++++++++++++ checker/provider.go | 19 ++ checker/report.go | 417 +++++++++++++++++++++++++++++ checker/report_template.go | 269 +++++++++++++++++++ checker/rule.go | 518 +++++++++++++++++++++++++++++++++++++ checker/types.go | 148 +++++++++++ go.mod | 5 + go.sum | 2 + main.go | 28 ++ plugin/plugin.go | 17 ++ 19 files changed, 2451 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/interactive.go create mode 100644 checker/parse.go create mode 100644 checker/provider.go create mode 100644 checker/report.go create mode 100644 checker/report_template.go create mode 100644 checker/rule.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d395012 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-email-autoconfig +checker-email-autoconfig.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..64d656c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6055491 --- /dev/null +++ b/Makefile @@ -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 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..3d3ad7b --- /dev/null +++ b/NOTICE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c37f7d --- /dev/null +++ b/README.md @@ -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./mail/config-v1.1.xml` (primary) + - `https:///.well-known/autoconfig/mail/config-v1.1.xml` (apex fallback) + - `http://autoconfig./...` (optional; surfaced as a warning) + - Mozilla ISPDB fallback (`autoconfig.thunderbird.net`) + - MX-parent fallbacks for hosted domains +- **Microsoft Autodiscover** POX (`https://autodiscover./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.` (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.` + 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`. diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..d078f32 --- /dev/null +++ b/checker/collect.go @@ -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 = ` + + + %s + http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a + +` + +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() +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..e57c596 --- /dev/null +++ b/checker/definition.go @@ -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. (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, + }, + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..6d35f7c --- /dev/null +++ b/checker/interactive.go @@ -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..", + }, + { + 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 +} diff --git a/checker/parse.go b/checker/parse.go new file mode 100644 index 0000000..ec0cd6a --- /dev/null +++ b/checker/parse.go @@ -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("= 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.", + "wellknown": ".well-known/autoconfig", + "http-autoconfig": "http://autoconfig.", + "ispdb": "Mozilla ISPDB", + "mx-autoconfig": "MX-parent autoconfig", + "mx-ispdb": "MX-parent ISPDB", + "subdomain": "autodiscover.", + "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(` +

Clients such as Thunderbird, K-9 Mail, Evolution and KMail will query +https://autoconfig.%[1]s/mail/config-v1.1.xml when the user +types user@%[1]s. Publishing the file removes the need for +manual IMAP/SMTP setup.

+
    +
  1. Create a subdomain autoconfig.%[1]s pointing to a TLS-enabled web server (a 200-byte static file is enough).
  2. +
  3. Drop the XML below at /mail/config-v1.1.xml and make sure it is served with Content-Type: text/xml.
  4. +
  5. Do the same at https://%[1]s/.well-known/autoconfig/mail/config-v1.1.xml so users who cannot add a subdomain still get configured.
  6. +
`, d.Domain)), + }) + } + + if hasParsed && !hasAutoconfig && hasWellKnown { + out = append(out, reportRemediation{ + Title: "Add the autoconfig. subdomain", + Body: template.HTML(fmt.Sprintf(` +

Only the .well-known fallback responded. Thunderbird tries +autoconfig.%[1]s first, so adding the subdomain is a +cheap win. Copy the XML from your apex, expose it on +https://autoconfig.%[1]s/mail/config-v1.1.xml.

`, d.Domain)), + }) + } + + if plaintextOK { + out = append(out, reportRemediation{ + Title: "Stop serving autoconfig over HTTP", + Body: template.HTML(` +

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.

`), + }) + } + + if tlsFailures > 0 { + out = append(out, reportRemediation{ + Title: "Fix the TLS certificate on the autoconfig endpoint", + Body: template.HTML(fmt.Sprintf(` +

At least one autoconfig endpoint failed certificate verification +(expired, self-signed, hostname mismatch or unknown CA). Clients will +refuse the document outright.

+

Issue a certificate that covers autoconfig.%[1]s (and +%[1]s if you serve .well-known). Let's Encrypt +works out of the box.

`, 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(` +

The server %s %s:%d is advertised with +socketType=%s. Clients that apply your config will send the +password in clear. Switch to:

+
    +
  • SSL on IMAP 993 / POP3 995 / SMTP submission 465.
  • +
  • STARTTLS on IMAP 143 or SMTP submission 587.
  • +
`, 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(` +

SRV records are a cheap safety net for clients that do not fetch an +autoconfig XML. Advertise IMAPS and submission:

+
_imaps._tcp.%[1]s.        IN SRV 0 1 993 imap.%[1]s.
+_submissions._tcp.%[1]s.  IN SRV 0 1 465 smtp.%[1]s.
+

Use target . to explicitly declare a service as unsupported (e.g. _pop3._tcp).

`, 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) +} diff --git a/checker/report_template.go b/checker/report_template.go new file mode 100644 index 0000000..eb82d07 --- /dev/null +++ b/checker/report_template.go @@ -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(` + + + + +Email Autoconfiguration Report: {{.Domain}} + + + + +
+

Email Autoconfiguration: {{.Domain}}

+ {{.HeadlineBadge}} +

{{.HeadlineText}}

+ {{if .MX}}

MX: {{range $i, $m := .MX}}{{if $i}}, {{end}}{{$m}}{{end}}

{{end}} +
+ +
+

Summary

+
+ {{range .Summary}} +
+ {{chkIcon .Status}}{{.Label}} +
+
{{.Message}}
+ {{end}} +
+
+ +{{if .Remediations}} +
+

Fix this first

+ {{range .Remediations}} +
+
{{.Title}}
+ {{.Body}} +
+ {{end}} +
+{{end}} + +
+

Thunderbird-style probes ({{len .Autoconfig}})

+ {{range .Autoconfig}} + + + {{.Source}} + {{.URL}} + {{.VerdictText}} + +
+
+ {{if .StatusCode}}
HTTP
{{.StatusCode}}
{{end}} + {{if .ContentType}}
Content-Type
{{.ContentType}}
{{end}} + {{if .DurationMs}}
Duration
{{.DurationMs}} ms
{{end}} + {{if .BodyBytes}}
Body size
{{.BodyBytes}} bytes
{{end}} + {{if .Redirected}}
Final URL
{{.FinalURL}}
{{end}} + {{if .TLSSubject}}
TLS subject
{{.TLSSubject}}
{{end}} + {{if .TLSIssuer}}
TLS issuer
{{.TLSIssuer}}
{{end}} + {{if .TLSNotAfter}}
Expires
{{.TLSNotAfter}}
{{end}} + {{if .TLSError}}
TLS error
{{.TLSError}}
{{end}} + {{if .Error}}
Error
{{.Error}}
{{end}} + {{if .ParseError}}
Parse error
{{.ParseError}}
{{end}} +
+
+ + {{end}} +
+ +{{if .ConfigServers.Incoming}} +
+

Servers advertised by clientConfig

+

Incoming

+ + + {{range .ConfigServers.Incoming}} + + + + + + + + {{end}} +
TypeHostnamePortSocketAuth
{{.Type}}{{.Hostname}}{{.Port}}{{if .Encrypted}}{{.SocketType}}{{else}}{{.SocketType}}{{end}}{{if .AuthSafe}}{{.Authentication}}{{else}}{{.Authentication}}{{end}}
+ {{if .ConfigServers.Outgoing}} +

Outgoing

+ + + {{range .ConfigServers.Outgoing}} + + + + + + + + {{end}} +
TypeHostnamePortSocketAuth
{{.Type}}{{.Hostname}}{{.Port}}{{if .Encrypted}}{{.SocketType}}{{else}}{{.SocketType}}{{end}}{{if .AuthSafe}}{{.Authentication}}{{else}}{{.Authentication}}{{end}}
+ {{end}} + {{if or .ConfigServers.CardDAV .ConfigServers.CalDAV}} +

Personal data (xDAV)

+
    + {{range .ConfigServers.CardDAV}}
  • CardDAV: {{.ServerURL}}
  • {{end}} + {{range .ConfigServers.CalDAV}}
  • CalDAV: {{.ServerURL}}
  • {{end}} +
+ {{end}} +
+{{end}} + +
+

RFC 6186 SRV records

+ {{if .SRVRecords}} + + + {{range .SRVRecords}} + + + + + + + + {{end}} +
ServiceTargetPortPrioWeight
{{.Service}}{{if .Skip}}disabled (.){{else}}{{.Target}}{{end}}{{.Port}}{{.Priority}}{{.Weight}}
+ {{else}} +

No SRV records found.

+ {{end}} +
+ +
+

Microsoft Autodiscover ({{len .Autodiscover}})

+ {{if .Autodiscover}} + {{range .Autodiscover}} + + + {{.Source}} + {{.URL}} + {{.VerdictText}} + +
+
+ {{if .StatusCode}}
HTTP
{{.StatusCode}}
{{end}} + {{if .DurationMs}}
Duration
{{.DurationMs}} ms
{{end}} + {{if .TLSSubject}}
TLS subject
{{.TLSSubject}}
{{end}} + {{if .Error}}
Error
{{.Error}}
{{end}} + {{if .ParseError}}
Parse error
{{.ParseError}}
{{end}} +
+
+ + {{end}} + {{else}} +

Autodiscover probes were disabled.

+ {{end}} +
+ +
+

Example config-v1.1.xml

+

Paste-ready starting point. Adjust hostnames and ports before publishing.

+
{{.ExampleXML}}
+
+ + +`)) diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..0a1b102 --- /dev/null +++ b/checker/rule.go @@ -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.) 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./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. 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 +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..407e732 --- /dev/null +++ b/checker/types.go @@ -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 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"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6f8bcf5 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.happydns.org/checker-autoconfig + +go 1.25.0 + +require git.happydns.org/checker-sdk-go v1.3.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fe4952c --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..42419ed --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..943036f --- /dev/null +++ b/plugin/plugin.go @@ -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 +}