Initial commit
Adds a happyDomain checker that probes STUN/TURN servers end-to-end:
DNS/SRV discovery, UDP/TCP/TLS/DTLS dial, STUN binding + reflexive-addr
sanity, open-relay detection, authenticated TURN Allocate (long-term
creds or REST-API HMAC), public-relay check, CreatePermission + Send
round-trip through the relay, and optional ChannelBind.
Failing sub-tests carry a remediation string (`Fix`) that the HTML
report surfaces as a yellow headline callout and inline next to each
row. Mapping covers the most common coturn misconfigurations
(external-ip, relay-ip, lt-cred-mech, min-port/max-port, cert issues,
401 nonce drift, 441/442/486/508 allocation errors).
Implements sdk.EndpointDiscoverer (checker/discovery.go): every
stuns:/turns:/DTLS endpoint observed during Collect is published as a
DiscoveredEndpoint{Type: "tls"|"dtls"} so a downstream TLS checker can
verify certificates without re-parsing the observation.
Backed by pion/stun/v3 + pion/turn/v4 + pion/dtls/v3; SDK pinned to a
local replace until the EndpointDiscoverer interface ships in a tagged
release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c413e87f04
23 changed files with 1869 additions and 0 deletions
180
checker/turn.go
Normal file
180
checker/turn.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/turn/v4"
|
||||
)
|
||||
|
||||
// turnAllocateResult holds the outcome of a TURN Allocate exchange.
|
||||
type turnAllocateResult struct {
|
||||
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{
|
||||
RelayConn: relay,
|
||||
RelayAddr: relay.LocalAddr(),
|
||||
Duration: dur,
|
||||
}
|
||||
if udpAddr, ok := relay.LocalAddr().(*net.UDPAddr); ok {
|
||||
res.IsPrivateRelay = isPrivate(udpAddr.IP)
|
||||
}
|
||||
// We intentionally do not Close() the client here so that the relay
|
||||
// PacketConn stays usable; the caller closes both via RelayConn.Close().
|
||||
return res
|
||||
}
|
||||
|
||||
// runRelayEcho asks the TURN server to relay a single short datagram to the
|
||||
// configured probe peer. This proves that:
|
||||
// - the relay address is reachable from the public internet,
|
||||
// - CreatePermission succeeds,
|
||||
// - the data path through the server actually carries traffic.
|
||||
//
|
||||
// We do not require an answer from the peer (many peers like 1.1.1.1:53
|
||||
// will silently drop a malformed DNS query); a successful WriteTo plus an
|
||||
// implicit CreatePermission acknowledged by the server is enough.
|
||||
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)
|
||||
}
|
||||
if err := relay.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Single-byte DNS-shaped prefix; enough to trigger CreatePermission + Send.
|
||||
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 := fmt.Sprintf("%d", expiry)
|
||||
if user != "" {
|
||||
username = fmt.Sprintf("%d:%s", expiry, 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 wraps the server's ERROR-CODE attribute in the error message; we
|
||||
// extract it heuristically while degrading gracefully on format changes.
|
||||
func stunErrorOf(err error) (int, string) {
|
||||
if err == nil {
|
||||
return 0, ""
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, code := range []int{400, 401, 403, 420, 437, 438, 441, 442, 443, 486, 500, 508} {
|
||||
needle := fmt.Sprintf("%d", code)
|
||||
if strings.Contains(msg, needle) {
|
||||
return code, msg
|
||||
}
|
||||
}
|
||||
return 0, msg
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue