Compare commits
No commits in common. "52723bb044ae098a2242d5f3fe4a8ceaad94be79" and "ddbcf63aec70b2d37fc981a7b11a2a18a3745cbe" have entirely different histories.
52723bb044
...
ddbcf63aec
9 changed files with 103 additions and 81 deletions
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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, ".")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue