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.
225 lines
5.1 KiB
Go
225 lines
5.1 KiB
Go
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
|
|
}
|