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 FROM scratch
COPY --from=builder /checker-autoconfig /checker-autoconfig COPY --from=builder /checker-autoconfig /checker-autoconfig
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
USER 65534:65534
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/checker-autoconfig"] ENTRYPOINT ["/checker-autoconfig"]

View file

@ -17,8 +17,8 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// maxBodyBytes caps the response bodies we download. Autoconfig/autodiscover // Real autoconfig/autodiscover documents are tiny; anything bigger is
// documents are small; anything larger is either misconfigured or hostile. // misconfigured or hostile.
const maxBodyBytes = 256 * 1024 const maxBodyBytes = 256 * 1024
func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { 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 == "" { if domain == "" {
return nil, fmt.Errorf("domain_name is required") return nil, fmt.Errorf("domain_name is required")
} }
domain, err := validateDomain(domain)
if err != nil {
return nil, err
}
localPart, _ := sdk.GetOption[string](opts, "probeEmail") localPart, _ := sdk.GetOption[string](opts, "probeEmail")
if localPart == "" { if localPart == "" {
@ -46,6 +50,13 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption
if !strings.HasSuffix(ispdbURL, "/") { if !strings.HasSuffix(ispdbURL, "/") {
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") userAgent, _ := sdk.GetOption[string](opts, "userAgent")
if userAgent == "" { if userAgent == "" {
userAgent = "happyDomain-autoconfig/1.0 (+https://happydomain.org)" 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) data.Autoconfig = collectAutoconfig(ctx, client, userAgent, domain, email, data.MX, tryISPDB, tryHTTPAutoconfig, ispdbURL)
for _, p := range data.Autoconfig { for _, p := range data.Autoconfig {
if p.Parsed != nil { 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 { func newHTTPClient(timeout time.Duration) *http.Client {
// We keep certificate validation ON and surface the failure as a // Keep cert validation ON; failures are surfaced as soft probe errors
// soft error on the probe, so the rule engine can flag it. // so the rule engine can flag them.
tr := &http.Transport{ tr := &http.Transport{
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 8, MaxIdleConns: 8,
@ -159,7 +170,6 @@ func fetch(ctx context.Context, client *http.Client, userAgent, method, rawURL s
res.DurationMs = time.Since(start).Milliseconds() res.DurationMs = time.Since(start).Milliseconds()
if err != nil { if err != nil {
res.Error = err.Error() res.Error = err.Error()
// Try to distinguish a TLS verification failure.
var tlsErr *tls.CertificateVerificationError var tlsErr *tls.CertificateVerificationError
if errors.As(err, &tlsErr) { if errors.As(err, &tlsErr) {
res.TLSError = err.Error() res.TLSError = err.Error()
@ -175,7 +185,6 @@ func fetch(ctx context.Context, client *http.Client, userAgent, method, rawURL s
if resp.TLS != nil { if resp.TLS != nil {
res.TLSServerName = resp.TLS.ServerName res.TLSServerName = resp.TLS.ServerName
res.TLSValid = true
if len(resp.TLS.PeerCertificates) > 0 { if len(resp.TLS.PeerCertificates) > 0 {
leaf := resp.TLS.PeerCertificates[0] leaf := resp.TLS.PeerCertificates[0]
res.TLSSubject = leaf.Subject.CommonName res.TLSSubject = leaf.Subject.CommonName
@ -216,10 +225,8 @@ func collectAutoconfig(ctx context.Context, client *http.Client, userAgent, doma
if tryISPDB { if tryISPDB {
targets = append(targets, target{"ispdb", ispdbURL + domain}) targets = append(targets, target{"ispdb", ispdbURL + domain})
} }
// MX fallback: repeat with the registrable parent domain of the MX // MX fallback catches gmail/MS365-hosted domains. Bucksch suggests
// host. We pick the lowest-preference MX for simplicity (Bucksch // iterating every MX; the lowest-preference one is enough in practice.
// suggests iterating; one is enough to catch gmail/MS365-hosted
// domains in practice).
if mxParent := pickMXParent(mx); mxParent != "" && mxParent != domain { 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)}) targets = append(targets, target{"mx-autoconfig", fmt.Sprintf("https://autoconfig.%s/mail/config-v1.1.xml?emailaddress=%s", mxParent, encoded)})
if tryISPDB { if tryISPDB {
@ -257,6 +264,38 @@ func runAutoconfigProbe(ctx context.Context, client *http.Client, userAgent, sou
return probe 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 // pickMXParent returns the parent domain of the lowest-preference MX, or
// empty when no suitable MX is present. // empty when no suitable MX is present.
func pickMXParent(mx []MXRecord) string { func pickMXParent(mx []MXRecord) string {
@ -272,12 +311,9 @@ func pickMXParent(mx []MXRecord) string {
return registrableDomain(best.Host) return registrableDomain(best.Host)
} }
// registrableDomain returns a best-effort "effective" domain. We do not // registrableDomain approximates a PSL lookup with last-two-labels (or
// ship a Public Suffix List; stripping one label covers the common case // three when the SLD looks like a ccTLD second level, e.g. co.uk). Good
// (e.g. aspmx.l.google.com → l.google.com, while l.google.com → // enough for the gmail / MS365 MX-fallback case we actually care about.
// 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).
func registrableDomain(host string) string { func registrableDomain(host string) string {
host = strings.TrimSuffix(strings.ToLower(host), ".") host = strings.TrimSuffix(strings.ToLower(host), ".")
parts := strings.Split(host, ".") parts := strings.Split(host, ".")

View file

@ -6,10 +6,9 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" 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" var Version = "built-in"
// Definition returns the CheckerDefinition for the autoconfig checker.
func Definition() *sdk.CheckerDefinition { func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{ return &sdk.CheckerDefinition{
ID: "email-autoconfig", ID: "email-autoconfig",

View file

@ -4,14 +4,14 @@ package checker
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// RenderForm advertises the minimal inputs a human needs to run the // RenderForm describes the standalone /check page inputs.
// checker from its standalone /check page.
func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField { func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField {
return []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 // ParseForm builds CheckerOptions from the submitted form. All probing is
// Collect step expects. Collect itself handles all DNS/HTTP probing, so // deferred to Collect, so we only validate the domain shape here.
// there is nothing to resolve up-front here beyond accepting the domain.
func (p *autoconfigProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { 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)), ".") domain := strings.TrimSuffix(strings.TrimSpace(r.FormValue(sdk.AutoFillDomainName)), ".")
if domain == "" { if domain == "" {
return nil, errors.New("domain name is required") 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 != "" { if v := strings.TrimSpace(r.FormValue("probeEmail")); v != "" {
opts["probeEmail"] = v opts["probeEmail"] = v
} }
opts["tryISPDB"] = r.FormValue("tryISPDB") == "true"
opts["tryHTTPAutoconfig"] = r.FormValue("tryHTTPAutoconfig") == "true" // HTML omits unchecked boxes; treat absence as "use the documented default".
opts["tryAutodiscoverPost"] = r.FormValue("tryAutodiscoverPost") == "true" for _, key := range []string{"tryISPDB", "tryHTTPAutoconfig", "tryAutodiscoverPost"} {
if _, ok := r.Form[key]; !ok {
continue
}
opts[key] = r.FormValue(key) == "true"
}
return opts, nil return opts, nil
} }

View file

@ -62,7 +62,7 @@ func parseClientConfig(body []byte) (*ClientConfig, error) {
if len(body) == 0 { if len(body) == 0 {
return nil, fmt.Errorf("empty body") 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")) { if !bytes.Contains(body, []byte("<clientConfig")) {
return nil, fmt.Errorf("not a clientConfig document") return nil, fmt.Errorf("not a clientConfig document")
} }

View file

@ -4,7 +4,6 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Provider returns a new autoconfig observation provider.
func Provider() sdk.ObservationProvider { func Provider() sdk.ObservationProvider {
return &autoconfigProvider{} return &autoconfigProvider{}
} }
@ -15,7 +14,6 @@ func (p *autoconfigProvider) Key() sdk.ObservationKey {
return ObservationKeyAutoconfig return ObservationKeyAutoconfig
} }
// Definition implements sdk.CheckerDefinitionProvider.
func (p *autoconfigProvider) Definition() *sdk.CheckerDefinition { func (p *autoconfigProvider) Definition() *sdk.CheckerDefinition {
return Definition() 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)) r.MX = append(r.MX, fmt.Sprintf("%s (pref %d)", m.Host, m.Preference))
} }
// Autoconfig probes.
hasParsed := false hasParsed := false
hasAutoconfigOK := false hasAutoconfigOK := false
hasWellKnownOK := false hasWellKnownOK := false
@ -139,7 +138,6 @@ func buildReport(d *Data) reportData {
r.Autoconfig = append(r.Autoconfig, rp) r.Autoconfig = append(r.Autoconfig, rp)
} }
// Autodiscover probes.
hasAutodiscover := false hasAutodiscover := false
for _, p := range d.Autodiscover { for _, p := range d.Autodiscover {
rp := reportProbe{ProbeResult: p.Result} rp := reportProbe{ProbeResult: p.Result}
@ -165,7 +163,6 @@ func buildReport(d *Data) reportData {
r.Autodiscover = append(r.Autodiscover, rp) r.Autodiscover = append(r.Autodiscover, rp)
} }
// SRV.
hasIncomingSRV, hasSubmissionSRV := false, false hasIncomingSRV, hasSubmissionSRV := false, false
for _, s := range d.SRV { for _, s := range d.SRV {
if !s.Skip { if !s.Skip {
@ -176,7 +173,6 @@ func buildReport(d *Data) reportData {
} }
r.SRVRecords = d.SRV r.SRVRecords = d.SRV
// Servers advertised in clientConfig.
if d.ClientConfig != nil { if d.ClientConfig != nil {
for _, s := range d.ClientConfig.Incoming { for _, s := range d.ClientConfig.Incoming {
r.ConfigServers.Incoming = append(r.ConfigServers.Incoming, toReportServer(s)) r.ConfigServers.Incoming = append(r.ConfigServers.Incoming, toReportServer(s))
@ -188,7 +184,6 @@ func buildReport(d *Data) reportData {
r.ConfigServers.CalDAV = d.ClientConfig.Calendar r.ConfigServers.CalDAV = d.ClientConfig.Calendar
} }
// Headline + summary.
switch { switch {
case hasParsed && hasAutoconfigOK && tlsFailures == 0 && !plaintextOK: case hasParsed && hasAutoconfigOK && tlsFailures == 0 && !plaintextOK:
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "OK", statusOK, "Email autoconfiguration is published and healthy." r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "OK", statusOK, "Email autoconfiguration is published and healthy."

View file

@ -3,12 +3,12 @@ package checker
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"strings" "strings"
sdk "git.happydns.org/checker-sdk-go/checker" 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) { func getData(ctx context.Context, obs sdk.ObservationGetter) (*Data, *sdk.CheckState) {
var d Data var d Data
if err := obs.Get(ctx, ObservationKeyAutoconfig, &d); err != nil { 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) return single(*errState)
} }
// Successful Thunderbird-style parse counts.
for _, p := range d.Autoconfig { for _, p := range d.Autoconfig {
if p.Parsed != nil { if p.Parsed != nil {
return single(sdk.CheckState{ 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. // When nothing works, let the presence rule drive the verdict.
if !anyOK { if !anyOK {
return single(sdk.CheckState{ return single(sdk.CheckState{
Status: sdk.StatusInfo, Status: sdk.StatusUnknown,
Message: "No autoconfig responded; primary endpoint not evaluated.", Message: "No autoconfig responded; primary endpoint not evaluated.",
Code: "autoconfig_preferred_skip", Code: "autoconfig_preferred_skip",
}) })
@ -193,7 +192,7 @@ func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
if len(out) == 0 { if len(out) == 0 {
return single(sdk.CheckState{ return single(sdk.CheckState{
Status: sdk.StatusInfo, Status: sdk.StatusUnknown,
Message: "No autoconfig probe reached an endpoint; TLS not assessed.", Message: "No autoconfig probe reached an endpoint; TLS not assessed.",
Code: "autoconfig_tls_skip", Code: "autoconfig_tls_skip",
}) })
@ -219,7 +218,7 @@ func (r *encryptionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter
} }
if d.ClientConfig == nil { if d.ClientConfig == nil {
return single(sdk.CheckState{ return single(sdk.CheckState{
Status: sdk.StatusInfo, Status: sdk.StatusUnknown,
Message: "No clientConfig parsed; encryption check skipped.", Message: "No clientConfig parsed; encryption check skipped.",
Code: "autoconfig_encryption_skip", Code: "autoconfig_encryption_skip",
}) })
@ -240,7 +239,7 @@ func (r *encryptionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter
Code: "autoconfig_plaintext_server", Code: "autoconfig_plaintext_server",
}) })
case strings.EqualFold(s.Authentication, "password-cleartext"): 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{ out = append(out, sdk.CheckState{
Status: sdk.StatusOK, Status: sdk.StatusOK,
Subject: subject, Subject: subject,
@ -258,7 +257,7 @@ func (r *encryptionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter
} }
if len(out) == 0 { if len(out) == 0 {
return single(sdk.CheckState{ return single(sdk.CheckState{
Status: sdk.StatusInfo, Status: sdk.StatusUnknown,
Message: "clientConfig declares no server to evaluate.", Message: "clientConfig declares no server to evaluate.",
Code: "autoconfig_encryption_skip", Code: "autoconfig_encryption_skip",
}) })
@ -284,7 +283,7 @@ func (r *consistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
} }
if d.ClientConfig == nil { if d.ClientConfig == nil {
return single(sdk.CheckState{ return single(sdk.CheckState{
Status: sdk.StatusInfo, Status: sdk.StatusUnknown,
Message: "No clientConfig to compare.", Message: "No clientConfig to compare.",
Code: "autoconfig_consistency_skip", Code: "autoconfig_consistency_skip",
}) })
@ -468,8 +467,15 @@ func srvVersusServers(srv []SRVRecord, incoming, outgoing []ServerConfig) []srvM
byType[r.Service] = append(byType[r.Service], r) 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 var out []srvMismatch
for svc, recs := range byType { for _, svc := range services {
recs := byType[svc]
var configs []ServerConfig var configs []ServerConfig
switch svc { switch svc {
case "_imaps._tcp", "_imap._tcp": case "_imaps._tcp", "_imap._tcp":
@ -494,7 +500,7 @@ func srvVersusServers(srv []SRVRecord, incoming, outgoing []ServerConfig) []srvM
if !match { if !match {
out = append(out, srvMismatch{ out = append(out, srvMismatch{
service: svc, 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 // Package checker probes a domain for the three competing email
// happyDomain. // autoconfiguration mechanisms (Bucksch autoconfig, Microsoft Autodiscover
// // POX, RFC 6186 SRV) and cross-checks them.
// 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 package checker
import ( import (
@ -22,27 +16,18 @@ type Data struct {
Email string `json:"email"` Email string `json:"email"`
CollectedAt time.Time `json:"collected_at"` CollectedAt time.Time `json:"collected_at"`
// MX records found on the domain.
MX []MXRecord `json:"mx,omitempty"` MX []MXRecord `json:"mx,omitempty"`
MXError string `json:"mx_error,omitempty"` MXError string `json:"mx_error,omitempty"`
// RFC 6186 style SRV records.
SRV []SRVRecord `json:"srv,omitempty"` SRV []SRVRecord `json:"srv,omitempty"`
// Thunderbird autoconfig probes. Autoconfig []AutoconfigProbe `json:"autoconfig,omitempty"`
Autoconfig []AutoconfigProbe `json:"autoconfig,omitempty"`
// Microsoft Autodiscover probes.
Autodiscover []AutodiscoverProbe `json:"autodiscover,omitempty"` Autodiscover []AutodiscoverProbe `json:"autodiscover,omitempty"`
// The "best" (first successful) autoconfig document we received, // First successful autoconfig parse, promoted here for rules to consume.
// fully parsed. Present only when at least one probe succeeded. ClientConfig *ClientConfig `json:"client_config,omitempty"`
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"` AutodiscoverResult *AutodiscoverResponse `json:"autodiscover_result,omitempty"`
} }
@ -54,14 +39,12 @@ type MXRecord struct {
// SRVRecord is a single RFC 6186 SRV record observation. // SRVRecord is a single RFC 6186 SRV record observation.
type SRVRecord struct { type SRVRecord struct {
// Service is the RFC 6186 service tag, e.g. "_imaps._tcp". Service string `json:"service"` // RFC 6186 tag, e.g. "_imaps._tcp"
Service string `json:"service"`
Target string `json:"target"` Target string `json:"target"`
Port uint16 `json:"port"` Port uint16 `json:"port"`
Priority uint16 `json:"priority"` Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"` Weight uint16 `json:"weight"`
// Skip is true when RFC 6186 indicates the service is explicitly // Skip means the service is explicitly disabled (RFC 6186 target ".").
// unsupported (target ".").
Skip bool `json:"skip,omitempty"` Skip bool `json:"skip,omitempty"`
} }
@ -79,7 +62,6 @@ type ProbeResult struct {
TLSIssuer string `json:"tls_issuer,omitempty"` TLSIssuer string `json:"tls_issuer,omitempty"`
TLSSubject string `json:"tls_subject,omitempty"` TLSSubject string `json:"tls_subject,omitempty"`
TLSNotAfter string `json:"tls_not_after,omitempty"` TLSNotAfter string `json:"tls_not_after,omitempty"`
TLSValid bool `json:"tls_valid,omitempty"`
TLSError string `json:"tls_error,omitempty"` TLSError string `json:"tls_error,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
ParseError string `json:"parse_error,omitempty"` ParseError string `json:"parse_error,omitempty"`
@ -145,18 +127,15 @@ type Documentation struct {
// AutodiscoverResponse is the parsed POX response. // AutodiscoverResponse is the parsed POX response.
type AutodiscoverResponse struct { type AutodiscoverResponse struct {
// User.DisplayName
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
// Redirect destinations (when Action=redirectAddr/redirectUrl). // Set when Action is redirectAddr / redirectUrl; mutually exclusive with Protocols.
RedirectAddr string `json:"redirect_addr,omitempty"` RedirectAddr string `json:"redirect_addr,omitempty"`
RedirectURL string `json:"redirect_url,omitempty"` RedirectURL string `json:"redirect_url,omitempty"`
// Protocols configured by the server. Protocols []AutodiscoverProtocol `json:"protocols,omitempty"`
Protocols []AutodiscoverProtocol `json:"protocols,omitempty"`
} }
// AutodiscoverProtocol mirrors the subset of <Protocol> elements relevant // AutodiscoverProtocol covers IMAP/POP/SMTP fields; Exchange-only protocols
// to IMAP/POP/SMTP configuration. Exchange-specific protocols are captured // (EXCH/EXPR/MobileSync) are stored but not analysed.
// but not deeply analysed.
type AutodiscoverProtocol struct { type AutodiscoverProtocol struct {
Type string `json:"type"` // IMAP, POP3, SMTP, EXCH, EXPR, WEB, MobileSync Type string `json:"type"` // IMAP, POP3, SMTP, EXCH, EXPR, WEB, MobileSync
Server string `json:"server,omitempty"` Server string `json:"server,omitempty"`