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.
400 lines
12 KiB
Go
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> · {{end}}
|
|
Mode: <code>{{.Mode}}</code> ·
|
|
{{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> · Source: <code>{{.Source}}</code>
|
|
{{if .ResolvedIPs}} · 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">⚠ {{.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
|
|
}
|