Initial commit
This commit is contained in:
commit
485c5a4a1d
33 changed files with 5407 additions and 0 deletions
663
checker/report.go
Normal file
663
checker/report.go
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type reportFix struct {
|
||||
Severity string
|
||||
Code string
|
||||
Message string
|
||||
Fix string
|
||||
Endpoint string
|
||||
Target string
|
||||
}
|
||||
|
||||
type reportMX struct {
|
||||
Preference uint16
|
||||
Target string
|
||||
IPv4 []string
|
||||
IPv6 []string
|
||||
IsCNAME bool
|
||||
CNAMEChain []string
|
||||
IsIPLiteral bool
|
||||
ResolveErr string
|
||||
}
|
||||
|
||||
type reportEndpoint struct {
|
||||
Target string
|
||||
Address string
|
||||
IP string
|
||||
IsIPv6 bool
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
AnyFail bool
|
||||
TCPConnected bool
|
||||
BannerLine string
|
||||
BannerHostname string
|
||||
BannerCode int
|
||||
EHLOReceived bool
|
||||
EHLOFallbackHELO bool
|
||||
EHLOHostname string
|
||||
STARTTLSOffered bool
|
||||
STARTTLSUpgraded bool
|
||||
TLSVersion string
|
||||
TLSCipher string
|
||||
SizeLimit uint64
|
||||
HasPipelining bool
|
||||
Has8BITMIME bool
|
||||
HasSMTPUTF8 bool
|
||||
HasCHUNKING bool
|
||||
HasDSN bool
|
||||
HasENHANCEDCODE bool
|
||||
AUTHPreTLS []string
|
||||
AUTHPostTLS []string
|
||||
PTR string
|
||||
PTRError string
|
||||
FCrDNSPass bool
|
||||
NullSenderState string
|
||||
NullSenderClass string
|
||||
NullSenderResponse string
|
||||
PostmasterState string
|
||||
PostmasterClass string
|
||||
PostmasterResponse string
|
||||
OpenRelayState string
|
||||
OpenRelayClass string
|
||||
OpenRelayResponse string
|
||||
OpenRelayRecipient string
|
||||
ElapsedMS int64
|
||||
Error string
|
||||
|
||||
// TLS posture (from a related tls_probes observation, when available).
|
||||
TLSPosture *reportTLSPosture
|
||||
}
|
||||
|
||||
type reportTLSPosture struct {
|
||||
CheckedAt time.Time
|
||||
ChainValid *bool
|
||||
HostnameMatch *bool
|
||||
NotAfter time.Time
|
||||
Issues []reportFix
|
||||
}
|
||||
|
||||
type reportData struct {
|
||||
Domain string
|
||||
RunAt string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
HasIssues bool
|
||||
Fixes []reportFix
|
||||
MX []reportMX
|
||||
NullMX bool
|
||||
ImplicitMX bool
|
||||
MXError string
|
||||
Endpoints []reportEndpoint
|
||||
HasIPv4 bool
|
||||
HasIPv6 bool
|
||||
AnySTARTTLS bool
|
||||
AllSTARTTLS bool
|
||||
HasTLSPosture bool
|
||||
}
|
||||
|
||||
var reportTpl = template.Must(template.New("smtp").Funcs(template.FuncMap{
|
||||
"deref": func(b *bool) bool { return b != nil && *b },
|
||||
"humanBytes": func(n uint64) string {
|
||||
if n == 0 {
|
||||
return "no limit"
|
||||
}
|
||||
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
|
||||
f := float64(n)
|
||||
u := 0
|
||||
for f >= 1024 && u < len(units)-1 {
|
||||
f /= 1024
|
||||
u++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", f, units[u])
|
||||
},
|
||||
}).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SMTP Report: {{.Domain}}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
body { margin: 0; padding: 1rem; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
|
||||
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
|
||||
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
|
||||
.hd, .section, details {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
|
||||
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
|
||||
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: .2em .65em; border-radius: 9999px;
|
||||
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
|
||||
}
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.fail { background: #fee2e2; color: #991b1b; }
|
||||
.muted { background: #e5e7eb; color: #374151; }
|
||||
.info { background: #dbeafe; color: #1e3a8a; }
|
||||
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||
summary {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
|
||||
details[open] > summary::before { transform: rotate(90deg); }
|
||||
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
|
||||
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
||||
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
.fix {
|
||||
border-left: 3px solid #dc2626;
|
||||
padding: .5rem .75rem; margin-bottom: .5rem;
|
||||
background: #fef2f2; border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
|
||||
.fix.info { border-color: #3b82f6; background: #eff6ff; }
|
||||
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
|
||||
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
|
||||
.fix .how { font-size: .88rem; }
|
||||
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
|
||||
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
|
||||
.chip {
|
||||
display: inline-block; padding: .12em .5em;
|
||||
background: #e0e7ff; color: #3730a3;
|
||||
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
|
||||
}
|
||||
.chip.danger { background: #fee2e2; color: #991b1b; }
|
||||
.chip.good { background: #d1fae5; color: #065f46; }
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
|
||||
.kv dt { color: #6b7280; }
|
||||
.kv dd { margin: 0; }
|
||||
.note { color: #6b7280; font-size: .85rem; }
|
||||
.banner-text { font-family: ui-monospace, monospace; font-size: .78rem;
|
||||
background: #f9fafb; border: 1px solid #e5e7eb; padding: .3rem .5rem;
|
||||
border-radius: 4px; color: #374151; word-break: break-all; }
|
||||
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
|
||||
.check-ok { color: #059669; }
|
||||
.check-fail { color: #dc2626; }
|
||||
.check-info { color: #6b7280; }
|
||||
.relay-alert {
|
||||
background: #fef2f2; border: 2px solid #dc2626;
|
||||
border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem;
|
||||
}
|
||||
.relay-alert strong { color: #991b1b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>SMTP: <code>{{.Domain}}</code></h1>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
<div class="meta">
|
||||
{{if .NullMX}}<span class="badge info">null MX (refuses mail)</span>{{else}}
|
||||
{{if .AllSTARTTLS}}<span class="badge ok">all STARTTLS</span>
|
||||
{{else if .AnySTARTTLS}}<span class="badge warn">partial STARTTLS</span>
|
||||
{{else}}<span class="badge fail">no STARTTLS</span>{{end}}
|
||||
{{if .HasIPv4}}<span class="badge muted">IPv4</span>{{end}}
|
||||
{{if .HasIPv6}}<span class="badge muted">IPv6</span>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="meta">Checked {{.RunAt}}</div>
|
||||
</div>
|
||||
|
||||
{{if .HasIssues}}
|
||||
<div class="section">
|
||||
<h2>What to fix</h2>
|
||||
{{range .Fixes}}
|
||||
<div class="fix {{.Severity}}">
|
||||
<div class="code">{{.Code}}{{if .Target}} · {{.Target}}{{end}}{{if .Endpoint}}{{if not .Target}} · {{.Endpoint}}{{else}} ({{.Endpoint}}){{end}}{{end}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="section">
|
||||
<h2>DNS / MX</h2>
|
||||
{{if .NullMX}}
|
||||
<p class="note">This domain publishes a <strong>null MX record</strong>: it explicitly does not accept email (RFC 7505).</p>
|
||||
{{else if .ImplicitMX}}
|
||||
<p class="note">No MX record is published; senders will fall back to the domain's A/AAAA (implicit MX, discouraged).</p>
|
||||
{{else if .MXError}}
|
||||
<p class="note">MX lookup failed: <code>{{.MXError}}</code></p>
|
||||
{{else if .MX}}
|
||||
<table>
|
||||
<tr><th>Pref</th><th>Target</th><th>IPv4</th><th>IPv6</th><th>Issues</th></tr>
|
||||
{{range .MX}}
|
||||
<tr>
|
||||
<td>{{.Preference}}</td>
|
||||
<td><code>{{.Target}}</code></td>
|
||||
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
|
||||
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
|
||||
<td>
|
||||
{{if .IsIPLiteral}}<span class="check-fail">IP literal</span>{{end}}
|
||||
{{if .IsCNAME}}<span class="check-fail">CNAME chain: {{range .CNAMEChain}}<code>{{.}}</code> {{end}}</span>{{end}}
|
||||
{{if .ResolveErr}}<span class="check-fail">resolve: {{.ResolveErr}}</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="note">No MX records found.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Endpoints}}
|
||||
<div class="section">
|
||||
<h2>Endpoints ({{len .Endpoints}})</h2>
|
||||
{{range .Endpoints}}
|
||||
<details{{if .AnyFail}} open{{end}}>
|
||||
<summary>
|
||||
<span class="conn-addr">{{.Target}} · {{.Address}}</span>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
</summary>
|
||||
<div class="details-body">
|
||||
<dl class="kv">
|
||||
<dt>Family</dt><dd>{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}</dd>
|
||||
<dt>TCP :25</dt><dd>{{if .TCPConnected}}<span class="check-ok">✓ connected</span>{{else}}<span class="check-fail">✗ failed</span>{{end}}</dd>
|
||||
{{if .BannerLine}}
|
||||
<dt>Banner</dt><dd>
|
||||
<div class="banner-text">{{.BannerCode}} {{.BannerLine}}</div>
|
||||
{{if .BannerHostname}}<div class="note">announced name: <code>{{.BannerHostname}}</code></div>{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
<dt>EHLO</dt><dd>
|
||||
{{if .EHLOFallbackHELO}}<span class="check-fail">✗ EHLO rejected, only HELO works</span>
|
||||
{{else if .EHLOReceived}}<span class="check-ok">✓ accepted{{if .EHLOHostname}} (<code>{{.EHLOHostname}}</code>){{end}}</span>
|
||||
{{else}}<span class="check-fail">✗ failed</span>{{end}}
|
||||
</dd>
|
||||
{{if .EHLOReceived}}
|
||||
<dt>Extensions</dt><dd>
|
||||
<div class="chiprow">
|
||||
{{if .STARTTLSOffered}}<span class="chip good">STARTTLS</span>{{else}}<span class="chip danger">no STARTTLS</span>{{end}}
|
||||
{{if .HasPipelining}}<span class="chip good">PIPELINING</span>{{else}}<span class="chip danger">no PIPELINING</span>{{end}}
|
||||
{{if .Has8BITMIME}}<span class="chip">8BITMIME</span>{{end}}
|
||||
{{if .HasSMTPUTF8}}<span class="chip">SMTPUTF8</span>{{end}}
|
||||
{{if .HasCHUNKING}}<span class="chip">CHUNKING</span>{{end}}
|
||||
{{if .HasDSN}}<span class="chip">DSN</span>{{end}}
|
||||
{{if .HasENHANCEDCODE}}<span class="chip">ENHANCEDSTATUSCODES</span>{{end}}
|
||||
{{if .SizeLimit}}<span class="chip">SIZE {{humanBytes .SizeLimit}}</span>{{end}}
|
||||
</div>
|
||||
</dd>
|
||||
{{end}}
|
||||
{{if .AUTHPreTLS}}
|
||||
<dt>AUTH pre-TLS</dt><dd>
|
||||
<span class="check-fail">✗ advertised without TLS:</span>
|
||||
{{range .AUTHPreTLS}}<span class="chip danger">{{.}}</span> {{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
{{if .AUTHPostTLS}}
|
||||
<dt>AUTH post-TLS</dt><dd>{{range .AUTHPostTLS}}<span class="chip">{{.}}</span> {{end}}</dd>
|
||||
{{end}}
|
||||
<dt>STARTTLS</dt><dd>
|
||||
{{if .STARTTLSUpgraded}}<span class="check-ok">✓ {{.TLSVersion}}{{if .TLSCipher}} ({{.TLSCipher}}){{end}}</span>
|
||||
{{else if .STARTTLSOffered}}<span class="check-fail">✗ handshake failed</span>
|
||||
{{else}}<span class="check-fail">✗ not offered</span>{{end}}
|
||||
</dd>
|
||||
{{with .TLSPosture}}
|
||||
<dt>TLS cert</dt><dd>
|
||||
{{if .ChainValid}}{{if deref .ChainValid}}<span class="check-ok">✓ chain valid</span>{{else}}<span class="check-fail">✗ chain invalid</span>{{end}}{{end}}
|
||||
{{if .HostnameMatch}} · {{if deref .HostnameMatch}}<span class="check-ok">✓ hostname match</span>{{else}}<span class="check-fail">✗ hostname mismatch</span>{{end}}{{end}}
|
||||
{{if not .NotAfter.IsZero}} · expires <code>{{.NotAfter.Format "2006-01-02"}}</code>{{end}}
|
||||
{{if not .CheckedAt.IsZero}}<div class="note">TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}</div>{{end}}
|
||||
{{range .Issues}}
|
||||
<div class="fix {{.Severity}}" style="margin-top:.3rem">
|
||||
<div class="code">{{.Code}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</dd>
|
||||
{{end}}
|
||||
<dt>PTR</dt><dd>
|
||||
{{if .PTR}}<code>{{.PTR}}</code>
|
||||
{{if .FCrDNSPass}}· <span class="check-ok">✓ FCrDNS</span>
|
||||
{{else}}· <span class="check-fail">✗ FCrDNS mismatch</span>{{end}}
|
||||
{{else}}<span class="check-fail">✗ no PTR</span>{{if .PTRError}} <span class="note">({{.PTRError}})</span>{{end}}{{end}}
|
||||
</dd>
|
||||
{{if .NullSenderState}}
|
||||
<dt>Null sender</dt><dd>
|
||||
<span class="check-{{.NullSenderClass}}">{{.NullSenderState}}</span>
|
||||
<div class="note">{{.NullSenderResponse}}</div>
|
||||
</dd>
|
||||
{{end}}
|
||||
{{if .PostmasterState}}
|
||||
<dt>Postmaster</dt><dd>
|
||||
<span class="check-{{.PostmasterClass}}">{{.PostmasterState}}</span>
|
||||
<div class="note">{{.PostmasterResponse}}</div>
|
||||
</dd>
|
||||
{{end}}
|
||||
{{if .OpenRelayState}}
|
||||
<dt>Open relay</dt><dd>
|
||||
<span class="check-{{.OpenRelayClass}}">{{.OpenRelayState}}</span>
|
||||
<div class="note">rcpt=<code>{{.OpenRelayRecipient}}</code>: {{.OpenRelayResponse}}</div>
|
||||
</dd>
|
||||
{{end}}
|
||||
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
|
||||
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="footer">{{if .HasTLSPosture}}Certificate posture above comes from the TLS checker, which probed the same endpoints after we discovered them.{{else}}For certificate chain, SAN match, expiry and cipher posture, run the TLS checker on port 25 with STARTTLS=smtp.{{end}}</p>
|
||||
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// GetHTMLReport implements sdk.CheckerHTMLReporter.
|
||||
func (p *smtpProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
|
||||
var d SMTPData
|
||||
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
|
||||
return "", fmt.Errorf("unmarshal smtp observation: %w", err)
|
||||
}
|
||||
view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States())
|
||||
return renderReport(view)
|
||||
}
|
||||
|
||||
func renderReport(view reportData) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := reportTpl.Execute(&buf, view); err != nil {
|
||||
return "", fmt.Errorf("render smtp report: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func buildReportData(d *SMTPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData {
|
||||
tlsByAddr := indexTLSByAddress(related)
|
||||
|
||||
fixes := fixesFromStates(states)
|
||||
|
||||
view := reportData{
|
||||
Domain: d.Domain,
|
||||
RunAt: d.RunAt,
|
||||
NullMX: d.MX.NullMX,
|
||||
ImplicitMX: d.MX.ImplicitMX,
|
||||
MXError: d.MX.Error,
|
||||
HasIPv4: d.Coverage.HasIPv4,
|
||||
HasIPv6: d.Coverage.HasIPv6,
|
||||
AnySTARTTLS: d.Coverage.AnySTARTTLS,
|
||||
AllSTARTTLS: d.Coverage.AllSTARTTLS,
|
||||
HasIssues: len(fixes) > 0,
|
||||
HasTLSPosture: len(tlsByAddr) > 0,
|
||||
}
|
||||
|
||||
view.StatusLabel, view.StatusClass = overallStatus(d, states, fixes)
|
||||
|
||||
sevRank := func(s string) int {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return 0
|
||||
case SeverityWarn:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) })
|
||||
view.Fixes = fixes
|
||||
|
||||
for _, rec := range d.MX.Records {
|
||||
view.MX = append(view.MX, reportMX{
|
||||
Preference: rec.Preference,
|
||||
Target: rec.Target,
|
||||
IPv4: rec.IPv4,
|
||||
IPv6: rec.IPv6,
|
||||
IsCNAME: rec.IsCNAME,
|
||||
CNAMEChain: rec.CNAMEChain,
|
||||
IsIPLiteral: rec.IsIPLiteral,
|
||||
ResolveErr: rec.ResolveError,
|
||||
})
|
||||
}
|
||||
|
||||
for _, ep := range d.Endpoints {
|
||||
re := reportEndpoint{
|
||||
Target: ep.Target,
|
||||
Address: ep.Address,
|
||||
IP: ep.IP,
|
||||
IsIPv6: ep.IsIPv6,
|
||||
TCPConnected: ep.TCPConnected,
|
||||
BannerLine: ep.BannerLine,
|
||||
BannerHostname: ep.BannerHostname,
|
||||
BannerCode: ep.BannerCode,
|
||||
EHLOReceived: ep.EHLOReceived,
|
||||
EHLOFallbackHELO: ep.EHLOFallbackHELO,
|
||||
EHLOHostname: ep.EHLOHostname,
|
||||
STARTTLSOffered: ep.STARTTLSOffered,
|
||||
STARTTLSUpgraded: ep.STARTTLSUpgraded,
|
||||
TLSVersion: ep.TLSVersion,
|
||||
TLSCipher: ep.TLSCipher,
|
||||
SizeLimit: ep.SizeLimit,
|
||||
HasPipelining: ep.HasPipelining,
|
||||
Has8BITMIME: ep.Has8BITMIME,
|
||||
HasSMTPUTF8: ep.HasSMTPUTF8,
|
||||
HasCHUNKING: ep.HasCHUNKING,
|
||||
HasDSN: ep.HasDSN,
|
||||
HasENHANCEDCODE: ep.HasENHANCEDCODE,
|
||||
AUTHPreTLS: ep.AUTHPreTLS,
|
||||
AUTHPostTLS: ep.AUTHPostTLS,
|
||||
PTR: ep.PTR,
|
||||
PTRError: ep.PTRError,
|
||||
FCrDNSPass: ep.FCrDNSPass,
|
||||
NullSenderResponse: ep.NullSenderResponse,
|
||||
PostmasterResponse: ep.PostmasterResponse,
|
||||
OpenRelayResponse: ep.OpenRelayResponse,
|
||||
OpenRelayRecipient: ep.OpenRelayRecipient,
|
||||
ElapsedMS: ep.ElapsedMS,
|
||||
Error: ep.Error,
|
||||
}
|
||||
if ep.NullSenderAccepted != nil {
|
||||
if *ep.NullSenderAccepted {
|
||||
re.NullSenderState = "accepted"
|
||||
re.NullSenderClass = "ok"
|
||||
} else {
|
||||
re.NullSenderState = "REJECTED"
|
||||
re.NullSenderClass = "fail"
|
||||
}
|
||||
}
|
||||
if ep.PostmasterAccepted != nil {
|
||||
if *ep.PostmasterAccepted {
|
||||
re.PostmasterState = "accepted"
|
||||
re.PostmasterClass = "ok"
|
||||
} else {
|
||||
re.PostmasterState = "REJECTED"
|
||||
re.PostmasterClass = "fail"
|
||||
}
|
||||
}
|
||||
if ep.OpenRelay != nil {
|
||||
if *ep.OpenRelay {
|
||||
re.OpenRelayState = "OPEN RELAY"
|
||||
re.OpenRelayClass = "fail"
|
||||
} else {
|
||||
re.OpenRelayState = "properly refused"
|
||||
re.OpenRelayClass = "ok"
|
||||
}
|
||||
}
|
||||
if meta, hit := tlsByAddr[ep.Address]; hit {
|
||||
re.TLSPosture = meta
|
||||
} else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit {
|
||||
re.TLSPosture = meta
|
||||
}
|
||||
ok := ep.TCPConnected && ep.EHLOReceived
|
||||
if ep.STARTTLSOffered {
|
||||
ok = ok && ep.STARTTLSUpgraded
|
||||
}
|
||||
if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted {
|
||||
ok = false
|
||||
}
|
||||
if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted {
|
||||
ok = false
|
||||
}
|
||||
if ep.OpenRelay != nil && *ep.OpenRelay {
|
||||
ok = false
|
||||
}
|
||||
re.AnyFail = !ok
|
||||
switch {
|
||||
case !ep.TCPConnected:
|
||||
re.StatusLabel = "unreachable"
|
||||
re.StatusClass = "fail"
|
||||
case ep.OpenRelay != nil && *ep.OpenRelay:
|
||||
re.StatusLabel = "OPEN RELAY"
|
||||
re.StatusClass = "fail"
|
||||
case !ok:
|
||||
re.StatusLabel = "partial"
|
||||
re.StatusClass = "warn"
|
||||
default:
|
||||
re.StatusLabel = "OK"
|
||||
re.StatusClass = "ok"
|
||||
}
|
||||
view.Endpoints = append(view.Endpoints, re)
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
// fixesFromStates turns the rule-driven CheckStates into the hint/fix
|
||||
// entries the report renders. It consumes Message, Meta["fix"], and Status
|
||||
// exclusively, the derivation of those fields lives in the rules, not
|
||||
// here. States that do not represent a finding (OK, Unknown) are skipped.
|
||||
func fixesFromStates(states []sdk.CheckState) []reportFix {
|
||||
out := make([]reportFix, 0, len(states))
|
||||
for _, st := range states {
|
||||
sev := statusToSeverity(st.Status)
|
||||
if sev == "" {
|
||||
continue
|
||||
}
|
||||
fix := ""
|
||||
endpoint := ""
|
||||
target := ""
|
||||
if st.Meta != nil {
|
||||
if s, ok := st.Meta["fix"].(string); ok {
|
||||
fix = s
|
||||
}
|
||||
if s, ok := st.Meta["endpoint"].(string); ok {
|
||||
endpoint = s
|
||||
}
|
||||
if s, ok := st.Meta["target"].(string); ok {
|
||||
target = s
|
||||
}
|
||||
}
|
||||
out = append(out, reportFix{
|
||||
Severity: sev,
|
||||
Code: st.Code,
|
||||
Message: st.Message,
|
||||
Fix: fix,
|
||||
Endpoint: endpoint,
|
||||
Target: target,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// statusToSeverity maps an sdk.Status to the severity strings used by the
|
||||
// HTML template. Status values that represent a non-finding (OK, Unknown)
|
||||
// return "" so the caller can skip them.
|
||||
func statusToSeverity(s sdk.Status) string {
|
||||
switch s {
|
||||
case sdk.StatusCrit, sdk.StatusError:
|
||||
return SeverityCrit
|
||||
case sdk.StatusWarn:
|
||||
return SeverityWarn
|
||||
case sdk.StatusInfo:
|
||||
return SeverityInfo
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// overallStatus picks the overall badge label/class. When there are no
|
||||
// states at all (data-only render), we fall back to a neutral "data only"
|
||||
// badge instead of claiming "OK", we can't assert anything we haven't
|
||||
// actually evaluated.
|
||||
func overallStatus(d *SMTPData, states []sdk.CheckState, fixes []reportFix) (string, string) {
|
||||
if d.MX.NullMX {
|
||||
return "NULL MX", "info"
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return "data only", "muted"
|
||||
}
|
||||
worst := ""
|
||||
for _, f := range fixes {
|
||||
if f.Severity == SeverityCrit {
|
||||
worst = SeverityCrit
|
||||
break
|
||||
}
|
||||
if f.Severity == SeverityWarn {
|
||||
worst = SeverityWarn
|
||||
} else if worst == "" && f.Severity == SeverityInfo {
|
||||
worst = SeverityInfo
|
||||
}
|
||||
}
|
||||
switch worst {
|
||||
case SeverityCrit:
|
||||
return "FAIL", "fail"
|
||||
case SeverityWarn:
|
||||
return "WARN", "warn"
|
||||
case SeverityInfo:
|
||||
return "INFO", "info"
|
||||
default:
|
||||
return "OK", "ok"
|
||||
}
|
||||
}
|
||||
|
||||
func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
|
||||
out := map[string]*reportTLSPosture{}
|
||||
for _, r := range related {
|
||||
v := parseTLSRelated(r)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
addr := v.address()
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
posture := &reportTLSPosture{
|
||||
CheckedAt: r.CollectedAt,
|
||||
ChainValid: v.ChainValid,
|
||||
HostnameMatch: v.HostnameMatch,
|
||||
NotAfter: v.NotAfter,
|
||||
}
|
||||
for _, is := range v.Issues {
|
||||
sev := strings.ToLower(is.Severity)
|
||||
if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo {
|
||||
continue
|
||||
}
|
||||
posture.Issues = append(posture.Issues, reportFix{
|
||||
Severity: sev,
|
||||
Code: is.Code,
|
||||
Message: is.Message,
|
||||
Fix: is.Fix,
|
||||
})
|
||||
}
|
||||
out[addr] = posture
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue