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>
This commit is contained in:
commit
fa516fdaad
23 changed files with 1869 additions and 0 deletions
211
checker/report.go
Normal file
211
checker/report.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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(raw json.RawMessage) (string, error) {
|
||||
var d StunTurnData
|
||||
if err := json.Unmarshal(raw, &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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue