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 = ":" // 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 : )" 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 }