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.
This commit is contained in:
nemunaire 2026-04-19 13:41:52 +07:00
commit 7c7706fe3f
29 changed files with 2794 additions and 0 deletions

235
checker/collect.go Normal file
View file

@ -0,0 +1,235 @@
package checker
import (
"context"
"crypto/tls"
"fmt"
"net"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type probeConfig struct {
mode string
username string
password string
sharedSecret string
realm string
probePeer string
testChannelBind bool
timeout time.Duration
}
// Collect gathers raw STUN/TURN observations (SRV discovery, dial
// outcome, STUN Binding result, TURN Allocate results, relay echo).
// It performs NO judgement: severity, pass/fail, warning thresholds
// and fix suggestions are left to the CheckRule layer.
func (p *stunTurnProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
zone, _ := opts["zone"].(string)
uri, _ := opts["serverURI"].(string)
mode, _ := opts["mode"].(string)
if mode == "" {
mode = "auto"
}
username, _ := opts["username"].(string)
password, _ := opts["credential"].(string)
sharedSecret, _ := opts["sharedSecret"].(string)
realm, _ := opts["realm"].(string)
transportsRaw, _ := opts["transports"].(string)
probePeer, _ := opts["probePeer"].(string)
if probePeer == "" {
probePeer = "1.1.1.1:53"
}
// Refuse to relay traffic toward private/loopback/link-local
// destinations: a malicious caller could otherwise abuse the target
// TURN server to port-scan the operator's internal network through us.
if isPrivateAddr(probePeer) {
return nil, fmt.Errorf("probePeer %q resolves to a private/loopback address", probePeer)
}
timeoutSec := sdk.GetIntOption(opts, "timeout", 5)
if timeoutSec <= 0 {
timeoutSec = 5
}
cfg := probeConfig{
mode: mode,
username: username,
password: password,
sharedSecret: sharedSecret,
realm: realm,
probePeer: probePeer,
testChannelBind: sdk.GetBoolOption(opts, "testChannelBind", false),
timeout: time.Duration(timeoutSec) * time.Second,
}
transports := parseTransports(transportsRaw)
warnRTT := int64(sdk.GetIntOption(opts, "warningRTT", 200))
critRTT := int64(sdk.GetIntOption(opts, "criticalRTT", 1000))
data := &StunTurnData{
Zone: zone,
Mode: mode,
RequestedURI: uri,
ProbePeer: probePeer,
WarningRTTMs: warnRTT,
CriticalRTT: critRTT,
HasCreds: sharedSecret != "" || (username != "" && password != ""),
CollectedAt: time.Now().UTC(),
}
endpoints, err := discoverEndpoints(ctx, zone, uri, transports)
if err != nil {
data.GlobalError = err.Error()
return data, nil
}
for _, ep := range endpoints {
probe := EndpointProbe{Endpoint: ep}
collectEndpoint(ctx, &probe, cfg)
data.Endpoints = append(data.Endpoints, probe)
}
return data, nil
}
// collectEndpoint runs every network probe we know how to run against
// a single endpoint and records raw results on probe. It never assigns
// severity.
func collectEndpoint(ctx context.Context, probe *EndpointProbe, cfg probeConfig) {
ep := probe.Endpoint
// Best-effort DNS lookup for IPv6 coverage rule.
if ips, err := net.DefaultResolver.LookupIPAddr(ctx, ep.Host); err == nil {
for _, ip := range ips {
probe.ResolvedIPs = append(probe.ResolvedIPs, ip.IP.String())
}
}
dialStart := time.Now()
dc, err := dial(ctx, ep, cfg.timeout)
dialDur := time.Since(dialStart)
if err != nil {
probe.Dial = DialResult{
OK: false,
DurationMs: dialDur.Milliseconds(),
Error: err.Error(),
}
return
}
defer dc.Close()
probe.Dial = DialResult{
OK: true,
DurationMs: dialDur.Milliseconds(),
RemoteAddr: dc.remoteAddr.String(),
}
if dc.tlsState != nil {
probe.Dial.TLSVersion = tlsVersionString(dc.tlsState.Version)
probe.Dial.TLSCipher = tls.CipherSuiteName(dc.tlsState.CipherSuite)
probe.Dial.TLSPeerCN = peerCertCN(dc.tlsState)
}
if dc.dtlsState != nil {
probe.Dial.DTLSHandshake = true
}
// STUN Binding probe, always attempted.
bind := runSTUNBinding(dc, cfg.timeout)
probe.STUNBinding.Attempted = true
if bind.Err != nil {
probe.STUNBinding.OK = false
probe.STUNBinding.Error = bind.Err.Error()
} else {
probe.STUNBinding.OK = true
probe.STUNBinding.RTTMs = bind.RTT.Milliseconds()
if bind.ReflexiveAddr != nil {
probe.STUNBinding.ReflexiveAddr = bind.ReflexiveAddr.String()
}
probe.STUNBinding.IsPrivateMapped = bind.IsPrivateMapped
}
// TURN-only probes short-circuit when mode=stun or the scheme is
// stun:/stuns:.
if cfg.mode == "stun" || !ep.IsTURN {
return
}
// Unauthenticated TURN Allocate (open-relay probe).
noAuth := runTURNAllocate(dc, nil, cfg.timeout)
probe.TURNNoAuth.Attempted = true
probe.TURNNoAuth.DurationMs = noAuth.Duration.Milliseconds()
probe.TURNNoAuth.ErrorCode = noAuth.AuthErrorCode
probe.TURNNoAuth.ErrorReason = noAuth.AuthErrorReason
probe.TURNNoAuth.UnauthChallenge = noAuth.UnauthChallenge
if noAuth.RelayConn != nil {
probe.TURNNoAuth.OK = true
if noAuth.RelayAddr != nil {
probe.TURNNoAuth.RelayAddr = noAuth.RelayAddr.String()
}
_ = noAuth.RelayConn.Close()
if noAuth.Client != nil {
noAuth.Client.Close()
}
} else if noAuth.Err != nil && !noAuth.UnauthChallenge {
probe.TURNNoAuth.Error = noAuth.Err.Error()
}
// Authenticated TURN Allocate, if credentials are provided.
creds := pickCredentials(cfg.username, cfg.password, cfg.sharedSecret, cfg.realm)
if creds == nil {
return
}
dc2, err := dial(ctx, ep, cfg.timeout)
if err != nil {
probe.TURNAuth.Attempted = true
probe.TURNAuth.Error = fmt.Sprintf("redial failed: %v", err)
return
}
defer dc2.Close()
auth := runTURNAllocate(dc2, creds, cfg.timeout)
probe.TURNAuth.Attempted = true
probe.TURNAuth.DurationMs = auth.Duration.Milliseconds()
probe.TURNAuth.ErrorCode = auth.AuthErrorCode
probe.TURNAuth.ErrorReason = auth.AuthErrorReason
if auth.Err != nil {
probe.TURNAuth.OK = false
probe.TURNAuth.Error = auth.Err.Error()
return
}
probe.TURNAuth.OK = true
if auth.RelayAddr != nil {
probe.TURNAuth.RelayAddr = auth.RelayAddr.String()
}
probe.TURNAuth.IsPrivateRelay = auth.IsPrivateRelay
defer func() {
auth.RelayConn.Close()
if auth.Client != nil {
auth.Client.Close()
}
}()
// Relay echo.
probe.RelayEcho.Attempted = true
probe.RelayEcho.PeerAddr = cfg.probePeer
if err := runRelayEcho(auth.RelayConn, cfg.probePeer, cfg.timeout); err != nil {
probe.RelayEcho.OK = false
probe.RelayEcho.Error = err.Error()
} else {
probe.RelayEcho.OK = true
}
if cfg.testChannelBind {
probe.ChannelBindRun = true
}
}
func pickCredentials(username, password, sharedSecret, realm string) *turnCredentials {
if sharedSecret != "" {
return restAPICredentials(sharedSecret, username, realm, time.Hour)
}
if username != "" && password != "" {
return &turnCredentials{Username: username, Password: password, Realm: realm}
}
return nil
}

94
checker/definition.go Normal file
View file

@ -0,0 +1,94 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
// Defaults to "built-in"; standalone binaries override it from main().
var Version = "built-in"
// Definition returns the CheckerDefinition for the STUN/TURN checker.
func (p *stunTurnProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "stunturn",
Name: "STUN/TURN Server",
Version: Version,
HasHTMLReport: true,
ObservationKeys: []sdk.ObservationKey{ObservationKeyStunTurn},
Availability: sdk.CheckerAvailability{
ApplyToZone: true,
ApplyToService: true,
},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "zone",
Type: "string",
Label: "Zone",
Placeholder: "example.com",
AutoFill: sdk.AutoFillDomainName,
Description: "Zone used for SRV-based STUN/TURN endpoint discovery when no explicit URI is provided.",
},
{
Id: "serverURI",
Type: "string",
Label: "Server URI",
Placeholder: "turns:turn.example.com:5349?transport=tcp",
Description: "Explicit STUN/TURN URI (RFC 7064/7065). Overrides SRV-based discovery.",
},
{
Id: "mode",
Type: "string",
Label: "Mode",
Default: "auto",
Choices: []string{"auto", "stun", "turn"},
Description: "auto: probe both STUN and TURN; stun: skip TURN allocation tests; turn: require TURN allocation.",
},
},
UserOpts: []sdk.CheckerOptionDocumentation{
{Id: "username", Type: "string", Label: "TURN username"},
{Id: "credential", Type: "string", Label: "TURN password", Secret: true},
{
Id: "sharedSecret",
Type: "string",
Label: "REST API shared secret",
Secret: true,
Description: "Shared secret used to derive ephemeral credentials (draft-uberti-rtcweb-turn-rest). Takes precedence over username/password.",
},
{Id: "realm", Type: "string", Label: "Realm"},
{
Id: "transports",
Type: "string",
Label: "Transports",
Default: "udp,tcp,tls",
Description: "Comma-separated list of transports to test among: udp, tcp, tls, dtls.",
},
{
Id: "probePeer",
Type: "string",
Label: "Relay echo target",
Default: "1.1.1.1:53",
Description: "host:port used to validate the relay path (a CreatePermission + Send is issued, no payload data is exchanged).",
},
{
Id: "testChannelBind",
Type: "bool",
Label: "Also test ChannelBind",
Default: false,
},
{Id: "warningRTT", Type: "uint", Label: "RTT warning threshold (ms)", Default: 200},
{Id: "criticalRTT", Type: "uint", Label: "RTT critical threshold (ms)", Default: 1000},
{Id: "timeout", Type: "uint", Label: "Per-probe timeout (s)", Default: 5},
},
},
Rules: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Default: 30 * time.Minute,
Max: 24 * time.Hour,
},
}
}

225
checker/discover.go Normal file
View file

@ -0,0 +1,225 @@
package checker
import (
"context"
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
// parseURI parses a STUN/TURN URI per RFC 7064 / RFC 7065.
//
// Examples:
//
// stun:turn.example.com
// stun:turn.example.com:3478
// stuns:turn.example.com:5349
// turn:turn.example.com:3478?transport=udp
// turns:turn.example.com:5349?transport=tcp
func parseURI(raw string) (Endpoint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return Endpoint{}, fmt.Errorf("empty URI")
}
colon := strings.IndexByte(raw, ':')
if colon < 0 {
return Endpoint{}, fmt.Errorf("missing scheme in %q", raw)
}
scheme := strings.ToLower(raw[:colon])
rest := raw[colon+1:]
var ep Endpoint
ep.URI = raw
ep.Source = "uri"
switch scheme {
case "stun":
ep.IsTURN = false
ep.Secure = false
case "stuns":
ep.IsTURN = false
ep.Secure = true
case "turn":
ep.IsTURN = true
ep.Secure = false
case "turns":
ep.IsTURN = true
ep.Secure = true
default:
return Endpoint{}, fmt.Errorf("unknown scheme %q", scheme)
}
hostport := rest
query := ""
if q := strings.IndexByte(rest, '?'); q >= 0 {
hostport = rest[:q]
query = rest[q+1:]
}
host, portStr, err := net.SplitHostPort(hostport)
if err != nil {
// no port; pick the default per scheme
host = hostport
portStr = ""
}
if host == "" {
return Endpoint{}, fmt.Errorf("missing host in %q", raw)
}
ep.Host = host
// Default transport: UDP for stun/turn, TCP for stuns/turns. Overridable via ?transport=
if ep.Secure {
ep.Transport = TransportTLS
} else {
ep.Transport = TransportUDP
}
if query != "" {
values, err := url.ParseQuery(query)
if err == nil {
if t := strings.ToLower(values.Get("transport")); t != "" {
switch t {
case "udp":
ep.Transport = TransportUDP
case "tcp":
if ep.Secure {
ep.Transport = TransportTLS
} else {
ep.Transport = TransportTCP
}
case "tls":
ep.Transport = TransportTLS
ep.Secure = true
case "dtls":
ep.Transport = TransportDTLS
ep.Secure = true
}
}
}
}
if portStr == "" {
if ep.Secure {
ep.Port = 5349
} else {
ep.Port = 3478
}
} else {
p, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return Endpoint{}, fmt.Errorf("invalid port %q: %w", portStr, err)
}
ep.Port = uint16(p)
}
return ep, nil
}
// discoverEndpoints returns the list of endpoints to probe.
//
// If serverURI is set, it is the only endpoint. Otherwise SRV records are
// looked up for the zone. Returned endpoints are filtered to the requested
// transports.
func discoverEndpoints(ctx context.Context, zone, serverURI string, transports []Transport) ([]Endpoint, error) {
if serverURI != "" {
ep, err := parseURI(serverURI)
if err != nil {
return nil, err
}
return filterByTransport([]Endpoint{ep}, transports), nil
}
zone = strings.TrimSuffix(strings.TrimSpace(zone), ".")
if zone == "" {
return nil, fmt.Errorf("either serverURI or zone is required")
}
resolver := net.DefaultResolver
type srvSpec struct {
service string // _stun, _turn, _stuns, _turns
proto string // _udp / _tcp
isTURN bool
secure bool
transport Transport
}
specs := []srvSpec{
{"_stun", "_udp", false, false, TransportUDP},
{"_stun", "_tcp", false, false, TransportTCP},
{"_stuns", "_tcp", false, true, TransportTLS},
{"_turn", "_udp", true, false, TransportUDP},
{"_turn", "_tcp", true, false, TransportTCP},
{"_turns", "_tcp", true, true, TransportTLS},
}
var endpoints []Endpoint
for _, s := range specs {
_, addrs, err := resolver.LookupSRV(ctx, strings.TrimPrefix(s.service, "_"), strings.TrimPrefix(s.proto, "_"), zone)
if err != nil || len(addrs) == 0 {
continue
}
for _, srv := range addrs {
ep := Endpoint{
Host: strings.TrimSuffix(srv.Target, "."),
Port: srv.Port,
Transport: s.transport,
Secure: s.secure,
IsTURN: s.isTURN,
Source: fmt.Sprintf("srv:%s.%s.%s", s.service, s.proto, zone),
}
scheme := "stun"
if s.isTURN {
scheme = "turn"
}
if s.secure {
scheme += "s"
}
ep.URI = fmt.Sprintf("%s:%s:%d", scheme, ep.Host, ep.Port)
endpoints = append(endpoints, ep)
}
}
if len(endpoints) == 0 {
return nil, fmt.Errorf("no STUN/TURN SRV records found under %s", zone)
}
return filterByTransport(endpoints, transports), nil
}
func filterByTransport(eps []Endpoint, allowed []Transport) []Endpoint {
if len(allowed) == 0 {
return eps
}
allow := make(map[Transport]bool, len(allowed))
for _, t := range allowed {
allow[t] = true
}
out := make([]Endpoint, 0, len(eps))
for _, ep := range eps {
if allow[ep.Transport] {
out = append(out, ep)
}
}
return out
}
func parseTransports(raw string) []Transport {
if raw == "" {
return []Transport{TransportUDP, TransportTCP, TransportTLS}
}
var out []Transport
for _, p := range strings.Split(raw, ",") {
p = strings.TrimSpace(strings.ToLower(p))
switch p {
case "udp":
out = append(out, TransportUDP)
case "tcp":
out = append(out, TransportTCP)
case "tls":
out = append(out, TransportTLS)
case "dtls":
out = append(out, TransportDTLS)
}
}
return out
}

