checker-stun-turn/checker/turn.go
Pierre-Olivier Mercier 7c7706fe3f 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.
2026-04-26 19:55:05 +07:00

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
}