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.
189 lines
5.8 KiB
Go
189 lines
5.8 KiB
Go
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
|
|
}
|