Initial commit
This commit is contained in:
commit
c4bf833274
19 changed files with 2451 additions and 0 deletions
417
checker/report.go
Normal file
417
checker/report.go
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue