Compare commits

...

No commits in common. "52723bb044ae098a2242d5f3fe4a8ceaad94be79" and "ddbcf63aec70b2d37fc981a7b11a2a18a3745cbe" have entirely different histories.

9 changed files with 103 additions and 81 deletions

View file

@ -11,5 +11,6 @@ RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_
FROM scratch
COPY --from=builder /checker-autoconfig /checker-autoconfig
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/checker-autoconfig"]

View file

@ -17,8 +17,8 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// maxBodyBytes caps the response bodies we download. Autoconfig/autodiscover
// documents are small; anything larger is either misconfigured or hostile.
// 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) {
@ -27,6 +27,10 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption
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 == "" {
@ -46,6 +50,13 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption
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)"
@ -99,7 +110,7 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption
})
}
// Autoconfig probes run after MX (MX parent needed), overlapping with the SRV/Autodiscover goroutines.
// 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 {
@ -114,8 +125,8 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption
}
func newHTTPClient(timeout time.Duration) *http.Client {
// We keep certificate validation ON and surface the failure as a
// soft error on the probe, so the rule engine can flag it.
// 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,
@ -159,7 +170,6 @@ func fetch(ctx context.Context, client *http.Client, userAgent, method, rawURL s
res.DurationMs = time.Since(start).Milliseconds()
if err != nil {
res.Error = err.Error()
// Try to distinguish a TLS verification failure.
var tlsErr *tls.CertificateVerificationError
if errors.As(err, &tlsErr) {
res.TLSError = err.Error()
@ -175,7 +185,6 @@ func fetch(ctx context.Context, client *http.Client, userAgent, method, rawURL s
if resp.TLS != nil {
res.TLSServerName = resp.TLS.ServerName
res.TLSValid = true
if len(resp.TLS.PeerCertificates) > 0 {
leaf := resp.TLS.PeerCertificates[0]
res.TLSSubject = leaf.Subject.CommonName
@ -216,10 +225,8 @@ func collectAutoconfig(ctx context.Context, client *http.Client, userAgent, doma
if tryISPDB {
targets = append(targets, target{"ispdb", ispdbURL + domain})
}
// MX fallback: repeat with the registrable parent domain of the MX
// host. We pick the lowest-preference MX for simplicity (Bucksch
// suggests iterating; one is enough to catch gmail/MS365-hosted
// domains in practice).
// 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 {
@ -257,6 +264,38 @@ func runAutoconfigProbe(ctx context.Context, client *http.Client, userAgent, sou
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 {
@ -272,12 +311,9 @@ func pickMXParent(mx []MXRecord) string {
return registrableDomain(best.Host)
}
// registrableDomain returns a best-effort "effective" domain. We do not
// ship a Public Suffix List; stripping one label covers the common case
// (e.g. aspmx.l.google.com → l.google.com, while l.google.com →
// google.com after a second call; the caller only needs one level).
// For the MX case we approximate with the last two labels, falling back
// to three when the second-to-last label is short (e.g. co.uk).
// 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, ".")

View file

@ -6,10 +6,9 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
// Version is overridden at link time by the build, or by the plugin loader.
var Version = "built-in"
// Definition returns the CheckerDefinition for the autoconfig checker.
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "email-autoconfig",

View file

@ -4,14 +4,14 @@ package checker
import (
"errors"
"fmt"
"net/http"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm advertises the minimal inputs a human needs to run the
// checker from its standalone /check page.
// RenderForm describes the standalone /check page inputs.
func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
@ -54,10 +54,13 @@ func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField {
}
}
// ParseForm turns a submitted /check form into the CheckerOptions the
// Collect step expects. Collect itself handles all DNS/HTTP probing, so
// there is nothing to resolve up-front here beyond accepting the domain.
// 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")
@ -69,9 +72,14 @@ func (p *autoconfigProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, err
if v := strings.TrimSpace(r.FormValue("probeEmail")); v != "" {
opts["probeEmail"] = v
}
opts["tryISPDB"] = r.FormValue("tryISPDB") == "true"
opts["tryHTTPAutoconfig"] = r.FormValue("tryHTTPAutoconfig") == "true"
opts["tryAutodiscoverPost"] = r.FormValue("tryAutodiscoverPost") == "true"
// 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
}

View file

@ -62,7 +62,7 @@ func parseClientConfig(body []byte) (*ClientConfig, error) {
if len(body) == 0 {
return nil, fmt.Errorf("empty body")
}
// Fast sanity check: must contain clientConfig.
// Cheap reject before invoking the XML decoder.
if !bytes.Contains(body, []byte("<clientConfig")) {
return nil, fmt.Errorf("not a clientConfig document")
}

View file

@ -4,7 +4,6 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new autoconfig observation provider.
func Provider() sdk.ObservationProvider {
return &autoconfigProvider{}
}
@ -15,7 +14,6 @@ func (p *autoconfigProvider) Key() sdk.ObservationKey {
return ObservationKeyAutoconfig
}
// Definition implements sdk.CheckerDefinitionProvider.
func (p *autoconfigProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

View file

@ -96,7 +96,6 @@ func buildReport(d *Data) reportData {
r.MX = append(r.MX, fmt.Sprintf("%s (pref %d)", m.Host, m.Preference))
}
// Autoconfig probes.
hasParsed := false
hasAutoconfigOK := false
hasWellKnownOK := false
@ -139,7 +138,6 @@ func buildReport(d *Data) reportData {
r.Autoconfig = append(r.Autoconfig, rp)
}
// Autodiscover probes.
hasAutodiscover := false
for _, p := range d.Autodiscover {
rp := reportProbe{ProbeResult: p.Result}
@ -165,7 +163,6 @@ func buildReport(d *Data) reportData {
r.Autodiscover = append(r.Autodiscover, rp)
}
// SRV.
hasIncomingSRV, hasSubmissionSRV := false, false
for _, s := range d.SRV {
if !s.Skip {
@ -176,7 +173,6 @@ func buildReport(d *Data) reportData {
}
r.SRVRecords = d.SRV
// Servers advertised in clientConfig.
if d.ClientConfig != nil {
for _, s := range d.ClientConfig.Incoming {
r.ConfigServers.Incoming = append(r.ConfigServers.Incoming, toReportServer(s))
@ -188,7 +184,6 @@ func buildReport(d *Data) reportData {
r.ConfigServers.CalDAV = d.ClientConfig.Calendar
}
// Headline + summary.
switch {
case hasParsed && hasAutoconfigOK && tlsFailures == 0 && !plaintextOK:
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "OK", statusOK, "Email autoconfiguration is published and healthy."

View file

@ -3,12 +3,12 @@ package checker
import (
"context"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// getData is the shared helper every rule starts with.
func getData(ctx context.Context, obs sdk.ObservationGetter) (*Data, *sdk.CheckState) {
var d Data
if err := obs.Get(ctx, ObservationKeyAutoconfig, &d); err != nil {
@ -40,7 +40,6 @@ func (r *presenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
return single(*errState)
}
// Successful Thunderbird-style parse counts.
for _, p := range d.Autoconfig {
if p.Parsed != nil {
return single(sdk.CheckState{
@ -109,7 +108,7 @@ func (r *preferredEndpointRule) Evaluate(ctx context.Context, obs sdk.Observatio
// When nothing works, let the presence rule drive the verdict.
if !anyOK {
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Status: sdk.StatusUnknown,
Message: "No autoconfig responded; primary endpoint not evaluated.",
Code: "autoconfig_preferred_skip",
})
@ -193,7 +192,7 @@ func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
if len(out) == 0 {
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Status: sdk.StatusUnknown,
Message: "No autoconfig probe reached an endpoint; TLS not assessed.",
Code: "autoconfig_tls_skip",
})
@ -219,7 +218,7 @@ func (r *encryptionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter
}
if d.ClientConfig == nil {
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Status: sdk.StatusUnknown,
Message: "No clientConfig parsed; encryption check skipped.",
Code: "autoconfig_encryption_skip",
})
@ -240,7 +239,7 @@ func (r *encryptionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter
Code: "autoconfig_plaintext_server",
})
case strings.EqualFold(s.Authentication, "password-cleartext"):
// isEncryptedSocket is true here, so the cleartext password is tunneled (informational).
// Cleartext password is fine here because the transport is encrypted.
out = append(out, sdk.CheckState{
Status: sdk.StatusOK,
Subject: subject,
@ -258,7 +257,7 @@ func (r *encryptionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter
}
if len(out) == 0 {
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Status: sdk.StatusUnknown,
Message: "clientConfig declares no server to evaluate.",
Code: "autoconfig_encryption_skip",
})
@ -284,7 +283,7 @@ func (r *consistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
}
if d.ClientConfig == nil {
return single(sdk.CheckState{
Status: sdk.StatusInfo,
Status: sdk.StatusUnknown,
Message: "No clientConfig to compare.",
Code: "autoconfig_consistency_skip",
})
@ -468,8 +467,15 @@ func srvVersusServers(srv []SRVRecord, incoming, outgoing []ServerConfig) []srvM
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, recs := range byType {
for _, svc := range services {
recs := byType[svc]
var configs []ServerConfig
switch svc {
case "_imaps._tcp", "_imap._tcp":
@ -494,7 +500,7 @@ func srvVersusServers(srv []SRVRecord, incoming, outgoing []ServerConfig) []srvM
if !match {
out = append(out, srvMismatch{
service: svc,
message: fmt.Sprintf("SRV %s points outside clientConfig's %s servers", svc, configs[0].Type),
message: fmt.Sprintf("No SRV %s record matches any clientConfig %s server (host/port)", svc, configs[0].Type),
})
}
}

View file

@ -1,12 +1,6 @@
// Package checker implements the email autoconfiguration checker for
// happyDomain.
//
// It verifies that a domain publishes discoverable email client
// configuration, as described in Bucksch' autoconfig draft
// (draft-bucksch-autoconfig-00), Microsoft's Autodiscover (POX) and RFC
// 6186 SRV records. The checker probes every standard endpoint, parses
// the returned documents, cross-checks the servers they advertise and
// rates the result with user-actionable hints.
// 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 (
@ -22,27 +16,18 @@ type Data struct {
Email string `json:"email"`
CollectedAt time.Time `json:"collected_at"`
// MX records found on the domain.
MX []MXRecord `json:"mx,omitempty"`
MXError string `json:"mx_error,omitempty"`
// RFC 6186 style SRV records.
SRV []SRVRecord `json:"srv,omitempty"`
// Thunderbird autoconfig probes.
Autoconfig []AutoconfigProbe `json:"autoconfig,omitempty"`
// Microsoft Autodiscover probes.
Autoconfig []AutoconfigProbe `json:"autoconfig,omitempty"`
Autodiscover []AutodiscoverProbe `json:"autodiscover,omitempty"`
// The "best" (first successful) autoconfig document we received,
// fully parsed. Present only when at least one probe succeeded.
ClientConfig *ClientConfig `json:"client_config,omitempty"`
// First successful autoconfig parse, promoted here for rules to consume.
ClientConfig *ClientConfig `json:"client_config,omitempty"`
ClientConfigSource string `json:"client_config_source,omitempty"`
// Source of the ClientConfig above ("autoconfig", "ispdb", "mx").
ClientConfigSource string `json:"client_config_source,omitempty"`
// The Autodiscover response we parsed, if any.
AutodiscoverResult *AutodiscoverResponse `json:"autodiscover_result,omitempty"`
}
@ -54,14 +39,12 @@ type MXRecord struct {
// SRVRecord is a single RFC 6186 SRV record observation.
type SRVRecord struct {
// Service is the RFC 6186 service tag, e.g. "_imaps._tcp".
Service string `json:"service"`
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 is true when RFC 6186 indicates the service is explicitly
// unsupported (target ".").
// Skip means the service is explicitly disabled (RFC 6186 target ".").
Skip bool `json:"skip,omitempty"`
}
@ -79,7 +62,6 @@ type ProbeResult struct {
TLSIssuer string `json:"tls_issuer,omitempty"`
TLSSubject string `json:"tls_subject,omitempty"`
TLSNotAfter string `json:"tls_not_after,omitempty"`
TLSValid bool `json:"tls_valid,omitempty"`
TLSError string `json:"tls_error,omitempty"`
Error string `json:"error,omitempty"`
ParseError string `json:"parse_error,omitempty"`
@ -145,18 +127,15 @@ type Documentation struct {
// AutodiscoverResponse is the parsed POX response.
type AutodiscoverResponse struct {
// User.DisplayName
DisplayName string `json:"display_name,omitempty"`
// Redirect destinations (when Action=redirectAddr/redirectUrl).
RedirectAddr string `json:"redirect_addr,omitempty"`
RedirectURL string `json:"redirect_url,omitempty"`
// Protocols configured by the server.
Protocols []AutodiscoverProtocol `json:"protocols,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 mirrors the subset of <Protocol> elements relevant
// to IMAP/POP/SMTP configuration. Exchange-specific protocols are captured
// but not deeply analysed.
// 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"`