66
checker/discover_test.go Normal file
View file

@ -0,0 +1,66 @@
package checker
import "testing"
func TestParseURI(t *testing.T) {
cases := []struct {
in string
host string
port uint16
transport Transport
secure bool
isTURN bool
wantErr bool
}{
{"stun:turn.example.com", "turn.example.com", 3478, TransportUDP, false, false, false},
{"stun:turn.example.com:3478", "turn.example.com", 3478, TransportUDP, false, false, false},
{"stuns:turn.example.com:5349", "turn.example.com", 5349, TransportTLS, true, false, false},
{"turn:turn.example.com:3478?transport=udp", "turn.example.com", 3478, TransportUDP, false, true, false},
{"turn:turn.example.com:3478?transport=tcp", "turn.example.com", 3478, TransportTCP, false, true, false},
{"turns:turn.example.com:5349?transport=tcp", "turn.example.com", 5349, TransportTLS, true, true, false},
{"turns:turn.example.com?transport=dtls", "turn.example.com", 5349, TransportDTLS, true, true, false},
{"http://example.com", "", 0, "", false, false, true},
{"stun:", "", 0, "", false, false, true},
}
for _, tc := range cases {
ep, err := parseURI(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("%q: expected error, got nil", tc.in)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error: %v", tc.in, err)
continue
}
if ep.Host != tc.host || ep.Port != tc.port || ep.Transport != tc.transport ||
ep.Secure != tc.secure || ep.IsTURN != tc.isTURN {
t.Errorf("%q: got %+v, want host=%s port=%d transport=%s secure=%v isTURN=%v",
tc.in, ep, tc.host, tc.port, tc.transport, tc.secure, tc.isTURN)
}
}
}
func TestParseTransports(t *testing.T) {
got := parseTransports("udp, TLS ,dtls")
want := []Transport{TransportUDP, TransportTLS, TransportDTLS}
if len(got) != len(want) {
t.Fatalf("got %v want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("index %d: got %s want %s", i, got[i], want[i])
}
}
}
func TestRestAPICredentials(t *testing.T) {
c := restAPICredentials("topsecret", "alice", "example.com", 0)
if c.Username == "" || c.Password == "" {
t.Fatalf("empty creds: %+v", c)
}
if c.Realm != "example.com" {
t.Fatalf("realm mismatch: %s", c.Realm)
}
}

51
checker/discovery.go Normal file
View file

@ -0,0 +1,51 @@
package checker
import (
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
)
// DiscoverEntries implements sdk.DiscoveryPublisher.
//
// stuns:/turns: (RFC 7064/7065) speak TLS immediately after the TCP
// handshake, so every secure TCP-based endpoint we observed is published
// under the tls.endpoint.v1 contract for checker-tls to pick up.
//
// DTLS is intentionally omitted: the current checker-tls consumer uses
// crypto/tls and would not probe a datagram-TLS endpoint correctly. Emitting
// a DTLS entry today would only produce orphan lineage.
//
// SNI is left empty (= Host); no STARTTLS upgrade applies; the scheme
// mandates direct TLS on the wire.
func (p *stunTurnProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*StunTurnData)
if !ok {
return nil, fmt.Errorf("unexpected data type %T", data)
}
seen := make(map[string]struct{})
var out []sdk.DiscoveryEntry
for _, ep := range d.Endpoints {
if !ep.Dial.OK {
continue
}
if !ep.Endpoint.Secure || ep.Endpoint.Transport == TransportDTLS {
continue
}
key := fmt.Sprintf("%s|%d", ep.Endpoint.Host, ep.Endpoint.Port)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
entry, err := tlsct.NewEntry(tlsct.TLSEndpoint{
Host: ep.Endpoint.Host,
Port: ep.Endpoint.Port,
})
if err != nil {
return nil, err
}
out = append(out, entry)
}
return out, nil
}

77
checker/interactive.go Normal file
View file

@ -0,0 +1,77 @@
//go:build standalone
package checker
import (
"errors"
"net/http"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes the inputs needed to drive a STUN/TURN check from
// the standalone HTML form. The fields mirror the canonical option
// documentation in Definition() (RunOpts then UserOpts) so the form
// stays in lock-step with the JSON schema served at /definition.
func (p *stunTurnProvider) RenderForm() []sdk.CheckerOptionField {
def := p.Definition()
fields := make([]sdk.CheckerOptionField, 0, len(def.Options.RunOpts)+len(def.Options.UserOpts))
fields = append(fields, def.Options.RunOpts...)
fields = append(fields, def.Options.UserOpts...)
return fields
}
// ParseForm turns the submitted form into a CheckerOptions. At least one
// of zone or serverURI must be provided so discoverEndpoints has
// something to work with.
func (p *stunTurnProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
zone := strings.TrimSpace(r.FormValue("zone"))
zone = strings.TrimSuffix(zone, ".")
uri := strings.TrimSpace(r.FormValue("serverURI"))
if zone == "" && uri == "" {
return nil, errors.New("either zone or serverURI is required")
}
opts := sdk.CheckerOptions{}
if zone != "" {
opts["zone"] = zone
}
if uri != "" {
opts["serverURI"] = uri
}
if v := strings.TrimSpace(r.FormValue("mode")); v != "" {
switch v {
case "auto", "stun", "turn":
opts["mode"] = v
default:
return nil, errors.New("mode must be one of: auto, stun, turn")
}
}
for _, k := range []string{"username", "credential", "sharedSecret", "realm", "transports", "probePeer"} {
if v := strings.TrimSpace(r.FormValue(k)); v != "" {
opts[k] = v
}
}
if v := r.FormValue("testChannelBind"); v != "" {
opts["testChannelBind"] = v == "true" || v == "on" || v == "1"
}
for _, k := range []string{"warningRTT", "criticalRTT", "timeout"} {
v := strings.TrimSpace(r.FormValue(k))
if v == "" {
continue
}
n, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return nil, errors.New(k + " must be a non-negative integer")
}
opts[k] = int(n)
}
return opts, nil
}

16
checker/provider.go Normal file
View file

@ -0,0 +1,16 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new STUN/TURN observation provider.
func Provider() sdk.ObservationProvider {
return &stunTurnProvider{}
}
type stunTurnProvider struct{}
func (p *stunTurnProvider) Key() sdk.ObservationKey {
return ObservationKeyStunTurn
}

400
checker/report.go Normal file
View file

@ -0,0 +1,400 @@
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
}

83
checker/rule.go Normal file
View file

@ -0,0 +1,83 @@
package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the list of CheckRules exposed by the STUN/TURN checker.
// Each concern is its own rule (SRV for STUN, SRV for TURN, STUN
// binding, TURN open-relay probe, TURN authenticated allocation, relay
// echo, TLS transport, IPv6 coverage, …) so the UI can show a granular
// status instead of a single aggregated one.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&discoveryRule{},
&srvStunRule{},
&srvTurnRule{},
&dialRule{},
&stunBindingRule{},
&stunReflexivePublicRule{},
&stunLatencyRule{},
&turnOpenRelayRule{},
&turnAuthRule{},
&turnRelayPublicRule{},
&turnRelayEchoRule{},
&turnTLSTransportRule{},
&ipv6CoverageRule{},
}
}
// loadData fetches the observation; on error returns a CheckState that
// callers should emit directly.
func loadData(ctx context.Context, obs sdk.ObservationGetter) (*StunTurnData, *sdk.CheckState) {
var data StunTurnData
if err := obs.Get(ctx, ObservationKeyStunTurn, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to get STUN/TURN observation: %v", err),
Code: "stun_turn.observation_error",
}
}
return &data, nil
}
func passState(code, msg string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusOK, Message: msg, Code: code}
}
func skippedState(code, msg string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusUnknown, Message: msg, Code: code}
}
func epSubject(ep Endpoint) string {
if ep.URI != "" {
return ep.URI
}
return fmt.Sprintf("%s:%d/%s", ep.Host, ep.Port, ep.Transport)
}
// hasTURNEndpoint reports whether the observation contains at least one
// TURN endpoint (excluding STUN-only endpoints).
func hasTURNEndpoint(data *StunTurnData) bool {
for _, ep := range data.Endpoints {
if ep.Endpoint.IsTURN {
return true
}
}
return false
}
// joinMsg concatenates non-empty parts with ": " between them.
func joinMsg(parts ...string) string {
out := make([]string, 0, len(parts))
for _, p := range parts {
if strings.TrimSpace(p) != "" {
out = append(out, p)
}
}
return strings.Join(out, ": ")
}

