checker-matrix/checker/report.go
Pierre-Olivier Mercier 2af16d3ab9 checker: harden HTTP collection and stabilize report ordering
Validate the federation tester URI placeholder, escape the domain, set
a client timeout, cap the response body, and ship CA certificates in
the scratch image so HTTPS calls succeed. Sort hosts, connection
reports, and errors when rendering so output is deterministic, and
deduplicate TLS problems. Drop the deprecated aggregate Rule() and add
tests for collection and rules.
2026-04-26 04:13:55 +07:00

396 lines
10 KiB
Go

package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ── HTML report ───────────────────────────────────────────────────────────────
type matrixCertData struct {
SubjectCommonName string
IssuerCommonName string
SHA256Fingerprint string
DNSNames []string
}
type matrixConnectionData struct {
Address string
TLSVersion string
CipherSuite string
Certs []matrixCertData
AllChecksOK bool
CheckDetails []matrixCheckItem
Errors []string
Open bool
}
type matrixCheckItem struct {
Label string
OK bool
}
type matrixConnErrData struct {
Address string
Message string
}
type matrixSRVRecord struct {
Target string
Port uint16
Priority uint16
Weight uint16
}
type matrixHostData struct {
Name string
CName string
Addrs []string
}
type matrixTemplateData struct {
FederationOK bool
Version string
VersionError string
WellKnownServer string
WellKnownResult string
SRVSkipped bool
SRVCName string
SRVRecords []matrixSRVRecord
SRVError string
Hosts []matrixHostData
Addrs []string
Connections []matrixConnectionData
ConnectionErrors []matrixConnErrData
}
var matrixHTMLTemplate = template.Must(
template.New("matrix").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Matrix Federation Report</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; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd {
background: #fff;
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
.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; }
.fail { background: #fee2e2; color: #991b1b; }
.version { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
.section {
background: #fff;
border-radius: 8px;
padding: .85rem 1rem;
margin-bottom: .6rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
}
details {
background: #fff;
border-radius: 8px;
margin-bottom: .45rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
overflow: hidden;
}
.section details {
box-shadow: none;
border-radius: 6px;
border: 1px solid #e5e7eb;
margin-bottom: .4rem;
}
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; }
th { font-weight: 600; color: #6b7280; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
.errmsg { color: #dc2626; font-size: .85rem; margin: .25rem 0 0; }
.note { color: #6b7280; font-size: .85rem; }
ul { margin: .25rem 0; padding-left: 1.2rem; }
li { margin-bottom: .15rem; }
</style>
</head>
<body>
<div class="hd">
<h1>Matrix Federation</h1>
{{if .FederationOK}}
<span class="badge ok">Federation OK</span>
{{- else}}
<span class="badge fail">Federation FAIL</span>
{{- end}}
{{if .Version}}<div class="version">Server: <code>{{.Version}}</code>{{if .VersionError}} &mdash; {{.VersionError}}{{end}}</div>{{end}}
</div>
{{if .Connections}}
<div class="section">
<h2>Connections ({{len .Connections}})</h2>
{{range .Connections}}
<details{{if .Open}} open{{end}}>
<summary>
<span class="conn-addr">{{.Address}}</span>
{{if .AllChecksOK}}<span class="badge ok">All checks OK</span>{{else}}<span class="badge fail">Checks failed</span>{{end}}
</summary>
<div class="details-body">
{{if or .TLSVersion .CipherSuite}}
<h3>TLS</h3>
<p class="note">{{.TLSVersion}}{{if and .TLSVersion .CipherSuite}} &mdash; {{end}}{{.CipherSuite}}</p>
{{end}}
{{if .Certs}}
<h3>Certificates</h3>
<table>
<tr><th>Subject</th><th>Issuer</th><th>DNS Names</th><th>Fingerprint (SHA-256)</th></tr>
{{range .Certs}}
<tr>
<td><code>{{.SubjectCommonName}}</code></td>
<td><code>{{.IssuerCommonName}}</code></td>
<td>{{range .DNSNames}}<code>{{.}}</code> {{end}}</td>
<td><code>{{.SHA256Fingerprint}}</code></td>
</tr>
{{end}}
</table>
{{end}}
{{if .CheckDetails}}
<h3 style="margin-top:.7rem">Checks</h3>
<table>
{{range .CheckDetails}}
<tr>
<td>{{if .OK}}<span class="check-ok">&#10003;</span>{{else}}<span class="check-fail">&#10007;</span>{{end}}</td>
<td>{{.Label}}</td>
</tr>
{{end}}
</table>
{{end}}
{{range .Errors}}<p class="errmsg">&#9888; {{.}}</p>{{end}}
</div>
</details>
{{end}}
</div>
{{end}}
{{if .ConnectionErrors}}
<div class="section">
<h2>Connection Errors ({{len .ConnectionErrors}})</h2>
{{range .ConnectionErrors}}
<p><code>{{.Address}}</code><br><span class="errmsg">{{.Message}}</span></p>
{{end}}
</div>
{{end}}
<div class="section">
<h2>Well-Known</h2>
{{if .WellKnownServer}}
<p>Server: <code>{{.WellKnownServer}}</code></p>
{{else if .WellKnownResult}}
<p class="note">{{.WellKnownResult}}</p>
{{else}}
<p class="note">Not found.</p>
{{end}}
</div>
<div class="section">
<h2>DNS Resolution</h2>
{{if .SRVSkipped}}
<p class="note">SRV lookup skipped{{if .SRVCName}} (CNAME: <code>{{.SRVCName}}</code>){{end}}</p>
{{else if .SRVError}}
<p class="errmsg">SRV error: {{.SRVError}}</p>
{{else if .SRVRecords}}
<h3>SRV Records</h3>
<table>
<tr><th>Target</th><th>Port</th><th>Priority</th><th>Weight</th></tr>
{{range .SRVRecords}}
<tr>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Priority}}</td>
<td>{{.Weight}}</td>
</tr>
{{end}}
</table>
{{else}}
<p class="note">No SRV records found.</p>
{{end}}
{{if .Hosts}}
<h3 style="margin-top:.6rem">Resolved Hosts</h3>
{{range .Hosts}}
<p style="margin:.25rem 0">
<code>{{.Name}}</code>
{{if .CName}} &rarr; <code>{{.CName}}</code>{{end}}
{{if .Addrs}}: {{range .Addrs}}<code>{{.}}</code> {{end}}{{end}}
</p>
{{end}}
{{else if .Addrs}}
<h3 style="margin-top:.6rem">Addresses</h3>
<ul>{{range .Addrs}}<li><code>{{.}}</code></li>{{end}}</ul>
{{end}}
</div>
</body>
</html>`),
)
// GetHTMLReport implements sdk.CheckerHTMLReporter.
func (p *matrixProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var r MatrixFederationData
if err := json.Unmarshal(ctx.Data(), &r); err != nil {
return "", fmt.Errorf("failed to unmarshal matrix report: %w", err)
}
data := matrixTemplateData{
FederationOK: r.FederationOK,
WellKnownServer: r.WellKnownResult.Server,
WellKnownResult: r.WellKnownResult.Result,
SRVSkipped: r.DNSResult.SRVSkipped,
SRVCName: r.DNSResult.SRVCName,
Addrs: r.DNSResult.Addrs,
}
// Version
if r.Version.Name != "" || r.Version.Version != "" {
data.Version = strings.TrimSpace(r.Version.Name + " " + r.Version.Version)
}
data.VersionError = r.Version.Error
// SRV records
for _, s := range r.DNSResult.SRVRecords {
data.SRVRecords = append(data.SRVRecords, matrixSRVRecord{
Target: s.Target,
Port: s.Port,
Priority: s.Priority,
Weight: s.Weight,
})
}
// SRV error
if r.DNSResult.SRVError != nil {
data.SRVError = r.DNSResult.SRVError.Message
}
// Hosts
hostNames := make([]string, 0, len(r.DNSResult.Hosts))
for name := range r.DNSResult.Hosts {
hostNames = append(hostNames, name)
}
sort.Strings(hostNames)
for _, name := range hostNames {
h := r.DNSResult.Hosts[name]
data.Hosts = append(data.Hosts, matrixHostData{
Name: name,
CName: h.CName,
Addrs: h.Addrs,
})
}
// Successful connections
connAddrs := make([]string, 0, len(r.ConnectionReports))
for addr := range r.ConnectionReports {
connAddrs = append(connAddrs, addr)
}
sort.Strings(connAddrs)
for _, addr := range connAddrs {
cr := r.ConnectionReports[addr]
conn := matrixConnectionData{
Address: addr,
TLSVersion: cr.Cipher.Version,
CipherSuite: cr.Cipher.CipherSuite,
AllChecksOK: cr.Checks.AllChecksOK,
Errors: cr.Errors,
Open: !cr.Checks.AllChecksOK,
}
for _, cert := range cr.Certificates {
conn.Certs = append(conn.Certs, matrixCertData{
SubjectCommonName: cert.SubjectCommonName,
IssuerCommonName: cert.IssuerCommonName,
SHA256Fingerprint: cert.SHA256Fingerprint,
DNSNames: cert.DNSNames,
})
}
conn.CheckDetails = []matrixCheckItem{
{"Matching server name", cr.Checks.MatchingServerName},
{"Certificate valid until future", cr.Checks.FutureValidUntilTS},
{"Valid certificates", cr.Checks.ValidCertificates},
{"Has Ed25519 key", cr.Checks.HasEd25519Key},
{"All Ed25519 checks OK", cr.Checks.AllEd25519ChecksOK},
}
data.Connections = append(data.Connections, conn)
}
// Failed connections
errAddrs := make([]string, 0, len(r.ConnectionErrors))
for addr := range r.ConnectionErrors {
errAddrs = append(errAddrs, addr)
}
sort.Strings(errAddrs)
for _, addr := range errAddrs {
data.ConnectionErrors = append(data.ConnectionErrors, matrixConnErrData{
Address: addr,
Message: r.ConnectionErrors[addr].Message,
})
}
var buf strings.Builder
if err := matrixHTMLTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to render matrix HTML report: %w", err)
}
return buf.String(), nil
}