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(`
XMPP Report: {{.Domain}}
XMPP: {{.Domain}}
{{.StatusLabel}}
{{if .WorkingC2S}}c2s OK{{else}}c2s FAIL{{end}}
{{if .WorkingS2S}}s2s OK{{else}}s2s FAIL{{end}}
{{if .HasIPv4}}IPv4{{end}}
{{if .HasIPv6}}IPv6{{end}}
Checked {{.RunAt}}
{{if .HasIssues}}
What to fix
{{range .Fixes}}
{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}
{{.Message}}
{{if .Fix}}
→ {{.Fix}}
{{end}}
{{end}}
{{end}}
DNS / SRV
{{if .FallbackProbed}}
No SRV records published; fell back to probing the bare domain on default ports.
{{else if .SRV}}
| Record | Target | Port | Prio/Weight | IPv4 | IPv6 |
{{range .SRV}}
{{.Prefix}} |
{{.Target}} |
{{.Port}} |
{{.Priority}}/{{.Weight}} |
{{range .IPv4}}{{.}} {{end}} |
{{range .IPv6}}{{.}} {{end}} |
{{end}}
{{else}}
No SRV records found.
{{end}}
{{if .JabberLegacy}}
⚠ Obsolete _jabber._tcp records are still published.
{{end}}
{{if .Endpoints}}
Endpoints ({{len .Endpoints}})
{{range .Endpoints}}
{{.ModeLabel}} · {{.Address}}{{if .DirectTLS}} · direct-TLS{{end}}
{{.StatusLabel}}
- SRV
{{.SRVPrefix}} → {{.Target}}:{{.Port}}
- Family
- {{if .IsIPv6}}IPv6{{else}}IPv4{{end}}
- TCP
- {{if .TCPConnected}}✓ connected{{else}}✗ failed{{end}}
- Stream
- {{if .StreamOpened}}✓ opened{{if .StreamFrom}} (from=
{{.StreamFrom}}){{end}}{{else}}✗ not opened{{end}}
{{if not .DirectTLS}}
- STARTTLS
-
{{if .STARTTLSOffered}}✓ offered{{else}}✗ not offered{{end}}
{{if .STARTTLSRequired}} · required{{else if .STARTTLSOffered}} · not required{{end}}
{{end}}
- TLS
- {{if .STARTTLSUpgraded}}✓ {{.TLSVersion}}{{if .TLSCipher}} — {{.TLSCipher}}{{end}}{{else}}✗ no TLS{{end}}
{{if eq .Mode "c2s"}}
- SASL
-
{{if .SASLMechanisms}}
{{range .SASLMechanisms}}{{.}}{{end}}
{{else}}none advertised{{end}}
{{end}}
{{if eq .Mode "s2s"}}
- Federation
-
{{if .DialbackOffered}}✓ dialback{{else}}✗ no dialback{{end}}
·
{{if .SASLExternal}}✓ SASL EXTERNAL{{else}}✗ no SASL EXTERNAL{{end}}
{{end}}
{{with .TLSPosture}}
- TLS cert
-
{{if .ChainValid}}
{{if deref .ChainValid}}✓ chain valid{{else}}✗ chain invalid{{end}}
{{end}}
{{if .HostnameMatch}}
· {{if deref .HostnameMatch}}✓ hostname match{{else}}✗ hostname mismatch{{end}}
{{end}}
{{if not .NotAfter.IsZero}}
· expires
{{.NotAfter.Format "2006-01-02"}}
{{end}}
{{if not .CheckedAt.IsZero}}
TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}
{{end}}
{{range .Issues}}
{{.Code}}
{{.Message}}
{{if .Fix}}
→ {{.Fix}}
{{end}}
{{end}}
{{end}}
- Duration
- {{.ElapsedMS}} ms
{{if .Error}}- Error
- {{.Error}}
{{end}}
{{end}}
{{end}}
`))
// 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.
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))
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) reportData {
tlsIssues := tlsIssuesFromRelated(related)
tlsByAddr := indexTLSByAddress(related)
allIssues := append([]Issue(nil), d.Issues...)
allIssues = append(allIssues, tlsIssues...)
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(allIssues) > 0,
HasTLSPosture: len(tlsByAddr) > 0,
}
// Status banner.
worst := SeverityInfo
for _, is := range allIssues {
if is.Severity == SeverityCrit {
worst = SeverityCrit
break
}
if is.Severity == SeverityWarn {
worst = SeverityWarn
}
}
if len(allIssues) == 0 {
view.StatusLabel = "OK"
view.StatusClass = "ok"
} else {
switch worst {
case SeverityCrit:
view.StatusLabel = "FAIL"
view.StatusClass = "fail"
case SeverityWarn:
view.StatusLabel = "WARN"
view.StatusClass = "warn"
default:
view.StatusLabel = "INFO"
view.StatusClass = "muted"
}
}
// 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(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) })
for _, is := range allIssues {
view.Fixes = append(view.Fixes, reportFix{
Severity: is.Severity,
Code: is.Code,
Message: is.Message,
Fix: is.Fix,
Endpoint: is.Endpoint,
})
}
// 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
}
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
}