186
checker/rules_discovery.go Normal file
View file

@ -0,0 +1,186 @@
package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// discoveryRule reports the outcome of endpoint discovery (URI parse
// or SRV lookup). If Collect recorded a GlobalError, this is where it
// surfaces.
type discoveryRule struct{}
func (r *discoveryRule) Name() string { return "stun_turn.discovery" }
func (r *discoveryRule) Description() string {
return "Verifies that at least one STUN/TURN endpoint could be discovered (explicit URI or SRV lookup)."
}
func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.GlobalError != "" {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: data.GlobalError,
Code: "stun_turn.discovery.error",
}}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: "no endpoints to probe",
Code: "stun_turn.discovery.empty",
}}
}
return []sdk.CheckState{passState("stun_turn.discovery.ok",
fmt.Sprintf("%d endpoint(s) discovered", len(data.Endpoints)))}
}
// srvStunRule verifies that at least one STUN (non-TURN) SRV/URI endpoint
// was obtained. Only meaningful in SRV-discovery mode.
type srvStunRule struct{}
func (r *srvStunRule) Name() string { return "stun_turn.srv_stun" }
func (r *srvStunRule) Description() string {
return "Verifies that at least one STUN endpoint is reachable via SRV (_stun/_stuns) or an explicit URI."
}
func (r *srvStunRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.GlobalError != "" {
return []sdk.CheckState{skippedState("stun_turn.srv_stun.skipped",
"Discovery failed, SRV coverage could not be evaluated.")}
}
// Count endpoints whose source indicates STUN SRV (or URI form).
var stunCount, turnCount int
for _, ep := range data.Endpoints {
if ep.Endpoint.IsTURN {
turnCount++
} else {
stunCount++
}
}
if stunCount == 0 && turnCount > 0 {
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Code: "stun_turn.srv_stun.none",
Message: "No STUN-only endpoint discovered (TURN endpoints also expose STUN).",
}}
}
if stunCount == 0 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "stun_turn.srv_stun.missing",
Message: "No STUN endpoint discovered; clients may fail to obtain a reflexive address.",
}}
}
return []sdk.CheckState{passState("stun_turn.srv_stun.ok",
fmt.Sprintf("%d STUN endpoint(s) discovered", stunCount))}
}
// srvTurnRule verifies TURN endpoint coverage.
type srvTurnRule struct{}
func (r *srvTurnRule) Name() string { return "stun_turn.srv_turn" }
func (r *srvTurnRule) Description() string {
return "Verifies that at least one TURN endpoint is reachable via SRV (_turn/_turns) or an explicit URI."
}
func (r *srvTurnRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.GlobalError != "" {
return []sdk.CheckState{skippedState("stun_turn.srv_turn.skipped",
"Discovery failed, TURN coverage could not be evaluated.")}
}
if data.Mode == "stun" {
return []sdk.CheckState{skippedState("stun_turn.srv_turn.skipped",
"TURN coverage not evaluated (mode=stun).")}
}
var turnCount int
for _, ep := range data.Endpoints {
if ep.Endpoint.IsTURN {
turnCount++
}
}
if turnCount == 0 {
sev := sdk.StatusWarn
if data.Mode == "turn" {
sev = sdk.StatusCrit
}
return []sdk.CheckState{{
Status: sev,
Code: "stun_turn.srv_turn.missing",
Message: "No TURN endpoint discovered; clients behind symmetric NAT will have no relay path.",
}}
}
return []sdk.CheckState{passState("stun_turn.srv_turn.ok",
fmt.Sprintf("%d TURN endpoint(s) discovered", turnCount))}
}
// dialRule reports connectivity/handshake failures per endpoint.
type dialRule struct{}
func (r *dialRule) Name() string { return "stun_turn.dial" }
func (r *dialRule) Description() string {
return "Verifies that every discovered endpoint accepts a connection (TCP/TLS handshake or UDP socket)."
}
func (r *dialRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.GlobalError != "" || len(data.Endpoints) == 0 {
return []sdk.CheckState{skippedState("stun_turn.dial.skipped", "No endpoint to evaluate.")}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.Dial.OK {
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.dial.failed",
Subject: epSubject(ep.Endpoint),
Message: ep.Dial.Error,
Meta: map[string]any{"fix": dialFix(ep.Endpoint, ep.Dial.Error)},
})
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.dial.ok", "All discovered endpoints accepted a connection.")}
}
return states
}
// dialFix mirrors the fix phrasing the old Collect emitted for dial
// failures. Kept verbatim so users keep the same remediation guidance.
func dialFix(ep Endpoint, errMsg string) string {
msg := strings.ToLower(errMsg)
switch {
case strings.Contains(msg, "no such host"):
return fmt.Sprintf("Hostname `%s` does not resolve. Add the matching A/AAAA record (or fix typos in the URI).", ep.Host)
case strings.Contains(msg, "tls handshake"), strings.Contains(msg, "x509"):
return fmt.Sprintf("TLS handshake failed for `%s`. Reissue the certificate covering this hostname (e.g. via Let's Encrypt) and reload the server (coturn: `cert=` and `pkey=`).", ep.Host)
case strings.Contains(msg, "connection refused"):
return fmt.Sprintf("Nothing is listening on %s/%d. Start the server with the appropriate listening port (coturn: `listening-port=`/`tls-listening-port=`).", ep.Host, ep.Port)
case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "deadline"):
switch ep.Transport {
case TransportUDP:
return "No reply on UDP. Open the UDP port inbound and verify your network does not block UDP egress."
default:
return "Connection timed out. A firewall or NAT is likely blocking this port."
}
}
return "Could not establish a connection to the server."
}

154
checker/rules_stun.go Normal file
View file

