checker-stun-turn/checker/report.go
Pierre-Olivier Mercier 7c7706fe3f 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.
2026-04-26 19:55:05 +07:00

400 lines
12 KiB
Go

package checker
import (
"encoding/json"
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type tmplHint struct {
StatusCSS string
StatusText string
Message string
Fix string
}
type tmplObservation struct {
Name string
Status string // "ok" | "fail" | "info" (for neutral data badge)
Detail string
Error string
}
type tmplEndpoint struct {
URI string
Transport string
Source string
Open bool
BadgeText string
BadgeCSS string
ResolvedIPs []string
Observations []tmplObservation
Hints []tmplHint
}
type tmplData struct {
Zone string
Mode string
OverallText string
OverallCSS string
HeadlineFix string
HeadlineDetail string
GlobalError string
GlobalHints []tmplHint // hints with no endpoint subject
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, .unknown { background: #e5e7eb; color: #374151; }
.fail { background: #fee2e2; color: #991b1b; }
.neutral { 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; margin-top: .4rem; }
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; }
.section-title { font-size: .78rem; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; margin: .6rem 0 .2rem; }
.hint { padding: .5rem .65rem; border-radius: 6px; margin-bottom: .35rem; background: #f9fafb; }
.hint-msg { font-size: .9rem; }
</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}}
{{if .GlobalHints}}
<div class="hd">
<h2>Global findings</h2>
{{range .GlobalHints}}
<div class="hint">
<span class="badge {{.StatusCSS}}">{{.StatusText}}</span>
<span class="hint-msg">{{.Message}}</span>
{{if .Fix}}<div class="fix"><strong>Fix:</strong> {{.Fix}}</div>{{end}}
</div>
{{end}}
</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>
{{if .ResolvedIPs}} &middot; Resolved: <code>{{range $i, $ip := .ResolvedIPs}}{{if $i}}, {{end}}{{$ip}}{{end}}</code>{{end}}
</p>
{{if .Hints}}
<div class="section-title">Findings</div>
{{range .Hints}}
<div class="hint">
<span class="badge {{.StatusCSS}}">{{.StatusText}}</span>
<span class="hint-msg">{{.Message}}</span>
{{if .Fix}}<div class="fix"><strong>Fix:</strong> {{.Fix}}</div>{{end}}
</div>
{{end}}
{{end}}
{{if .Observations}}
<div class="section-title">Observations</div>
<table>
<tr><th>Probe</th><th>Result</th><th>Detail</th></tr>
{{range .Observations}}
<tr>
<td><code>{{.Name}}</code></td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td>
{{if .Detail}}{{.Detail}}{{end}}
{{if .Error}}<div class="err">&#9888; {{.Error}}</div>{{end}}
</td>
</tr>
{{end}}
</table>
{{end}}
</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)
}
states := ctx.States()
// Group hints by endpoint subject.
type group struct {
worst sdk.Status
hints []tmplHint
first *tmplHint // first failing hint (for headline/open logic)
}
byEp := make(map[string]*group)
global := &group{worst: sdk.StatusOK}
for _, st := range states {
fix := ""
if st.Meta != nil {
if v, ok := st.Meta["fix"].(string); ok {
fix = v
}
}
css, txt := statusBadge(st.Status)
h := tmplHint{
StatusCSS: css,
StatusText: txt,
Message: st.Message,
Fix: fix,
}
var g *group
if st.Subject == "" {
g = global
} else {
g = byEp[st.Subject]
if g == nil {
g = &group{worst: sdk.StatusOK}
byEp[st.Subject] = g
}
}
g.hints = append(g.hints, h)
if statusSeverity(st.Status) > statusSeverity(g.worst) {
g.worst = st.Status
}
if g.first == nil && isFailing(st.Status) {
hh := h
g.first = &hh
}
}
td := tmplData{
Zone: d.Zone,
Mode: d.Mode,
GlobalError: d.GlobalError,
GlobalHints: global.hints,
}
worst := sdk.StatusOK
if statusSeverity(global.worst) > statusSeverity(worst) {
worst = global.worst
}
var headlineFix, headlineDetail string
if global.first != nil && global.first.Fix != "" {
headlineFix = global.first.Fix
headlineDetail = global.first.Message
}
for _, ep := range d.Endpoints {
subj := epSubject(ep.Endpoint)
g := byEp[subj]
te := tmplEndpoint{
URI: ep.Endpoint.URI,
Transport: string(ep.Endpoint.Transport),
Source: ep.Endpoint.Source,
ResolvedIPs: ep.ResolvedIPs,
Observations: observationsFor(ep),
}
epWorst := sdk.StatusOK
if g != nil {
te.Hints = g.hints
epWorst = g.worst
if statusSeverity(g.worst) > statusSeverity(worst) {
worst = g.worst
}
if headlineFix == "" && g.first != nil && g.first.Fix != "" {
headlineFix = g.first.Fix
headlineDetail = fmt.Sprintf("[%s] %s", ep.Endpoint.URI, g.first.Message)
}
}
te.BadgeCSS, te.BadgeText = statusBadge(epWorst)
te.Open = isFailing(epWorst)
td.Endpoints = append(td.Endpoints, te)
}
td.OverallCSS, td.OverallText = statusBadge(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
}
// observationsFor renders the raw probe observations for an endpoint,
// without any severity or fix guidance. This is the neutral, data-only
// view used both as the default and as a fallback when no CheckStates
// are available.
func observationsFor(ep EndpointProbe) []tmplObservation {
var out []tmplObservation
// Dial
dialName := fmt.Sprintf("dial:%s", ep.Endpoint.Transport)
if ep.Dial.OK {
detail := fmt.Sprintf("connected to %s in %d ms", ep.Dial.RemoteAddr, ep.Dial.DurationMs)
if ep.Dial.TLSVersion != "" {
detail += fmt.Sprintf("; TLS %s, %s, peer CN=%s", ep.Dial.TLSVersion, ep.Dial.TLSCipher, ep.Dial.TLSPeerCN)
}
if ep.Dial.DTLSHandshake {
detail += "; DTLS handshake completed"
}
out = append(out, tmplObservation{Name: dialName, Status: "ok", Detail: detail})
} else {
out = append(out, tmplObservation{Name: dialName, Status: "fail", Error: ep.Dial.Error})
return out
}
// STUN binding
if ep.STUNBinding.Attempted {
if ep.STUNBinding.OK {
detail := fmt.Sprintf("reflexive address: %s (RTT %d ms)", ep.STUNBinding.ReflexiveAddr, ep.STUNBinding.RTTMs)
if ep.STUNBinding.IsPrivateMapped {
detail += " [private]"
}
out = append(out, tmplObservation{Name: "stun_binding", Status: "ok", Detail: detail})
} else {
out = append(out, tmplObservation{Name: "stun_binding", Status: "fail", Error: ep.STUNBinding.Error})
}
}
// TURN no-auth probe
if ep.TURNNoAuth.Attempted {
switch {
case ep.TURNNoAuth.OK:
out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "ok", Detail: "allocation accepted without authentication"})
case ep.TURNNoAuth.UnauthChallenge:
out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "ok", Detail: "server challenged the unauthenticated allocate (401)"})
default:
out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "info", Detail: fmt.Sprintf("code=%d %s", ep.TURNNoAuth.ErrorCode, ep.TURNNoAuth.ErrorReason)})
}
}
// TURN authenticated allocate
if ep.TURNAuth.Attempted {
if ep.TURNAuth.OK {
detail := fmt.Sprintf("relay address: %s (%d ms)", ep.TURNAuth.RelayAddr, ep.TURNAuth.DurationMs)
if ep.TURNAuth.IsPrivateRelay {
detail += " [private]"
}
out = append(out, tmplObservation{Name: "turn_allocate_auth", Status: "ok", Detail: detail})
} else {
out = append(out, tmplObservation{
Name: "turn_allocate_auth",
Status: "fail",
Detail: fmt.Sprintf("STUN error code: %d", ep.TURNAuth.ErrorCode),
Error: ep.TURNAuth.Error,
})
}
}
// Relay echo
if ep.RelayEcho.Attempted {
if ep.RelayEcho.OK {
out = append(out, tmplObservation{Name: "turn_relay_echo", Status: "ok", Detail: fmt.Sprintf("CreatePermission + Send to %s succeeded", ep.RelayEcho.PeerAddr)})
} else {
out = append(out, tmplObservation{Name: "turn_relay_echo", Status: "fail", Error: ep.RelayEcho.Error})
}
}
if ep.ChannelBindRun {
out = append(out, tmplObservation{Name: "turn_channel_bind", Status: "info", Detail: "ChannelBind exercised implicitly by relay traffic"})
}
return out
}
func statusBadge(s sdk.Status) (cssClass, label string) {
switch s {
case sdk.StatusOK:
return "ok", "OK"
case sdk.StatusInfo:
return "info", "INFO"
case sdk.StatusWarn:
return "warn", "WARN"
case sdk.StatusCrit:
return "crit", "CRIT"
case sdk.StatusError:
return "error", "ERROR"
case sdk.StatusUnknown:
return "unknown", "UNKNOWN"
}
return "info", s.String()
}
func statusSeverity(s sdk.Status) int {
switch s {
case sdk.StatusOK:
return 0
case sdk.StatusUnknown:
return 1
case sdk.StatusInfo:
return 2
case sdk.StatusWarn:
return 3
case sdk.StatusCrit:
return 4
case sdk.StatusError:
return 5
}
return -1
}
func isFailing(s sdk.Status) bool {
return s == sdk.StatusWarn || s == sdk.StatusCrit || s == sdk.StatusError
}