417 lines
13 KiB
Go
417 lines
13 KiB
Go
package checker
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"strings"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
type reportStatus string
|
|
|
|
const (
|
|
statusOK reportStatus = "ok"
|
|
statusWarn reportStatus = "warn"
|
|
statusFail reportStatus = "fail"
|
|
statusInfo reportStatus = "info"
|
|
statusSkip reportStatus = "skip"
|
|
)
|
|
|
|
type reportProbe struct {
|
|
ProbeResult
|
|
Source string
|
|
Verdict reportStatus
|
|
VerdictText string
|
|
}
|
|
|
|
type reportServer struct {
|
|
Type string
|
|
Hostname string
|
|
Port int
|
|
SocketType string
|
|
Authentication string
|
|
Encrypted bool
|
|
AuthSafe bool
|
|
}
|
|
|
|
type reportRemediation struct {
|
|
Title string
|
|
Body template.HTML
|
|
}
|
|
|
|
type reportData struct {
|
|
Domain string
|
|
Email string
|
|
HeadlineBadge string
|
|
HeadlineClass reportStatus
|
|
HeadlineText string
|
|
Summary []reportSummaryItem
|
|
|
|
Autoconfig []reportProbe
|
|
Autodiscover []reportProbe
|
|
SRVRecords []SRVRecord
|
|
MX []string
|
|
ConfigServers struct {
|
|
Incoming []reportServer
|
|
Outgoing []reportServer
|
|
CardDAV []DavServer
|
|
CalDAV []DavServer
|
|
}
|
|
|
|
Remediations []reportRemediation
|
|
ExampleXML template.HTML
|
|
}
|
|
|
|
type reportSummaryItem struct {
|
|
Label string
|
|
Status reportStatus
|
|
Message string
|
|
}
|
|
|
|
// GetHTMLReport implements sdk.CheckerHTMLReporter.
|
|
func (p *autoconfigProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
|
var d Data
|
|
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
|
|
return "", fmt.Errorf("decode autoconfig data: %w", err)
|
|
}
|
|
|
|
data := buildReport(&d)
|
|
|
|
var buf strings.Builder
|
|
buf.Grow(32 * 1024)
|
|
if err := autoconfigHTMLTemplate.Execute(&buf, data); err != nil {
|
|
return "", fmt.Errorf("render: %w", err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func buildReport(d *Data) reportData {
|
|
r := reportData{
|
|
Domain: d.Domain,
|
|
Email: d.Email,
|
|
}
|
|
for _, m := range d.MX {
|
|
r.MX = append(r.MX, fmt.Sprintf("%s (pref %d)", m.Host, m.Preference))
|
|
}
|
|
|
|
hasParsed := false
|
|
hasAutoconfigOK := false
|
|
hasWellKnownOK := false
|
|
plaintextOK := false
|
|
tlsFailures := 0
|
|
for _, p := range d.Autoconfig {
|
|
rp := reportProbe{ProbeResult: p.Result}
|
|
switch {
|
|
case p.Parsed != nil:
|
|
rp.Verdict = statusOK
|
|
rp.VerdictText = "Parsed OK"
|
|
hasParsed = true
|
|
if p.Source == "autoconfig" {
|
|
hasAutoconfigOK = true
|
|
}
|
|
if p.Source == "wellknown" {
|
|
hasWellKnownOK = true
|
|
}
|
|
if strings.HasPrefix(p.Result.URL, "http://") {
|
|
plaintextOK = true
|
|
}
|
|
case p.Result.TLSError != "":
|
|
rp.Verdict = statusFail
|
|
rp.VerdictText = "TLS error"
|
|
tlsFailures++
|
|
case p.Result.Error != "":
|
|
rp.Verdict = statusFail
|
|
rp.VerdictText = "Unreachable"
|
|
case p.Result.ParseError != "":
|
|
rp.Verdict = statusFail
|
|
rp.VerdictText = "XML parse error"
|
|
case p.Result.StatusCode >= 400:
|
|
rp.Verdict = statusFail
|
|
rp.VerdictText = fmt.Sprintf("HTTP %d", p.Result.StatusCode)
|
|
default:
|
|
rp.Verdict = statusWarn
|
|
rp.VerdictText = fmt.Sprintf("HTTP %d (no config)", p.Result.StatusCode)
|
|
}
|
|
rp.Source = probeSourceLabel(p.Source)
|
|
r.Autoconfig = append(r.Autoconfig, rp)
|
|
}
|
|
|
|
hasAutodiscover := false
|
|
for _, p := range d.Autodiscover {
|
|
rp := reportProbe{ProbeResult: p.Result}
|
|
switch {
|
|
case p.Parsed != nil:
|
|
rp.Verdict = statusOK
|
|
rp.VerdictText = "Parsed OK"
|
|
hasAutodiscover = true
|
|
case p.Result.Error != "":
|
|
rp.Verdict = statusSkip
|
|
rp.VerdictText = "Unreachable"
|
|
case p.Result.ParseError != "":
|
|
rp.Verdict = statusFail
|
|
rp.VerdictText = "XML parse error"
|
|
case p.Result.StatusCode >= 400:
|
|
rp.Verdict = statusSkip
|
|
rp.VerdictText = fmt.Sprintf("HTTP %d", p.Result.StatusCode)
|
|
default:
|
|
rp.Verdict = statusWarn
|
|
rp.VerdictText = fmt.Sprintf("HTTP %d", p.Result.StatusCode)
|
|
}
|
|
rp.Source = probeSourceLabel(p.Source)
|
|
r.Autodiscover = append(r.Autodiscover, rp)
|
|
}
|
|
|
|
hasIncomingSRV, hasSubmissionSRV := false, false
|
|
for _, s := range d.SRV {
|
|
if !s.Skip {
|
|
inc, sub := classifySRV(s.Service)
|
|
hasIncomingSRV = hasIncomingSRV || inc
|
|
hasSubmissionSRV = hasSubmissionSRV || sub
|
|
}
|
|
}
|
|
r.SRVRecords = d.SRV
|
|
|
|
if d.ClientConfig != nil {
|
|
for _, s := range d.ClientConfig.Incoming {
|
|
r.ConfigServers.Incoming = append(r.ConfigServers.Incoming, toReportServer(s))
|
|
}
|
|
for _, s := range d.ClientConfig.Outgoing {
|
|
r.ConfigServers.Outgoing = append(r.ConfigServers.Outgoing, toReportServer(s))
|
|
}
|
|
r.ConfigServers.CardDAV = d.ClientConfig.AddressBook
|
|
r.ConfigServers.CalDAV = d.ClientConfig.Calendar
|
|
}
|
|
|
|
switch {
|
|
case hasParsed && hasAutoconfigOK && tlsFailures == 0 && !plaintextOK:
|
|
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "OK", statusOK, "Email autoconfiguration is published and healthy."
|
|
case hasParsed && tlsFailures == 0:
|
|
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "PARTIAL", statusWarn, "Autoconfig works but has room for improvement (see remediation)."
|
|
case hasParsed && tlsFailures > 0:
|
|
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "TLS ISSUE", statusFail, "Autoconfig answers but at least one endpoint has a TLS problem."
|
|
case hasAutodiscover:
|
|
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "LEGACY ONLY", statusWarn, "Only Microsoft Autodiscover answers. Thunderbird/K9/Evolution users will see manual setup."
|
|
case hasIncomingSRV && hasSubmissionSRV:
|
|
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "SRV ONLY", statusWarn, "Only RFC 6186 SRV records; most clients still need a clientConfig XML."
|
|
default:
|
|
r.HeadlineBadge, r.HeadlineClass, r.HeadlineText = "NOT FOUND", statusFail, "No email autoconfiguration was discovered for this domain."
|
|
}
|
|
|
|
r.Summary = []reportSummaryItem{
|
|
{"Primary autoconfig (autoconfig." + d.Domain + ")", boolStatus(hasAutoconfigOK), primaryMsg(hasAutoconfigOK)},
|
|
{".well-known/autoconfig", boolStatus(hasWellKnownOK), wellKnownMsg(hasWellKnownOK)},
|
|
{"Microsoft Autodiscover", boolStatus(hasAutodiscover), autodiscoverMsg(hasAutodiscover)},
|
|
{"RFC 6186 SRV records", srvStatus(hasIncomingSRV, hasSubmissionSRV), srvMsg(hasIncomingSRV, hasSubmissionSRV)},
|
|
}
|
|
|
|
r.Remediations = buildRemediations(d, hasParsed, hasAutoconfigOK, hasWellKnownOK, plaintextOK, tlsFailures, hasIncomingSRV, hasSubmissionSRV)
|
|
r.ExampleXML = template.HTML(exampleClientConfig(d.Domain))
|
|
|
|
return r
|
|
}
|
|
|
|
func boolStatus(ok bool) reportStatus {
|
|
if ok {
|
|
return statusOK
|
|
}
|
|
return statusFail
|
|
}
|
|
|
|
func primaryMsg(ok bool) string {
|
|
if ok {
|
|
return "Reachable and serves a valid clientConfig."
|
|
}
|
|
return "Not reachable. This is the first URL Thunderbird tries."
|
|
}
|
|
|
|
func wellKnownMsg(ok bool) string {
|
|
if ok {
|
|
return "Answers on the domain apex."
|
|
}
|
|
return "Not exposed. Optional, but useful when you cannot add a subdomain."
|
|
}
|
|
|
|
func autodiscoverMsg(ok bool) string {
|
|
if ok {
|
|
return "Responds. Microsoft Outlook / mobile clients will find your server."
|
|
}
|
|
return "Silent. Outlook-for-Windows and iOS Mail rely on this."
|
|
}
|
|
|
|
func srvStatus(inc, sub bool) reportStatus {
|
|
switch {
|
|
case inc && sub:
|
|
return statusOK
|
|
case inc || sub:
|
|
return statusWarn
|
|
}
|
|
return statusInfo
|
|
}
|
|
|
|
func srvMsg(inc, sub bool) string {
|
|
switch {
|
|
case inc && sub:
|
|
return "Incoming and submission SRV records published."
|
|
case inc:
|
|
return "Submission SRV missing (_submissions._tcp)."
|
|
case sub:
|
|
return "Incoming SRV missing (_imaps._tcp / _pop3s._tcp)."
|
|
}
|
|
return "No RFC 6186 SRV records. Not mandatory, but a cheap safety net."
|
|
}
|
|
|
|
var probeLabelMap = map[string]string{
|
|
"autoconfig": "autoconfig.<domain>",
|
|
"wellknown": ".well-known/autoconfig",
|
|
"http-autoconfig": "http://autoconfig.<domain>",
|
|
"ispdb": "Mozilla ISPDB",
|
|
"mx-autoconfig": "MX-parent autoconfig",
|
|
"mx-ispdb": "MX-parent ISPDB",
|
|
"subdomain": "autodiscover.<domain>",
|
|
"root": "apex /autodiscover",
|
|
}
|
|
|
|
func probeSourceLabel(src string) string {
|
|
if label, ok := probeLabelMap[src]; ok {
|
|
return label
|
|
}
|
|
return src
|
|
}
|
|
|
|
func toReportServer(s ServerConfig) reportServer {
|
|
return reportServer{
|
|
Type: s.Type,
|
|
Hostname: s.Hostname,
|
|
Port: s.Port,
|
|
SocketType: s.SocketType,
|
|
Authentication: s.Authentication,
|
|
Encrypted: isEncryptedSocket(s.SocketType),
|
|
AuthSafe: !strings.EqualFold(s.Authentication, "password-cleartext") || isEncryptedSocket(s.SocketType),
|
|
}
|
|
}
|
|
|
|
// ── Remediation snippets ────────────────────────────────────────────────────
|
|
|
|
func buildRemediations(d *Data, hasParsed, hasAutoconfig, hasWellKnown, plaintextOK bool, tlsFailures int, hasIncomingSRV, hasSubmissionSRV bool) []reportRemediation {
|
|
var out []reportRemediation
|
|
|
|
if !hasParsed {
|
|
out = append(out, reportRemediation{
|
|
Title: "Publish an autoconfig XML file",
|
|
Body: template.HTML(fmt.Sprintf(`
|
|
<p>Clients such as Thunderbird, K-9 Mail, Evolution and KMail will query
|
|
<code>https://autoconfig.%[1]s/mail/config-v1.1.xml</code> when the user
|
|
types <code>user@%[1]s</code>. Publishing the file removes the need for
|
|
manual IMAP/SMTP setup.</p>
|
|
<ol>
|
|
<li>Create a subdomain <code>autoconfig.%[1]s</code> pointing to a TLS-enabled web server (a 200-byte static file is enough).</li>
|
|
<li>Drop the XML below at <code>/mail/config-v1.1.xml</code> and make sure it is served with <code>Content-Type: text/xml</code>.</li>
|
|
<li>Do the same at <code>https://%[1]s/.well-known/autoconfig/mail/config-v1.1.xml</code> so users who cannot add a subdomain still get configured.</li>
|
|
</ol>`, d.Domain)),
|
|
})
|
|
}
|
|
|
|
if hasParsed && !hasAutoconfig && hasWellKnown {
|
|
out = append(out, reportRemediation{
|
|
Title: "Add the autoconfig.<domain> subdomain",
|
|
Body: template.HTML(fmt.Sprintf(`
|
|
<p>Only the <code>.well-known</code> fallback responded. Thunderbird tries
|
|
<code>autoconfig.%[1]s</code> <em>first</em>, so adding the subdomain is a
|
|
cheap win. Copy the XML from your apex, expose it on
|
|
<code>https://autoconfig.%[1]s/mail/config-v1.1.xml</code>.</p>`, d.Domain)),
|
|
})
|
|
}
|
|
|
|
if plaintextOK {
|
|
out = append(out, reportRemediation{
|
|
Title: "Stop serving autoconfig over HTTP",
|
|
Body: template.HTML(`
|
|
<p>The draft requires clients to ignore plaintext responses unless HTTPS
|
|
fails. Clients will still warn the user. Redirect HTTP to HTTPS and drop
|
|
the plaintext virtualhost entirely.</p>`),
|
|
})
|
|
}
|
|
|
|
if tlsFailures > 0 {
|
|
out = append(out, reportRemediation{
|
|
Title: "Fix the TLS certificate on the autoconfig endpoint",
|
|
Body: template.HTML(fmt.Sprintf(`
|
|
<p>At least one autoconfig endpoint failed certificate verification
|
|
(expired, self-signed, hostname mismatch or unknown CA). Clients will
|
|
refuse the document outright.</p>
|
|
<p>Issue a certificate that covers <code>autoconfig.%[1]s</code> (and
|
|
<code>%[1]s</code> if you serve <code>.well-known</code>). Let's Encrypt
|
|
works out of the box.</p>`, d.Domain)),
|
|
})
|
|
}
|
|
|
|
if d.ClientConfig != nil {
|
|
servers := make([]ServerConfig, 0, len(d.ClientConfig.Incoming)+len(d.ClientConfig.Outgoing))
|
|
servers = append(servers, d.ClientConfig.Incoming...)
|
|
servers = append(servers, d.ClientConfig.Outgoing...)
|
|
for _, s := range servers {
|
|
if !isEncryptedSocket(s.SocketType) {
|
|
out = append(out, reportRemediation{
|
|
Title: "Remove plaintext server definitions",
|
|
Body: template.HTML(fmt.Sprintf(`
|
|
<p>The server <code>%s %s:%d</code> is advertised with
|
|
<code>socketType=%s</code>. Clients that apply your config will send the
|
|
password in clear. Switch to:</p>
|
|
<ul>
|
|
<li><code>SSL</code> on IMAP 993 / POP3 995 / SMTP submission 465.</li>
|
|
<li><code>STARTTLS</code> on IMAP 143 or SMTP submission 587.</li>
|
|
</ul>`, s.Type, s.Hostname, s.Port, s.SocketType)),
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !hasIncomingSRV || !hasSubmissionSRV {
|
|
out = append(out, reportRemediation{
|
|
Title: "Publish RFC 6186 SRV records",
|
|
Body: template.HTML(fmt.Sprintf(`
|
|
<p>SRV records are a cheap safety net for clients that do not fetch an
|
|
autoconfig XML. Advertise IMAPS and submission:</p>
|
|
<pre>_imaps._tcp.%[1]s. IN SRV 0 1 993 imap.%[1]s.
|
|
_submissions._tcp.%[1]s. IN SRV 0 1 465 smtp.%[1]s.</pre>
|
|
<p>Use target <code>.</code> to explicitly declare a service as unsupported (e.g. <code>_pop3._tcp</code>).</p>`, d.Domain)),
|
|
})
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// exampleClientConfig returns a paste-ready XML snippet for the domain.
|
|
func exampleClientConfig(domain string) string {
|
|
if domain == "" {
|
|
domain = "example.com"
|
|
}
|
|
tpl := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<clientConfig version="1.1">
|
|
<emailProvider id="%[1]s">
|
|
<domain>%[1]s</domain>
|
|
<displayName>%[1]s Mail</displayName>
|
|
<displayShortName>%[1]s</displayShortName>
|
|
|
|
<incomingServer type="imap">
|
|
<hostname>imap.%[1]s</hostname>
|
|
<port>993</port>
|
|
<socketType>SSL</socketType>
|
|
<username>%%EMAILADDRESS%%</username>
|
|
<authentication>password-cleartext</authentication>
|
|
</incomingServer>
|
|
|
|
<outgoingServer type="smtp">
|
|
<hostname>smtp.%[1]s</hostname>
|
|
<port>465</port>
|
|
<socketType>SSL</socketType>
|
|
<username>%%EMAILADDRESS%%</username>
|
|
<authentication>password-cleartext</authentication>
|
|
</outgoingServer>
|
|
</emailProvider>
|
|
</clientConfig>`
|
|
return fmt.Sprintf(tpl, domain)
|
|
}
|