Compare commits

...

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

9 changed files with 81 additions and 103 deletions

View file

@ -11,6 +11,5 @@ 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"
) )
// Real autoconfig/autodiscover documents are tiny; anything bigger is // maxBodyBytes caps the response bodies we download. Autoconfig/autodiscover
// misconfigured or hostile. // documents are small; anything larger is either 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,10 +27,6 @@ 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 == "" {
@ -50,13 +46,6 @@ 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)"
@ -110,7 +99,7 @@ func (p *autoconfigProvider) Collect(ctx context.Context, opts sdk.CheckerOption
}) })
} }
// Runs synchronously: needs MX, but overlaps the SRV/Autodiscover goroutines. // Autoconfig probes run after MX (MX parent needed), overlapping with 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 {
@ -125,8 +114,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 {
// Keep cert validation ON; failures are surfaced as soft probe errors // We keep certificate validation ON and surface the failure as a
// so the rule engine can flag them. // soft error on the probe, so the rule engine can flag it.
tr := &http.Transport{ tr := &http.Transport{
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 8, MaxIdleConns: 8,
@ -170,6 +159,7 @@ 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()
@ -185,6 +175,7 @@ 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
@ -225,8 +216,10 @@ 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 catches gmail/MS365-hosted domains. Bucksch suggests // MX fallback: repeat with the registrable parent domain of the MX
// iterating every MX; the lowest-preference one is enough in practice. // host. We pick the lowest-preference MX for simplicity (Bucksch
// 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 {
@ -264,38 +257,6 @@ 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 {
@ -311,9 +272,12 @@ func pickMXParent(mx []MXRecord) string {
return registrableDomain(best.Host) return registrableDomain(best.Host)
} }
// registrableDomain approximates a PSL lookup with last-two-labels (or // registrableDomain returns a best-effort "effective" domain. We do not
// three when the SLD looks like a ccTLD second level, e.g. co.uk). Good // ship a Public Suffix List; stripping one label covers the common case
// enough for the gmail / MS365 MX-fallback case we actually care about. // (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).
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,9 +6,10 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Version is overridden at link time by the build, or by the plugin loader. // Version is the checker version reported in CheckerDefinition.Version.
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 describes the standalone /check page inputs. // RenderForm advertises the minimal inputs a human needs to run the
// 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,13 +54,10 @@ func (p *autoconfigProvider) RenderForm() []sdk.CheckerOptionField {
} }
} }
// ParseForm builds CheckerOptions from the submitted form. All probing is // ParseForm turns a submitted /check form into the CheckerOptions the
// deferred to Collect, so we only validate the domain shape here. // Collect step expects. Collect itself handles all DNS/HTTP probing, so
// 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")
@ -72,14 +69,9 @@ 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"
// HTML omits unchecked boxes; treat absence as "use the documented default". opts["tryHTTPAutoconfig"] = r.FormValue("tryHTTPAutoconfig") == "true"
for _, key := range []string{"tryISPDB", "tryHTTPAutoconfig", "tryAutodiscoverPost"} { opts["tryAutodiscoverPost"] = r.FormValue("tryAutodiscoverPost") == "true"
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")
} }
// Cheap reject before invoking the XML decoder. // Fast sanity check: must contain clientConfig.
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,6 +4,7 @@ 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{}
} }
@ -14,6 +15,7 @@ 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,6 +96,7 @@ 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
@ -138,6 +139,7 @@ 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}
@ -163,6 +165,7 @@ 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 {
@ -173,6 +176,7 @@ 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))
@ -184,6 +188,7 @@ 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,6 +40,7 @@ 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{
@ -108,7 +109,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.StatusUnknown, Status: sdk.StatusInfo,
Message: "No autoconfig responded; primary endpoint not evaluated.", Message: "No autoconfig responded; primary endpoint not evaluated.",
Code: "autoconfig_preferred_skip", Code: "autoconfig_preferred_skip",
}) })
@ -192,7 +193,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.StatusUnknown, Status: sdk.StatusInfo,
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",
}) })
@ -218,7 +219,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.StatusUnknown, Status: sdk.StatusInfo,
Message: "No clientConfig parsed; encryption check skipped.", Message: "No clientConfig parsed; encryption check skipped.",
Code: "autoconfig_encryption_skip", Code: "autoconfig_encryption_skip",
}) })
@ -239,7 +240,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"):
// Cleartext password is fine here because the transport is encrypted. // isEncryptedSocket is true here, so the cleartext password is tunneled (informational).
out = append(out, sdk.CheckState{ out = append(out, sdk.CheckState{
Status: sdk.StatusOK, Status: sdk.StatusOK,
Subject: subject, Subject: subject,
@ -257,7 +258,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.StatusUnknown, Status: sdk.StatusInfo,
Message: "clientConfig declares no server to evaluate.", Message: "clientConfig declares no server to evaluate.",
Code: "autoconfig_encryption_skip", Code: "autoconfig_encryption_skip",
}) })
@ -283,7 +284,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.StatusUnknown, Status: sdk.StatusInfo,
Message: "No clientConfig to compare.", Message: "No clientConfig to compare.",
Code: "autoconfig_consistency_skip", Code: "autoconfig_consistency_skip",
}) })
@ -467,15 +468,8 @@ 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 := range services { for svc, recs := range byType {
recs := byType[svc]
var configs []ServerConfig var configs []ServerConfig
switch svc { switch svc {
case "_imaps._tcp", "_imap._tcp": case "_imaps._tcp", "_imap._tcp":
@ -500,7 +494,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("No SRV %s record matches any clientConfig %s server (host/port)", svc, configs[0].Type), message: fmt.Sprintf("SRV %s points outside clientConfig's %s servers", svc, configs[0].Type),
}) })
} }
} }

View file

@ -1,6 +1,12 @@
// Package checker probes a domain for the three competing email // Package checker implements the email autoconfiguration checker for
// autoconfiguration mechanisms (Bucksch autoconfig, Microsoft Autodiscover // happyDomain.
// 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 (
@ -16,18 +22,27 @@ 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"`
Autoconfig []AutoconfigProbe `json:"autoconfig,omitempty"` // Thunderbird autoconfig probes.
Autoconfig []AutoconfigProbe `json:"autoconfig,omitempty"`
// Microsoft Autodiscover probes.
Autodiscover []AutodiscoverProbe `json:"autodiscover,omitempty"` Autodiscover []AutodiscoverProbe `json:"autodiscover,omitempty"`
// First successful autoconfig parse, promoted here for rules to consume. // The "best" (first successful) autoconfig document we received,
ClientConfig *ClientConfig `json:"client_config,omitempty"` // fully parsed. Present only when at least one probe succeeded.
ClientConfigSource string `json:"client_config_source,omitempty"` ClientConfig *ClientConfig `json:"client_config,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"`
} }
@ -39,12 +54,14 @@ 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 string `json:"service"` // RFC 6186 tag, e.g. "_imaps._tcp" // Service is the RFC 6186 service 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 means the service is explicitly disabled (RFC 6186 target "."). // Skip is true when RFC 6186 indicates the service is explicitly
// unsupported (target ".").
Skip bool `json:"skip,omitempty"` Skip bool `json:"skip,omitempty"`
} }
@ -62,6 +79,7 @@ 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"`
@ -127,15 +145,18 @@ 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"`
// Set when Action is redirectAddr / redirectUrl; mutually exclusive with Protocols. // Redirect destinations (when Action=redirectAddr/redirectUrl).
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 []AutodiscoverProtocol `json:"protocols,omitempty"` // Protocols configured by the server.
Protocols []AutodiscoverProtocol `json:"protocols,omitempty"`
} }
// AutodiscoverProtocol covers IMAP/POP/SMTP fields; Exchange-only protocols // AutodiscoverProtocol mirrors the subset of <Protocol> elements relevant
// (EXCH/EXPR/MobileSync) are stored but not analysed. // to IMAP/POP/SMTP configuration. Exchange-specific protocols are captured
// 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"`