// Package turnsrv is the embedded TURN/STUN service for private-call P3 // (pion/turn/v5). // // Role: the private-call media plane is client-to-client P2P (tgcalls/WebRTC); the // server acts as a relay only when NAT traversal fails. This package exposes just // three things externally: the reachable service address (written into the // phoneConnectionWebrtc entries of phoneCall.connections), the temporary // credentials issued per call, and the on/off switch. // // Credentials follow the TURN REST spec (draft-uberti-behave-turn-rest-00, same as // coturn): username = ":", password = // base64(HMAC-SHA1(secret, username)). This means switching to an external coturn // in the future only requires sharing the secret, with zero changes on the // signaling side. package turnsrv import ( "crypto/rand" "encoding/hex" "fmt" "net" "time" "github.com/pion/logging" "github.com/pion/turn/v5" "go.uber.org/zap" ) // Config is the operating parameters of the embedded TURN. type Config struct { // UDPPort is the TURN/STUN listening port (independent of the SFU port: both // must exclusively consume the STUN traffic on their own socket and cannot // share one). The Windows firewall must allow it. UDPPort int // AdvertiseIP is the client-reachable address written into // phoneConnectionWebrtc and used for relay allocations. ⚠ With 127.0.0.1 the // relay candidate a real device gets is unreachable (the media plane fails // silently). AdvertiseIP string // Realm is the realm of the TURN long-term credential (any stable string works). Realm string // SharedSecret is the HMAC key for REST credentials; when empty it is generated // randomly at process level (self-consistent for a single instance; multiple // instances / an external coturn must explicitly configure the same value). SharedSecret string // RelayMinPort/RelayMaxPort bound the UDP port range for relay allocations (the // firewall's allowed range). RelayMinPort int RelayMaxPort int // CredentialTTL is the validity period of issued credentials. ⚠ It must be >= // the maximum duration of a single call: libwebrtc TurnPort's allocation // refresh reuses the initial credentials, so an expired credential cuts off an // in-progress relayed call (conclusion from reading the TDesktop code); // default 24h. CredentialTTL time.Duration Logger *zap.Logger } // Service is the TURN boundary visible to the signaling layer. type Service interface { // Enabled reports whether relaying is actually available (when false, // connections can only deliver an empty list and behavior falls back to P1's // LAN direct connection). Enabled() bool // Credentials issues temporary credentials for one call (user is any identity; // the convention is callID). Credentials(user string) (username, password string, err error) // IP/Port return the client-reachable service address. IP() string Port() int Close() error } // Disabled returns the not-enabled implementation. func Disabled() Service { return disabled{} } type disabled struct{} func (disabled) Enabled() bool { return false } func (disabled) Credentials(string) (string, string, error) { return "", "", fmt.Errorf("turnsrv: disabled") } func (disabled) IP() string { return "" } func (disabled) Port() int { return 0 } func (disabled) Close() error { return nil } type pionTURN struct { cfg Config server *turn.Server } // New starts the embedded TURN: binds the UDP port and wires up REST credential // verification and the relay port range. func New(cfg Config) (Service, error) { if cfg.UDPPort <= 0 { return nil, fmt.Errorf("turnsrv: invalid udp port %d", cfg.UDPPort) } if cfg.AdvertiseIP == "" { cfg.AdvertiseIP = "127.0.0.1" } if cfg.Realm == "" { cfg.Realm = "telesrv" } if cfg.SharedSecret == "" { secret, err := randomSecret() if err != nil { return nil, err } cfg.SharedSecret = secret } if cfg.RelayMinPort <= 0 || cfg.RelayMaxPort < cfg.RelayMinPort { return nil, fmt.Errorf("turnsrv: invalid relay port range [%d,%d]", cfg.RelayMinPort, cfg.RelayMaxPort) } if cfg.CredentialTTL <= 0 { cfg.CredentialTTL = 24 * time.Hour } if cfg.Logger == nil { cfg.Logger = zap.NewNop() } relayIP := net.ParseIP(cfg.AdvertiseIP) if relayIP == nil { return nil, fmt.Errorf("turnsrv: invalid advertise ip %q", cfg.AdvertiseIP) } conn, err := net.ListenPacket("udp4", fmt.Sprintf("0.0.0.0:%d", cfg.UDPPort)) if err != nil { return nil, fmt.Errorf("turnsrv: listen udp %d: %w", cfg.UDPPort, err) } pionLog := logging.NewDefaultLoggerFactory() server, err := turn.NewServer(turn.ServerConfig{ Realm: cfg.Realm, LoggerFactory: pionLog, AuthHandler: turn.LongTermTURNRESTAuthHandler(cfg.SharedSecret, pionLog.NewLogger("turn-auth")), PacketConnConfigs: []turn.PacketConnConfig{{ PacketConn: conn, RelayAddressGenerator: &turn.RelayAddressGeneratorPortRange{ RelayAddress: relayIP, MinPort: uint16(cfg.RelayMinPort), MaxPort: uint16(cfg.RelayMaxPort), Address: "0.0.0.0", }, }}, }) if err != nil { _ = conn.Close() return nil, fmt.Errorf("turnsrv: new server: %w", err) } cfg.Logger.Info("turn listening", zap.Int("udp_port", cfg.UDPPort), zap.String("advertise_ip", cfg.AdvertiseIP), zap.Int("relay_min_port", cfg.RelayMinPort), zap.Int("relay_max_port", cfg.RelayMaxPort)) if cfg.AdvertiseIP == "127.0.0.1" { cfg.Logger.Warn("TELESRV_TURN_ADVERTISE_IP is 127.0.0.1: the relay candidate a real device gets is unreachable (the cross-network media plane fails silently); cross-device calls must set it to a client-reachable IP") } return &pionTURN{cfg: cfg, server: server}, nil } func (t *pionTURN) Enabled() bool { return true } func (t *pionTURN) Credentials(user string) (string, string, error) { username, password, err := turn.GenerateLongTermTURNRESTCredentials(t.cfg.SharedSecret, user, t.cfg.CredentialTTL) if err != nil { return "", "", fmt.Errorf("turnsrv: generate credentials: %w", err) } return username, password, nil } func (t *pionTURN) IP() string { return t.cfg.AdvertiseIP } func (t *pionTURN) Port() int { return t.cfg.UDPPort } func (t *pionTURN) Close() error { return t.server.Close() } func randomSecret() (string, error) { buf := make([]byte, 24) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("turnsrv: random secret: %w", err) } return hex.EncodeToString(buf), nil }