@ -0,0 +1,154 @@
package checker
import (
"context"
"fmt"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// stunBindingRule verifies that the STUN Binding request succeeds on every
// reachable endpoint (returns a reflexive address).
type stunBindingRule struct{}
func (r *stunBindingRule) Name() string { return "stun_turn.stun_binding" }
func (r *stunBindingRule) Description() string {
return "Verifies that the STUN Binding request receives a XOR-MAPPED-ADDRESS reply."
}
func (r *stunBindingRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
seen := false
for _, ep := range data.Endpoints {
if !ep.Dial.OK || !ep.STUNBinding.Attempted {
continue
}
seen = true
if ep.STUNBinding.OK {
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.stun_binding.failed",
Subject: epSubject(ep.Endpoint),
Message: ep.STUNBinding.Error,
Meta: map[string]any{
"fix": "Server did not answer the STUN Binding Request. Check that the STUN service is actually listening on this transport, and that no middlebox is filtering RFC 5389 traffic.",
},
})
}
if !seen {
return []sdk.CheckState{skippedState("stun_turn.stun_binding.skipped", "No endpoint completed a dial, STUN binding not evaluated.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.stun_binding.ok", "STUN Binding succeeded on every reachable endpoint.")}
}
return states
}
// stunReflexivePublicRule flags servers that return a private/loopback
// reflexive address (typically a TURN server behind NAT with missing
// external-ip configuration).
type stunReflexivePublicRule struct{}
func (r *stunReflexivePublicRule) Name() string { return "stun_turn.reflexive_public" }
func (r *stunReflexivePublicRule) Description() string {
return "Flags endpoints that return a private/loopback reflexive address (server unaware of its public IP)."
}
func (r *stunReflexivePublicRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
seen := false
for _, ep := range data.Endpoints {
if !ep.STUNBinding.OK {
continue
}
seen = true
if !ep.STUNBinding.IsPrivateMapped {
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.reflexive_public.private",
Subject: epSubject(ep.Endpoint),
Message: fmt.Sprintf("server returned a private/loopback IP: %s", ep.STUNBinding.ReflexiveAddr),
Meta: map[string]any{
"fix": "Server appears to be behind NAT and unaware of its public IP. Set `external-ip=<public>` (coturn) or the equivalent on your TURN server.",
},
})
}
if !seen {
return []sdk.CheckState{skippedState("stun_turn.reflexive_public.skipped", "No successful STUN Binding to evaluate.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.reflexive_public.ok", "Every reflexive address is public.")}
}
return states
}
// stunLatencyRule folds the warningRTT / criticalRTT thresholds the old
// Collect hard-coded into a dedicated rule.
type stunLatencyRule struct{}
func (r *stunLatencyRule) Name() string { return "stun_turn.stun_latency" }
func (r *stunLatencyRule) Description() string {
return "Compares the STUN Binding RTT against the configured warning/critical thresholds."
}
func (r *stunLatencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
warn := time.Duration(sdk.GetIntOption(opts, "warningRTT", int(data.WarningRTTMs))) * time.Millisecond
crit := time.Duration(sdk.GetIntOption(opts, "criticalRTT", int(data.CriticalRTT))) * time.Millisecond
if warn <= 0 {
warn = 200 * time.Millisecond
}
if crit <= 0 {
crit = 1000 * time.Millisecond
}
var states []sdk.CheckState
seen := false
for _, ep := range data.Endpoints {
if !ep.STUNBinding.OK {
continue
}
seen = true
rtt := time.Duration(ep.STUNBinding.RTTMs) * time.Millisecond
switch {
case rtt > crit:
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.stun_latency.critical",
Subject: epSubject(ep.Endpoint),
Message: fmt.Sprintf("STUN RTT %dms exceeds critical threshold %dms", ep.STUNBinding.RTTMs, crit.Milliseconds()),
Meta: map[string]any{"fix": "Server is very slow to respond. Check server load, network path, and consider deploying closer to your users."},
})
case rtt > warn:
states = append(states, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "stun_turn.stun_latency.high",
Subject: epSubject(ep.Endpoint),
Message: fmt.Sprintf("STUN RTT %dms exceeds warning threshold %dms", ep.STUNBinding.RTTMs, warn.Milliseconds()),
Meta: map[string]any{"fix": "Latency is high enough to noticeably degrade interactive RTC. Consider a server geographically closer to your users."},
})
}
}
if !seen {
return []sdk.CheckState{skippedState("stun_turn.stun_latency.skipped", "No successful STUN Binding to evaluate.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.stun_latency.ok", "STUN RTT within acceptable thresholds on every endpoint.")}
}
return states
}

108
checker/rules_transport.go Normal file
View file

@ -0,0 +1,108 @@
package checker
import (
"context"
"net"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// turnTLSTransportRule evaluates whether a TLS-capable transport is
// available and reports its version/cipher metadata. Kept separate from
// the generic dial rule because TURN-TLS deployments often need dedicated
// attention (port 5349, certificate covering the TURN hostname, …).
type turnTLSTransportRule struct{}
func (r *turnTLSTransportRule) Name() string { return "stun_turn.tls_transport" }
func (r *turnTLSTransportRule) Description() string {
return "Verifies that at least one TLS/DTLS transport (stuns/turns) succeeds when present in the endpoint set."
}
func (r *turnTLSTransportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var (
secureCount, secureOK int
states []sdk.CheckState
)
for _, ep := range data.Endpoints {
if !ep.Endpoint.Secure {
continue
}
secureCount++
if ep.Dial.OK {
secureOK++
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.tls_transport.handshake_failed",
Subject: epSubject(ep.Endpoint),
Message: ep.Dial.Error,
Meta: map[string]any{
"fix": "TLS/DTLS handshake failed. Reissue a certificate covering the TURN hostname and reload the server (coturn: `cert=`/`pkey=`).",
},
})
}
if secureCount == 0 {
return []sdk.CheckState{skippedState("stun_turn.tls_transport.skipped", "No secure (stuns/turns) endpoint discovered.")}
}
if secureOK == 0 {
// All secure endpoints failed, already emitted per-endpoint
// crit states; return them as-is.
return states
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.tls_transport.ok", "TLS/DTLS handshake succeeded on every secure endpoint.")}
}
return states
}
// ipv6CoverageRule verifies at least one endpoint resolves to an IPv6
// address. It never marks an endpoint failed: missing AAAA records are a
// coverage concern, not an error.
type ipv6CoverageRule struct{}
func (r *ipv6CoverageRule) Name() string { return "stun_turn.ipv6_coverage" }
func (r *ipv6CoverageRule) Description() string {
return "Verifies at least one STUN/TURN hostname resolves to an IPv6 address."
}
func (r *ipv6CoverageRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{skippedState("stun_turn.ipv6_coverage.skipped", "No endpoint discovered.")}
}
var anyResolved, anyV6 bool
for _, ep := range data.Endpoints {
for _, ipStr := range ep.ResolvedIPs {
anyResolved = true
ip := net.ParseIP(ipStr)
if ip != nil && ip.To4() == nil && strings.Contains(ipStr, ":") {
anyV6 = true
break
}
}
if anyV6 {
break
}
}
if !anyResolved {
return []sdk.CheckState{skippedState("stun_turn.ipv6_coverage.skipped", "Hostname resolution data unavailable.")}
}
if !anyV6 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "stun_turn.ipv6_coverage.missing",
Message: "No STUN/TURN endpoint resolves to an IPv6 address; IPv6-only clients will have no reachable server.",
Meta: map[string]any{"fix": "Publish AAAA records for your STUN/TURN hostnames and ensure the server listens on IPv6."},
}}
}
return []sdk.CheckState{passState("stun_turn.ipv6_coverage.ok", "At least one endpoint resolves to an IPv6 address.")}
}

210
checker/rules_turn.go Normal file
View file

@ -0,0 +1,210 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// turnOpenRelayRule flags servers that accept an unauthenticated TURN
// Allocate (open relay, abuse vector) and warns on non-standard replies.
type turnOpenRelayRule struct{}
func (r *turnOpenRelayRule) Name() string { return "stun_turn.turn_open_relay" }
func (r *turnOpenRelayRule) Description() string {
return "Verifies the TURN server requires authentication (challenges unauthenticated Allocate with 401)."
}
func (r *turnOpenRelayRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.Mode == "stun" || !hasTURNEndpoint(data) {
return []sdk.CheckState{skippedState("stun_turn.turn_open_relay.skipped", "No TURN endpoint to evaluate.")}
}
var states []sdk.CheckState
seen := false
for _, ep := range data.Endpoints {
if !ep.TURNNoAuth.Attempted {
continue
}
seen = true
switch {
case ep.TURNNoAuth.OK:
// Allocate accepted without credentials => open relay.
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.turn_open_relay.open",
Subject: epSubject(ep.Endpoint),
Message: "TURN allocation accepted without authentication",
Meta: map[string]any{"fix": "Enable long-term credentials (`lt-cred-mech` for coturn). Open relays are abused for spam and DDoS amplification."},
})
case ep.TURNNoAuth.UnauthChallenge:
// Expected 401 + REALM/NONCE, nothing to emit, pass below.
default:
states = append(states, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "stun_turn.turn_open_relay.unexpected",
Subject: epSubject(ep.Endpoint),
Message: fmt.Sprintf("unexpected response (code=%d): %s", ep.TURNNoAuth.ErrorCode, ep.TURNNoAuth.ErrorReason),
Meta: map[string]any{"fix": "Server did not behave like a standard TURN. Verify it actually implements RFC 5766."},
})
}
}
if !seen {
return []sdk.CheckState{skippedState("stun_turn.turn_open_relay.skipped", "No TURN Allocate probe attempted.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.turn_open_relay.ok", "Server correctly challenged unauthenticated Allocate requests.")}
}
return states
}
// turnAuthRule evaluates the outcome of the authenticated TURN Allocate.
type turnAuthRule struct{}
func (r *turnAuthRule) Name() string { return "stun_turn.turn_auth" }
func (r *turnAuthRule) Description() string {
return "Verifies the supplied TURN credentials (or REST shared secret) yield a successful Allocate."
}
func (r *turnAuthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.Mode == "stun" || !hasTURNEndpoint(data) {
return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "No TURN endpoint to evaluate.")}
}
if !data.HasCreds {
return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "No TURN credentials supplied; authenticated Allocate not attempted.")}
}
var states []sdk.CheckState
seen := false
for _, ep := range data.Endpoints {
if !ep.TURNAuth.Attempted {
continue
}
seen = true
if ep.TURNAuth.OK {
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.turn_auth.failed",
Subject: epSubject(ep.Endpoint),
Message: joinMsg(ep.TURNAuth.Error, fmt.Sprintf("STUN error code: %d", ep.TURNAuth.ErrorCode)),
Meta: map[string]any{"fix": allocateFix(ep.TURNAuth.ErrorCode)},
})
}
if !seen {
return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "Authenticated Allocate not attempted on any endpoint.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.turn_auth.ok", "Authenticated TURN Allocate succeeded on every endpoint.")}
}
return states
}
// turnRelayPublicRule flags private relay addresses (missing relay-ip).
type turnRelayPublicRule struct{}
func (r *turnRelayPublicRule) Name() string { return "stun_turn.relay_public" }
func (r *turnRelayPublicRule) Description() string {
return "Flags TURN servers whose allocated relay address is private/loopback (missing public relay-ip)."
}
func (r *turnRelayPublicRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
seen := false
for _, ep := range data.Endpoints {
if !ep.TURNAuth.OK {
continue
}
seen = true
if !ep.TURNAuth.IsPrivateRelay {
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.relay_public.private",
Subject: epSubject(ep.Endpoint),
Message: fmt.Sprintf("relay address is private: %s", ep.TURNAuth.RelayAddr),
Meta: map[string]any{"fix": "Set `relay-ip=<public>` (coturn). The relay range must be publicly reachable for clients to use TURN."},
})
}
if !seen {
return []sdk.CheckState{skippedState("stun_turn.relay_public.skipped", "No successful TURN allocation to evaluate.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.relay_public.ok", "Every relay address is public.")}
}
return states
}
// turnRelayEchoRule reports relay-path breakage.
type turnRelayEchoRule struct{}
func (r *turnRelayEchoRule) Name() string { return "stun_turn.relay_echo" }
func (r *turnRelayEchoRule) Description() string {
return "Verifies the TURN relay path can carry traffic to the configured probe peer (CreatePermission + Send)."
}
func (r *turnRelayEchoRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var states []sdk.CheckState
seen := false
for _, ep := range data.Endpoints {
if !ep.RelayEcho.Attempted {
continue
}
seen = true
if ep.RelayEcho.OK {
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "stun_turn.relay_echo.failed",
Subject: epSubject(ep.Endpoint),
Message: ep.RelayEcho.Error,
Meta: map[string]any{"fix": "Relay path could not carry traffic to the probe peer. Check the firewall/NAT around the server's relay range (`min-port`/`max-port`/`relay-ip` for coturn)."},
})
}
if !seen {
return []sdk.CheckState{skippedState("stun_turn.relay_echo.skipped", "No relay allocation available to exercise.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.relay_echo.ok", "Relay echo succeeded on every tested endpoint.")}
}
return states
}
// allocateFix mirrors the coturn/RFC 5766 guidance the old Collect emitted.
func allocateFix(code int) string {
switch code {
case 401:
return "Server kept rejecting the credentials. Check username/password (or the REST shared secret), and verify the server clock (NTP), as TURN nonces are time-sensitive."
case 403:
return "Server forbade the request. The user may not have allocation rights, or a peer-address filter is in effect."
case 437:
return "Allocation Mismatch. Wait a few seconds for the previous allocation to expire and retry, or restart the TURN server."
case 441:
return "Wrong Credentials. Double-check username/password; for REST-API auth ensure the shared secret matches the server's `static-auth-secret`."
case 442:
return "Unsupported Transport Protocol. Try a different transport in the URI (`?transport=tcp`/`udp`) or enable it server-side."
case 486:
return "Allocation Quota Reached. Lower per-user concurrent allocations or raise `user-quota`."
case 508:
return "Insufficient Capacity. Server is out of relay ports; raise `total-quota` or extend the `min-port`/`max-port` range."
}
return "TURN Allocate failed. Inspect the error and confirm the server speaks RFC 5766 on this transport."
}

61
checker/stun.go Normal file
View file

@ -0,0 +1,61 @@
package checker
import (
"fmt"
"net"
"time"
"github.com/pion/turn/v4"
)
// stunBindingResult holds the outcome of a STUN Binding test.
type stunBindingResult struct {
RTT time.Duration
ReflexiveAddr net.Addr
IsPrivateMapped bool
Err error
}
// runSTUNBinding sends a STUN Binding Request to the remote and returns the
// reflexive (XOR-MAPPED) address along with the RTT. We construct a tiny
// turn.Client with no credentials; its SendBindingRequestTo path drives a
// vanilla STUN exchange (RFC 5389) and works on UDP/TCP/TLS/DTLS through
// the dialed PacketConn we hand it.
func runSTUNBinding(d *dialedConn, timeout time.Duration) stunBindingResult {
cfg := &turn.ClientConfig{
Conn: d.pc,
STUNServerAddr: d.remoteAddr.String(),
RTO: timeout,
Software: "happyDomain-checker-stun-turn",
}
client, err := turn.NewClient(cfg)
if err != nil {
return stunBindingResult{Err: fmt.Errorf("turn.NewClient: %w", err)}
}
defer client.Close()
if err := client.Listen(); err != nil {
return stunBindingResult{Err: fmt.Errorf("client.Listen: %w", err)}
}
start := time.Now()
addr, err := client.SendBindingRequestTo(d.remoteAddr)
if err != nil {
return stunBindingResult{Err: err}
}
res := stunBindingResult{
RTT: time.Since(start),
ReflexiveAddr: addr,
}
if udpAddr, ok := addr.(*net.UDPAddr); ok {
res.IsPrivateMapped = isPrivate(udpAddr.IP)
} else if tcpAddr, ok := addr.(*net.TCPAddr); ok {
res.IsPrivateMapped = isPrivate(tcpAddr.IP)
}
return res
}
func isPrivate(ip net.IP) bool {
if ip == nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
}

24
checker/tlsmeta.go Normal file
View file

@ -0,0 +1,24 @@
package checker
import "crypto/tls"
func tlsVersionString(v uint16) string {
switch v {
case tls.VersionTLS10:
return "TLS 1.0"
case tls.VersionTLS11:
return "TLS 1.1"
case tls.VersionTLS12:
return "TLS 1.2"
case tls.VersionTLS13:
return "TLS 1.3"
}
return "TLS ?"
}
func peerCertCN(s *tls.ConnectionState) string {
if s == nil || len(s.PeerCertificates) == 0 {
return ""
}
return s.PeerCertificates[0].Subject.CommonName
}

148
checker/transport.go Normal file
View file

