// Package turnsrv 是私聊通话 P3 的内嵌 TURN/STUN 服务(pion/turn/v5)。 // // 角色:私聊媒体面是客户端间 P2P(tgcalls/WebRTC),服务端只在 NAT 穿透失败时 // 充当中继。本包对外只暴露三件事:服务可达地址(写进 phoneCall.connections 的 // phoneConnectionWebrtc 条目)、按通话签发的临时凭据、运行开关。 // // 凭据采用 TURN REST 规范(draft-uberti-behave-turn-rest-00,coturn 同款): // username = ":<标识>",password = base64(HMAC-SHA1(secret, username))。 // 这使得未来切换到外部 coturn 时只需共享 secret,信令侧零改动。 package turnsrv import ( "crypto/rand" "encoding/hex" "fmt" "net" "time" "github.com/pion/logging" "github.com/pion/turn/v5" "go.uber.org/zap" ) // Config 是内嵌 TURN 的运行参数。 type Config struct { // UDPPort 是 TURN/STUN 监听端口(独立于 SFU 端口:两者都要独占消费各自 // socket 上的 STUN 流量,不能共用)。Windows 防火墙需放行。 UDPPort int // AdvertiseIP 是写进 phoneConnectionWebrtc 与 relay 分配地址的客户端可达 // 地址。⚠ 127.0.0.1 时真机拿到的 relay candidate 不可达(媒体面静默失败)。 AdvertiseIP string // Realm 是 TURN long-term credential 的 realm(任意稳定串即可)。 Realm string // SharedSecret 是 REST 凭据的 HMAC 密钥;为空则进程级随机生成 // (单实例自洽;多实例/外部 coturn 必须显式配置同一值)。 SharedSecret string // RelayMinPort/RelayMaxPort 限定 relay 分配的 UDP 端口段(防火墙放行范围)。 RelayMinPort int RelayMaxPort int // CredentialTTL 是签发凭据的有效期。⚠ 必须 ≥ 单次通话时长上限:libwebrtc // TurnPort 的 allocation refresh 复用初始凭据,凭据过期会让进行中的中继 // 通话断流(TDesktop 读码结论),缺省 24h。 CredentialTTL time.Duration Logger *zap.Logger } // Service 是信令层可见的 TURN 边界。 type Service interface { // Enabled 报告中继是否真实可用(false 时 connections 只能下发空列表, // 行为退回 P1 的 LAN 直连)。 Enabled() bool // Credentials 为一通通话签发临时凭据(user 任意标识,惯例用 callID)。 Credentials(user string) (username, password string, err error) // IP/Port 返回客户端可达的服务地址。 IP() string Port() int Close() error } // Disabled 返回未启用实现。 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 启动内嵌 TURN:绑定 UDP 端口、装配 REST 凭据校验与 relay 端口段。 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 为 127.0.0.1:真机拿到的 relay candidate 不可达(跨网媒体面静默失败),跨设备通话必须设为客户端可达 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 }