checker-xmpp/checker/report.go

552 lines
18 KiB
Go

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
}
type reportEndpoint struct {
Mode string
ModeLabel string
SRVPrefix string
Target string
Port uint16
Address string
IsIPv6 bool
DirectTLS bool
TCPConnected bool
StreamOpened bool
STARTTLSOffered bool
STARTTLSRequired bool
STARTTLSUpgraded bool
TLSVersion string
TLSCipher string
SASLMechanisms []string
DialbackOffered bool
SASLExternal bool
StreamFrom string
ElapsedMS int64
Error string
// TLS posture (from a related tls_probes observation, when available).
TLSPosture *reportTLSPosture
// Rendering helpers.
AnyFail bool
StatusLabel string
StatusClass string
}
type reportTLSPosture struct {
CheckedAt time.Time
ChainValid *bool
HostnameMatch *bool
NotAfter time.Time
Issues []reportFix
}
type reportSRVEntry struct {
Prefix string
Target string
Port uint16
Priority uint16
Weight uint16
IPv4 []string
IPv6 []string
}
type reportData struct {
Domain string
RunAt string
StatusLabel string
StatusClass string
HasIssues bool
Fixes []reportFix
SRV []reportSRVEntry
FallbackProbed bool
JabberLegacy bool
Endpoints []reportEndpoint
HasIPv4 bool
HasIPv6 bool
WorkingC2S bool
WorkingS2S bool
HasTLSPosture bool
}
var reportTpl = template.Must(template.New("xmpp").Funcs(template.FuncMap{
"hasPrefix": strings.HasPrefix,
"deref": func(b *bool) bool { return b != nil && *b },
}).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XMPP 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; }
.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.plain { background: #fee2e2; color: #991b1b; }
.chip.scram { 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; }
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
</style>
</head>
<body>
<div class="hd">
<h1>XMPP: <code>{{.Domain}}</code></h1>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
<div class="meta">
{{if .WorkingC2S}}<span class="badge ok" style="margin-right:.25rem">c2s OK</span>{{else}}<span class="badge fail" style="margin-right:.25rem">c2s FAIL</span>{{end}}
{{if .WorkingS2S}}<span class="badge ok" style="margin-right:.25rem">s2s OK</span>{{else}}<span class="badge fail" style="margin-right:.25rem">s2s FAIL</span>{{end}}
{{if .HasIPv4}}<span class="badge muted">IPv4</span>{{end}}
{{if .HasIPv6}}<span class="badge muted">IPv6</span>{{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 .Endpoint}} · {{.Endpoint}}{{end}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
<div class="section">
<h2>DNS / SRV</h2>
{{if .FallbackProbed}}
<p class="note">No SRV records published; fell back to probing the bare domain on default ports.</p>
{{else if .SRV}}
<table>
<tr><th>Record</th><th>Target</th><th>Port</th><th>Prio/Weight</th><th>IPv4</th><th>IPv6</th></tr>
{{range .SRV}}
<tr>
<td><code>{{.Prefix}}</code></td>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Priority}}/{{.Weight}}</td>
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
</tr>
{{end}}
</table>
{{else}}
<p class="note">No SRV records found.</p>
{{end}}
{{if .JabberLegacy}}<p class="note">&#9888; Obsolete <code>_jabber._tcp</code> records are still published.</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">{{.ModeLabel}} · {{.Address}}{{if .DirectTLS}} · direct-TLS{{end}}</span>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
</summary>
<div class="details-body">
<dl class="kv">
<dt>SRV</dt><dd><code>{{.SRVPrefix}}</code> &rarr; <code>{{.Target}}:{{.Port}}</code></dd>
<dt>Family</dt><dd>{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}</dd>
<dt>TCP</dt><dd>{{if .TCPConnected}}<span class="check-ok">&#10003; connected</span>{{else}}<span class="check-fail">&#10007; failed</span>{{end}}</dd>
<dt>Stream</dt><dd>{{if .StreamOpened}}<span class="check-ok">&#10003; opened</span>{{if .StreamFrom}} (from=<code>{{.StreamFrom}}</code>){{end}}{{else}}<span class="check-fail">&#10007; not opened</span>{{end}}</dd>
{{if not .DirectTLS}}
<dt>STARTTLS</dt><dd>
{{if .STARTTLSOffered}}<span class="check-ok">&#10003; offered</span>{{else}}<span class="check-fail">&#10007; not offered</span>{{end}}
{{if .STARTTLSRequired}} &middot; <span class="check-ok">required</span>{{else if .STARTTLSOffered}} &middot; <span class="check-fail">not required</span>{{end}}
</dd>
{{end}}
<dt>TLS</dt><dd>{{if .STARTTLSUpgraded}}<span class="check-ok">&#10003; {{.TLSVersion}}{{if .TLSCipher}} &mdash; {{.TLSCipher}}{{end}}</span>{{else}}<span class="check-fail">&#10007; no TLS</span>{{end}}</dd>
{{if eq .Mode "c2s"}}
<dt>SASL</dt><dd>
{{if .SASLMechanisms}}
<div class="chiprow">
{{range .SASLMechanisms}}<span class="chip {{if eq . "PLAIN"}}plain{{else if hasPrefix . "SCRAM-"}}scram{{end}}">{{.}}</span>{{end}}
</div>
{{else}}<span class="note">none advertised</span>{{end}}
</dd>
{{end}}
{{if eq .Mode "s2s"}}
<dt>Federation</dt><dd>
{{if .DialbackOffered}}<span class="check-ok">&#10003; dialback</span>{{else}}<span class="check-fail">&#10007; no dialback</span>{{end}}
&middot;
{{if .SASLExternal}}<span class="check-ok">&#10003; SASL EXTERNAL</span>{{else}}<span class="check-fail">&#10007; no SASL EXTERNAL</span>{{end}}
</dd>
{{end}}
{{with .TLSPosture}}
<dt>TLS cert</dt><dd>
{{if .ChainValid}}
{{if deref .ChainValid}}<span class="check-ok">&#10003; chain valid</span>{{else}}<span class="check-fail">&#10007; chain invalid</span>{{end}}
{{end}}
{{if .HostnameMatch}}
&middot; {{if deref .HostnameMatch}}<span class="check-ok">&#10003; hostname match</span>{{else}}<span class="check-fail">&#10007; hostname mismatch</span>{{end}}
{{end}}
{{if not .NotAfter.IsZero}}
&middot; 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">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</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 the same ports.{{end}}</p>
</body>
</html>`))
// GetHTMLReport implements sdk.CheckerHTMLReporter. It folds in related TLS
// observations so the XMPP service page shows cert posture directly, without
// the user having to open a separate TLS report.
//
// The hint/fix section is driven exclusively by ctx.States(): it is the host
// that has already evaluated every rule and handed us the resulting
// CheckStates. The report never re-derives issues from the raw observation
// so there is no duplicated judgment logic. When States() is empty (for
// example a standalone render with no rule run), we still show the raw
// facts (SRV table, endpoint details) but drop the actionable hints.
func (p *xmppProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
var d XMPPData
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
return "", fmt.Errorf("unmarshal xmpp 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 xmpp report: %w", err)
}
return buf.String(), nil
}
func buildReportData(d *XMPPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData {
tlsByAddr := indexTLSByAddress(related)
// Fix list comes exclusively from the CheckStates the host evaluated.
// When no states were supplied (standalone renders, one-off tests),
// the hint section is skipped entirely: we show raw facts only,
// never re-judge the observation here.
fixes := fixesFromStates(states)
hasStates := len(states) > 0
view := reportData{
Domain: d.Domain,
RunAt: d.RunAt,
FallbackProbed: d.SRV.FallbackProbed,
JabberLegacy: len(d.SRV.Jabber) > 0,
HasIPv4: d.Coverage.HasIPv4,
HasIPv6: d.Coverage.HasIPv6,
WorkingC2S: d.Coverage.WorkingC2S,
WorkingS2S: d.Coverage.WorkingS2S,
HasIssues: len(fixes) > 0,
HasTLSPosture: len(tlsByAddr) > 0,
}
// Status banner: driven by the worst CheckState when available,
// otherwise a neutral label (data-only render).
if !hasStates {
view.StatusLabel = "DATA"
view.StatusClass = "muted"
} else {
worst := sdk.StatusOK
for _, s := range states {
if s.Status > worst {
worst = s.Status
}
}
switch worst {
case sdk.StatusCrit, sdk.StatusError:
view.StatusLabel = "FAIL"
view.StatusClass = "fail"
case sdk.StatusWarn:
view.StatusLabel = "WARN"
view.StatusClass = "warn"
case sdk.StatusInfo:
view.StatusLabel = "INFO"
view.StatusClass = "muted"
case sdk.StatusUnknown:
view.StatusLabel = "UNKNOWN"
view.StatusClass = "muted"
default:
view.StatusLabel = "OK"
view.StatusClass = "ok"
}
}
// Fix list: sort crit → warn → info, preserving order within each severity.
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
// SRV rows.
addSRV := func(prefix string, records []SRVRecord) {
for _, r := range records {
view.SRV = append(view.SRV, reportSRVEntry{
Prefix: prefix, Target: r.Target, Port: r.Port,
Priority: r.Priority, Weight: r.Weight,
IPv4: r.IPv4, IPv6: r.IPv6,
})
}
}
addSRV("_xmpp-client._tcp", d.SRV.Client)
addSRV("_xmpp-server._tcp", d.SRV.Server)
addSRV("_xmpps-client._tcp", d.SRV.ClientSecure)
addSRV("_xmpps-server._tcp", d.SRV.ServerSecure)
addSRV("_jabber._tcp", d.SRV.Jabber)
// Endpoint rows.
for _, ep := range d.Endpoints {
re := reportEndpoint{
Mode: string(ep.Mode),
ModeLabel: modeLabel(ep.Mode),
SRVPrefix: ep.SRVPrefix,
Target: ep.Target,
Port: ep.Port,
Address: ep.Address,
IsIPv6: ep.IsIPv6,
DirectTLS: ep.DirectTLS,
TCPConnected: ep.TCPConnected,
StreamOpened: ep.StreamOpened,
STARTTLSOffered: ep.STARTTLSOffered,
STARTTLSRequired: ep.STARTTLSRequired,
STARTTLSUpgraded: ep.STARTTLSUpgraded,
TLSVersion: ep.TLSVersion,
TLSCipher: ep.TLSCipher,
SASLMechanisms: ep.SASLMechanisms,
DialbackOffered: ep.DialbackOffered,
SASLExternal: ep.SASLExternal,
StreamFrom: ep.StreamFrom,
ElapsedMS: ep.ElapsedMS,
Error: ep.Error,
}
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.STARTTLSUpgraded
if ep.Mode == ModeServer {
ok = ok && (ep.DialbackOffered || ep.SASLExternal)
}
if ep.Mode == ModeClient {
ok = ok && len(ep.SASLMechanisms) > 0
}
re.AnyFail = !ok
if ok {
re.StatusLabel = "OK"
re.StatusClass = "ok"
} else if ep.TCPConnected {
re.StatusLabel = "partial"
re.StatusClass = "warn"
} else {
re.StatusLabel = "unreachable"
re.StatusClass = "fail"
}
view.Endpoints = append(view.Endpoints, re)
}
return view
}
// fixesFromStates turns CheckStates handed to us by the host into the
// severity-tagged entries rendered in the "What to fix" section. It is
// intentionally the only source of hints on the report: the raw
// observation is never re-judged here.
func fixesFromStates(states []sdk.CheckState) []reportFix {
var out []reportFix
for _, s := range states {
var sev string
switch s.Status {
case sdk.StatusCrit, sdk.StatusError:
sev = SeverityCrit
case sdk.StatusWarn:
sev = SeverityWarn
case sdk.StatusInfo:
sev = SeverityInfo
default:
// OK / Unknown: not an actionable finding.
continue
}
fix := ""
if s.Meta != nil {
if v, ok := s.Meta["fix"].(string); ok {
fix = v
}
}
out = append(out, reportFix{
Severity: sev,
Code: s.Code,
Message: s.Message,
Fix: fix,
Endpoint: s.Subject,
})
}
return out
}
func modeLabel(m XMPPMode) string {
switch m {
case ModeClient:
return "client"
case ModeServer:
return "server"
default:
return string(m)
}
}
// indexTLSByAddress returns a map keyed by "host:port" (and by the SRV
// target:port when host is the target) pointing at a reportTLSPosture.
// This lets the template match a related observation to the right endpoint.
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
}