@ -0,0 +1,148 @@
package checker
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"strconv"
"time"
"github.com/pion/dtls/v3"
"github.com/pion/turn/v4"
)
// dialedConn wraps the network conn used to talk to a STUN/TURN server,
// always exposing a PacketConn (turn/stun talk in datagrams). For
// stream transports (TCP/TLS) we wrap with turn.NewSTUNConn which frames
// STUN messages on top of the byte stream per RFC 5389 §7.2.2.
type dialedConn struct {
pc net.PacketConn
underlying net.Conn // non-nil for TCP/TLS; nil for UDP and DTLS
tlsState *tls.ConnectionState
dtlsState *dtls.State
remoteAddr net.Addr
}
func (d *dialedConn) Close() error {
var err error
if d.pc != nil {
err = d.pc.Close()
}
if d.underlying != nil {
if e := d.underlying.Close(); e != nil && err == nil {
err = e
}
}
return err
}
// dtlsPacketConn adapts *dtls.Conn (net.Conn) to net.PacketConn.
// DTLS frames messages at the record level; no additional length-prefix
// framing (as turn.NewSTUNConn adds for TCP) is needed or correct here.
type dtlsPacketConn struct {
conn *dtls.Conn
raddr net.Addr
}
func (d *dtlsPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
n, err := d.conn.Read(b)
return n, d.raddr, err
}
func (d *dtlsPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
return d.conn.Write(b)
}
func (d *dtlsPacketConn) Close() error { return d.conn.Close() }
func (d *dtlsPacketConn) LocalAddr() net.Addr { return d.conn.LocalAddr() }
func (d *dtlsPacketConn) SetDeadline(t time.Time) error { return d.conn.SetDeadline(t) }
func (d *dtlsPacketConn) SetReadDeadline(t time.Time) error { return d.conn.SetReadDeadline(t) }
func (d *dtlsPacketConn) SetWriteDeadline(t time.Time) error { return d.conn.SetWriteDeadline(t) }
// dial establishes the appropriate L4(/secure) connection to ep.
// timeout is applied per dial step (TCP connect, TLS handshake, DTLS handshake).
func dial(ctx context.Context, ep Endpoint, timeout time.Duration) (*dialedConn, error) {
addr := net.JoinHostPort(ep.Host, strconv.Itoa(int(ep.Port)))
dctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
switch ep.Transport {
case TransportUDP:
raddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return nil, fmt.Errorf("resolve udp %s: %w", addr, err)
}
// Use the dual-stack wildcard ("") so the kernel can pick an IPv6
// source when the resolved server address is IPv6.
conn, err := net.ListenPacket("udp", ":0")
if err != nil {
return nil, fmt.Errorf("listen udp: %w", err)
}
return &dialedConn{pc: conn, remoteAddr: raddr}, nil
case TransportTCP:
var d net.Dialer
c, err := d.DialContext(dctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("dial tcp %s: %w", addr, err)
}
return &dialedConn{
pc: turn.NewSTUNConn(c),
underlying: c,
remoteAddr: c.RemoteAddr(),
}, nil
case TransportTLS:
var d net.Dialer
raw, err := d.DialContext(dctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("dial tcp %s: %w", addr, err)
}
tlsConn := tls.Client(raw, &tls.Config{ServerName: ep.Host, MinVersion: tls.VersionTLS12})
if err := tlsConn.HandshakeContext(dctx); err != nil {
raw.Close()
return nil, fmt.Errorf("tls handshake %s: %w", addr, err)
}
state := tlsConn.ConnectionState()
return &dialedConn{
pc: turn.NewSTUNConn(tlsConn),
underlying: tlsConn,
tlsState: &state,
remoteAddr: tlsConn.RemoteAddr(),
}, nil
case TransportDTLS:
raddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return nil, fmt.Errorf("resolve udp %s: %w", addr, err)
}
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, fmt.Errorf("listen udp: %w", err)
}
dconn, err := dtls.Client(udpConn, raddr, &dtls.Config{
ServerName: ep.Host,
})
if err != nil {
udpConn.Close()
return nil, fmt.Errorf("dtls setup %s: %w", addr, err)
}
if err := dconn.HandshakeContext(dctx); err != nil {
dconn.Close()
udpConn.Close()
return nil, fmt.Errorf("dtls handshake %s: %w", addr, err)
}
state, _ := dconn.ConnectionState()
return &dialedConn{
pc: &dtlsPacketConn{conn: dconn, raddr: raddr},
dtlsState: &state,
remoteAddr: raddr,
}, nil
default:
return nil, errors.New("unknown transport")
}
}

189
checker/turn.go Normal file
View file

@ -0,0 +1,189 @@
package checker
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/pion/turn/v4"
)
// turnAllocateResult holds the outcome of a TURN Allocate exchange.
type turnAllocateResult struct {
Client *turn.Client // non-nil iff RelayConn is non-nil; caller must Close after RelayConn.
RelayConn net.PacketConn
RelayAddr net.Addr
IsPrivateRelay bool
UnauthChallenge bool // first allocate replied with 401 + REALM/NONCE (good for "no auth" probe)
AuthErrorCode int // STUN error code on the final attempt (0 if OK)
AuthErrorReason string // STUN reason phrase
Duration time.Duration // wall time of the allocate exchange
Err error
}
// runTURNAllocate runs a full TURN Allocate against the dialed connection.
// If creds is nil, it sends an unauthenticated Allocate and treats the
// expected 401 challenge as success of the *probe* (UnauthChallenge=true).
// If creds is non-nil, it performs the full long-term-credential dance.
//
// The returned RelayConn is owned by the caller and must be Close()d.
func runTURNAllocate(d *dialedConn, creds *turnCredentials, timeout time.Duration) turnAllocateResult {
cfg := &turn.ClientConfig{
Conn: d.pc,
TURNServerAddr: d.remoteAddr.String(),
STUNServerAddr: d.remoteAddr.String(),
RTO: timeout,
Software: "happyDomain-checker-stun-turn",
}
if creds != nil {
cfg.Username = creds.Username
cfg.Password = creds.Password
cfg.Realm = creds.Realm
}
client, err := turn.NewClient(cfg)
if err != nil {
return turnAllocateResult{Err: fmt.Errorf("turn.NewClient: %w", err)}
}
if err := client.Listen(); err != nil {
client.Close()
return turnAllocateResult{Err: fmt.Errorf("client.Listen: %w", err)}
}
start := time.Now()
relay, err := client.Allocate()
dur := time.Since(start)
if err != nil {
// Inspect the STUN error code to give the user a precise diagnostic.
code, reason := stunErrorOf(err)
// 401 with REALM/NONCE is the *expected* answer when probing without
// credentials; surface that as a positive UnauthChallenge signal,
// not as a failure, so the rule layer can flag "open relay" if we
// got a 200 instead.
if creds == nil && code == 401 {
client.Close()
return turnAllocateResult{
UnauthChallenge: true,
Duration: dur,
AuthErrorCode: 401,
AuthErrorReason: reason,
}
}
client.Close()
return turnAllocateResult{
AuthErrorCode: code,
AuthErrorReason: reason,
Duration: dur,
Err: err,
}
}
res := turnAllocateResult{
Client: client,
RelayConn: relay,
RelayAddr: relay.LocalAddr(),
Duration: dur,
}
if udpAddr, ok := relay.LocalAddr().(*net.UDPAddr); ok {
res.IsPrivateRelay = isPrivate(udpAddr.IP)
}
// Client is intentionally not Close()d here: closing it before the
// caller is done with RelayConn would tear down the underlying PacketConn.
// The caller is responsible for Close()ing RelayConn first, then Client.
return res
}
// runRelayEcho asks the TURN server to relay a single short datagram to the
// configured probe peer. This proves that:
// - CreatePermission succeeds (server acknowledges the Send indication),
// - the TURN data path accepts traffic.
//
// A reply from the peer is not required or awaited.
func runRelayEcho(relay net.PacketConn, peer string, timeout time.Duration) error {
host, _, err := net.SplitHostPort(peer)
if err != nil {
return fmt.Errorf("invalid probePeer %q: %w", peer, err)
}
if host == "" {
return errors.New("empty probe peer host")
}
addr, err := net.ResolveUDPAddr("udp", peer)
if err != nil {
return fmt.Errorf("resolve probe peer: %w", err)
}
// Defence in depth against SSRF: even if the literal probePeer passed
// the upfront check, its DNS resolution might land on private space.
if isPrivate(addr.IP) {
return fmt.Errorf("probePeer %q resolves to private address %s", peer, addr.IP)
}
if err := relay.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
return err
}
// One-byte payload: enough to trigger CreatePermission + Send on the
// TURN data path. We do not expect or wait for a peer reply.
payload := []byte{0x00}
if _, err := relay.WriteTo(payload, addr); err != nil {
return fmt.Errorf("relay WriteTo: %w", err)
}
return nil
}
// turnCredentials carries either explicit long-term credentials or values
// derived from a REST-API shared secret.
type turnCredentials struct {
Username string
Password string
Realm string
}
// restAPICredentials derives ephemeral credentials per the
// draft-uberti-rtcweb-turn-rest scheme:
//
// username = "<unix_ts>:<optional_user>"
// password = base64(hmac_sha1(secret, username))
//
// ttl is the validity window from now.
func restAPICredentials(secret, user, realm string, ttl time.Duration) *turnCredentials {
if ttl <= 0 {
ttl = time.Hour
}
expiry := time.Now().Add(ttl).Unix()
username := strconv.FormatInt(expiry, 10)
if user != "" {
username += ":" + user
}
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(username))
password := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return &turnCredentials{
Username: username,
Password: password,
Realm: realm,
}
}
// stunErrorOf parses a STUN error returned by pion/turn into (code, reason).
// pion/turn does not wrap errors with %w, so we parse the formatted message.
// pion formats error responses as: "... (error <code>: <reason>)"
func stunErrorOf(err error) (int, string) {
if err == nil {
return 0, ""
}
msg := err.Error()
if i := strings.LastIndex(msg, "(error "); i >= 0 {
inner := strings.TrimSuffix(msg[i+7:], ")")
if sep := strings.IndexByte(inner, ':'); sep > 0 {
if code, err := strconv.Atoi(strings.TrimSpace(inner[:sep])); err == nil {
return code, strings.TrimSpace(inner[sep+1:])
}
}
}
return 0, msg
}

