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>
213 lines
6.8 KiB
Go
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> · {{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}}
|
|
|
|
{{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></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">⚠ {{.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)
|
|
}
|