checker-stun-turn/checker/report.go
Pierre-Olivier Mercier 7ff9f92305 Initial commit
Adds a happyDomain checker that probes STUN/TURN servers end-to-end:
DNS/SRV discovery, UDP/TCP/TLS/DTLS dial, STUN binding + reflexive-addr
sanity, open-relay detection, authenticated TURN Allocate (long-term
creds or REST-API HMAC), public-relay check, CreatePermission + Send
round-trip through the relay, and optional ChannelBind.

Failing sub-tests carry a remediation string (`Fix`) that the HTML
report surfaces as a yellow headline callout and inline next to each
row. Mapping covers the most common coturn misconfigurations
(external-ip, relay-ip, lt-cred-mech, min-port/max-port, cert issues,
401 nonce drift, 441/442/486/508 allocation errors).

Implements sdk.EndpointDiscoverer (checker/discovery.go): every
stuns:/turns:/DTLS endpoint observed during Collect is published as a
DiscoveredEndpoint{Type: "tls"|"dtls"} so a downstream TLS checker can
verify certificates without re-parsing the observation.

Backed by pion/stun/v3 + pion/turn/v4 + pion/dtls/v3; SDK pinned to a
local replace until the EndpointDiscoverer interface ships in a tagged
release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:35:43 +07:00

213 lines
6.8 KiB
Go

package checker
import (
"encoding/json"
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type tmplSubTest struct {
Name string
StatusCSS string
StatusText string
DurationMs int64
Detail string
Error string
Fix string
}
type tmplEndpoint struct {
URI string
Transport string
Source string
Open bool
BadgeText string
BadgeCSS string
SubTests []tmplSubTest
}
type tmplData struct {
Zone string
Mode string
OverallText string
OverallCSS string
HeadlineFix string
HeadlineDetail string
GlobalError string
Endpoints []tmplEndpoint
}
var stunturnTemplate = template.Must(template.New("stunturn").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>STUN/TURN Report</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; padding: 1rem; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 14px; color: #1f2937; background: #f3f4f6; line-height: 1.5; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; }
h2 { margin: 0 0 .4rem; font-size: 1rem; }
.hd { background: #fff; border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
.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; }
.info { background: #dbeafe; color: #1e3a8a; }
.warn { background: #fef3c7; color: #92400e; }
.crit { background: #fee2e2; color: #991b1b; }
.error { background: #fee2e2; color: #991b1b; }
.skipped { background: #e5e7eb; color: #374151; }
.headline-fix { background: #fef3c7; border-left: 4px solid #f59e0b; padding: .75rem 1rem; border-radius: 6px; margin-top: .6rem; }
.headline-fix strong { color: #78350f; }
.global-err { background: #fee2e2; border-left: 4px solid #dc2626; padding: .75rem 1rem; border-radius: 6px; }
details { background: #fff; border-radius: 8px; margin-bottom: .45rem; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden; }
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); }
.ep-uri { font-weight: 600; flex: 1; font-family: ui-monospace, monospace; font-size: .9rem; }
.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: .35rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
.fix { color: #92400e; font-size: .82rem; }
.err { color: #b91c1c; font-size: .82rem; }
.dur { color: #6b7280; font-size: .8rem; white-space: nowrap; }
</style>
</head>
<body>
<div class="hd">
<h1>STUN/TURN check</h1>
<span class="badge {{.OverallCSS}}">{{.OverallText}}</span>
<div class="meta">
{{if .Zone}}Zone: <code>{{.Zone}}</code> &middot; {{end}}
Mode: <code>{{.Mode}}</code> &middot;
{{len .Endpoints}} endpoint(s) probed
</div>
{{if .HeadlineFix}}
<div class="headline-fix">
<strong>How to fix:</strong> {{.HeadlineFix}}
{{if .HeadlineDetail}}<div class="err" style="margin-top:.3rem">{{.HeadlineDetail}}</div>{{end}}
</div>
{{end}}
</div>
{{if .GlobalError}}
<div class="hd"><div class="global-err"><strong>Discovery failed:</strong> {{.GlobalError}}</div></div>
{{end}}
{{range .Endpoints}}
<details{{if .Open}} open{{end}}>
<summary>
<span class="ep-uri">{{.URI}}</span>
<span class="badge {{.BadgeCSS}}">{{.BadgeText}}</span>
</summary>
<div class="body">
<p class="meta">Transport: <code>{{.Transport}}</code> &middot; Source: <code>{{.Source}}</code></p>
<table>
<tr><th>Test</th><th>Status</th><th>Duration</th><th>Detail</th></tr>
{{range .SubTests}}
<tr>
<td><code>{{.Name}}</code></td>
<td><span class="badge {{.StatusCSS}}">{{.StatusText}}</span></td>
<td class="dur">{{if .DurationMs}}{{.DurationMs}} ms{{end}}</td>
<td>
{{if .Detail}}{{.Detail}}{{end}}
{{if .Error}}<div class="err">&#9888; {{.Error}}</div>{{end}}
{{if .Fix}}<div class="fix"><strong>Fix:</strong> {{.Fix}}</div>{{end}}
</td>
</tr>
{{end}}
</table>
</div>
</details>
{{end}}
</body>
</html>`))
// GetHTMLReport implements sdk.CheckerHTMLReporter.
func (p *stunTurnProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var d StunTurnData
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
return "", fmt.Errorf("unmarshal stun/turn report: %w", err)
}
worst := SubTestOK
var headlineFix, headlineDetail string
td := tmplData{
Zone: d.Zone,
Mode: d.Mode,
GlobalError: d.GlobalError,
}
for _, ep := range d.Endpoints {
te := tmplEndpoint{
URI: ep.Endpoint.URI,
Transport: string(ep.Endpoint.Transport),
Source: ep.Endpoint.Source,
}
w := ep.Worst()
if statusRank(w) > statusRank(worst) {
worst = w
}
te.BadgeCSS, te.BadgeText = badge(w)
te.Open = w != SubTestOK && w != SubTestSkipped && w != SubTestInfo
if te.Open && headlineFix == "" {
if f := ep.FirstFailure(); f != nil && f.Fix != "" {
headlineFix = f.Fix
if f.Detail != "" {
headlineDetail = fmt.Sprintf("[%s] %s: %s", ep.Endpoint.URI, f.Name, f.Detail)
} else if f.Error != "" {
headlineDetail = fmt.Sprintf("[%s] %s: %s", ep.Endpoint.URI, f.Name, f.Error)
}
}
}
for _, st := range ep.SubTests {
css, txt := badge(st.Status)
te.SubTests = append(te.SubTests, tmplSubTest{
Name: st.Name,
StatusCSS: css,
StatusText: txt,
DurationMs: st.DurationMs,
Detail: st.Detail,
Error: st.Error,
Fix: st.Fix,
})
}
td.Endpoints = append(td.Endpoints, te)
}
td.OverallCSS, td.OverallText = badge(worst)
td.HeadlineFix = headlineFix
td.HeadlineDetail = headlineDetail
var buf strings.Builder
if err := stunturnTemplate.Execute(&buf, td); err != nil {
return "", fmt.Errorf("render stun/turn report: %w", err)
}
return buf.String(), nil
}
func badge(s SubTestStatus) (cssClass, label string) {
switch s {
case SubTestOK:
return "ok", "OK"
case SubTestInfo:
return "info", "INFO"
case SubTestWarn:
return "warn", "WARN"
case SubTestCrit:
return "crit", "CRIT"
case SubTestError:
return "error", "ERROR"
case SubTestSkipped:
return "skipped", "SKIPPED"
}
return "info", string(s)
}