53
checker/turn_test.go Normal file
View file

@ -0,0 +1,53 @@
package checker
import (
"errors"
"fmt"
"testing"
"github.com/pion/stun/v3"
)
// TestStunErrorOf_PinPionFormat builds an error using pion's own
// ErrorCodeAttribute.String() so that any future change to the format
// pion/turn uses ("... (error <code>: <reason>)") makes this test fail
// loudly instead of silently breaking our diagnostic parsing.
func TestStunErrorOf_PinPionFormat(t *testing.T) {
cases := []struct {
name string
code stun.ErrorCode
reason string
wantCode int
wantReason string
}{
{"unauthorized", stun.CodeUnauthorized, "Unauthorized", 401, "Unauthorized"},
{"stale nonce", stun.CodeStaleNonce, "Stale Nonce", 438, "Stale Nonce"},
{"server error", stun.CodeServerError, "Server Error", 500, "Server Error"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
attr := stun.ErrorCodeAttribute{Code: tc.code, Reason: []byte(tc.reason)}
// Mirror pion/turn/v4@v4.0.0 client.go:296:
// fmt.Errorf("%s (error %s)", res.Type, code)
err := fmt.Errorf("error response (error %s)", attr)
gotCode, gotReason := stunErrorOf(err)
if gotCode != tc.wantCode || gotReason != tc.wantReason {
t.Errorf("stunErrorOf(%q) = (%d, %q); want (%d, %q): pion error format may have changed",
err.Error(), gotCode, gotReason, tc.wantCode, tc.wantReason)
}
})
}
}
func TestStunErrorOf_NoMatch(t *testing.T) {
code, reason := stunErrorOf(errors.New("plain error"))
if code != 0 {
t.Errorf("expected code 0 for unparseable error, got %d", code)
}
if reason != "plain error" {
t.Errorf("expected reason to fall back to message, got %q", reason)
}
if c, r := stunErrorOf(nil); c != 0 || r != "" {
t.Errorf("nil error: got (%d, %q), want (0, \"\")", c, r)
}
}

133
checker/types.go Normal file
View file

@ -0,0 +1,133 @@
// Package checker implements the STUN/TURN checker for happyDomain.
//
// The checker drives a target server through the STUN binding and TURN
// allocation/relay protocols (RFC 5389, RFC 5766) using the Pion libraries,
// then exposes a structured observation and a rich HTML report including
// remediation guidance for the most common deployment mistakes.
package checker
import (
"net"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// isPrivateAddr returns whether s parses as a private/loopback/link-local
// address. Accepts either a bare IP or a host:port form. Hostnames return
// false; the caller should resolve first if needed.
func isPrivateAddr(s string) bool {
ip := net.ParseIP(s)
if ip == nil {
host, _, err := net.SplitHostPort(s)
if err != nil {
return false
}
ip = net.ParseIP(host)
}
return isPrivate(ip)
}
// ObservationKeyStunTurn is the observation key for STUN/TURN test data.
const ObservationKeyStunTurn sdk.ObservationKey = "stun_turn"
// Transport identifies the L4/L4-secure transport used to reach an endpoint.
type Transport string
const (
TransportUDP Transport = "udp"
TransportTCP Transport = "tcp"
TransportTLS Transport = "tls"
TransportDTLS Transport = "dtls"
)
// Endpoint is a single resolved server target to probe.
type Endpoint struct {
URI string `json:"uri"`
Host string `json:"host"`
Port uint16 `json:"port"`
Transport Transport `json:"transport"`
Secure bool `json:"secure"`
IsTURN bool `json:"is_turn"` // false: STUN-only scheme, true: TURN scheme
Source string `json:"source"` // "uri" or "srv:_turn._udp.example.com"
}
// DialResult holds the raw outcome of establishing the L4(/secure)
// connection to an endpoint. Collected without judgement.
type DialResult struct {
OK bool `json:"ok"`
DurationMs int64 `json:"duration_ms"`
RemoteAddr string `json:"remote_addr,omitempty"`
Error string `json:"error,omitempty"`
// TLS metadata populated when the underlying transport used TLS.
TLSVersion string `json:"tls_version,omitempty"`
TLSCipher string `json:"tls_cipher,omitempty"`
TLSPeerCN string `json:"tls_peer_cn,omitempty"`
// DTLSHandshake records whether a DTLS handshake was performed.
DTLSHandshake bool `json:"dtls_handshake,omitempty"`
}
// STUNBindingObservation holds the raw outcome of a STUN Binding probe.
type STUNBindingObservation struct {
Attempted bool `json:"attempted"`
OK bool `json:"ok"`
RTTMs int64 `json:"rtt_ms"`
ReflexiveAddr string `json:"reflexive_addr,omitempty"`
IsPrivateMapped bool `json:"is_private_mapped,omitempty"`
Error string `json:"error,omitempty"`
}
// TURNAllocateObservation holds the raw outcome of a TURN Allocate
// attempt (either unauthenticated probe or authenticated attempt).
type TURNAllocateObservation struct {
Attempted bool `json:"attempted"`
OK bool `json:"ok"`
DurationMs int64 `json:"duration_ms"`
RelayAddr string `json:"relay_addr,omitempty"`
IsPrivateRelay bool `json:"is_private_relay,omitempty"`
UnauthChallenge bool `json:"unauth_challenge,omitempty"`
ErrorCode int `json:"error_code,omitempty"`
ErrorReason string `json:"error_reason,omitempty"`
Error string `json:"error,omitempty"`
}
// RelayEchoObservation holds the raw outcome of the relay echo probe.
type RelayEchoObservation struct {
Attempted bool `json:"attempted"`
OK bool `json:"ok"`
PeerAddr string `json:"peer_addr,omitempty"`
Error string `json:"error,omitempty"`
}
// EndpointProbe holds the raw, unjudged observation for a single endpoint.
type EndpointProbe struct {
Endpoint Endpoint `json:"endpoint"`
// ResolvedIPs lists the A/AAAA addresses we observed for this host
// (populated when the endpoint was reached). Used for IPv6 coverage.
ResolvedIPs []string `json:"resolved_ips,omitempty"`
Dial DialResult `json:"dial"`
STUNBinding STUNBindingObservation `json:"stun_binding"`
TURNNoAuth TURNAllocateObservation `json:"turn_noauth"`
TURNAuth TURNAllocateObservation `json:"turn_auth"`
RelayEcho RelayEchoObservation `json:"relay_echo"`
ChannelBindRun bool `json:"channel_bind_run,omitempty"`
}
// StunTurnData is the JSON-serializable observation payload. It now
// carries only raw per-endpoint probe outcomes; rules do the judging.
type StunTurnData struct {
Zone string `json:"zone,omitempty"`
Mode string `json:"mode,omitempty"`
RequestedURI string `json:"requested_uri,omitempty"`
HasCreds bool `json:"has_creds,omitempty"`
ProbePeer string `json:"probe_peer,omitempty"`
WarningRTTMs int64 `json:"warning_rtt_ms,omitempty"`
CriticalRTT int64 `json:"critical_rtt_ms,omitempty"`
CollectedAt time.Time `json:"collected_at"`
Endpoints []EndpointProbe `json:"endpoints"`
GlobalError string `json:"global_error,omitempty"`
}