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