Vue中WebRTC集成Open Relay TURN服务器失败求助
WebRTC跨网连接问题排查求助
我用Vue+WebRTC开发了单向直播应用,仅从推流端向接收端传输视频帧,用Firestore做信令服务器交换SDP和ICE候选,分为发起Offer的CompOffer(推流端)和接收流的CompAnswer(应答端)。
本地网络运行正常,但部署到云服务器后,跨不同网络的设备无法连接。两台设备都处于对称NAT之后,我集成了免费TURN服务器,在两端RTCPeerConnection里配置了turn_config,但跨网测试失败:接收端收不到视频,不久后ICE连接状态变为disconnected。
尝试设置iceTransportPolicy: "relay"后,onicecandidate事件完全不触发,未生成任何ICE候选。已确认TURN配置与提供的一致,附上操作步骤和两端代码,请求排查问题。
操作步骤
- 推流端先点击"Offer"按钮
- 观看端点击"Answer"按钮
- 推流端点击"Connect"按钮开始传输帧
CompOffer(推流端)代码
<template> <div> <button @click="offer">Offer</button> <button @click="connect">Connect</button> </div> </template> <script> // eslint-disable-next-line import firestore from "@/firestore"; // eslint-disable-next-line import { updateDoc, getDoc, doc, setDoc, arrayUnion } from "firebase/firestore"; export default { data() { return { pc: null }; }, methods: { async offer() { // Create a new peer connection const turn_config = { iceServers: [ { urls: "stun:relay.metered.ca:80" }, { urls: "turn:relay.metered.ca:80", username: "7c6e2dfc7ba5dd33578fc9e1", credential: "18GkZDVEKpCweYAf" }, { urls: "turn:relay.metered.ca:443", username: "7c6e2dfc7ba5dd33578fc9e1", credential: "18GkZDVEKpCweYAf" }, { urls: "turn:relay.metered.ca:443?transport=tcp", username: "7c6e2dfc7ba5dd33578fc9e1", credential: "18GkZDVEKpCweYAf" } ] }; this.pc = new RTCPeerConnection(turn_config); this.pc.oniceconnectionstatechange = () => { console.log(`ICE connection state changed to ${this.pc.iceConnectionState}`); }; // Getting the user media ( Webcam in this case ) const stream = await navigator.mediaDevices.getUserMedia({ video: true }); stream.getTracks().forEach((track) => { this.pc.addTrack(track, stream); // Adding the tracks to the peer to stream to the other side }); // Setting the ICE candidates to firestore const icesref = doc(firestore, "offer", "ices"); await setDoc(icesref, { ices: [] }); // Clearing out the previously ICE candidates list this.pc.onicecandidate = (e) => { if (e.candidate) { updateDoc(icesref, { ices: arrayUnion(e.candidate.toJSON()) }).then(() => { console.log("Successfully set ICE candidate"); }); } }; // Create an offer and set it in firestore let offer = await this.pc.createOffer(); await setDoc(doc(firestore, "offer", "main"), offer.toJSON()); // Setting the offer to firestore console.log("Successfully set offer"); await this.pc.setLocalDescription(offer); }, async connect() { // Extracting the answer and set to local description // Extract the ICE candidates also // Getting Answerer's SDP here const answer = await getDoc(doc(firestore, "answer", "main")); // Getting the answer const ices = await getDoc(doc(firestore, "answer", "ices")); // Get answerer ICE candidates let answersdp, iceslistreal; if (answer.exists()) answersdp = answer.data(); if (ices.exists()) iceslistreal = ices.data(); // Setting remote Description here await this.pc.setRemoteDescription(new RTCSessionDescription(answersdp)); iceslistreal["ices"].forEach((e) => { this.pc.addIceCandidate(new RTCIceCandidate(e)); }); console.log("Successfully set remote description"); } } }; </script>
CompAnswer(接收端)代码
<template> <div> <button @click="answer">Answer</button> <video id="vid" playsinline=""></video> </div> </template> <script> import firestore from "@/firestore"; import { setDoc, doc, arrayUnion, updateDoc, getDoc } from "@firebase/firestore"; export default { data() { return { pc: null, stream: new MediaStream() }; }, methods: { async answer() { // TURN servers const turn_config = { iceServers: [ { urls: "stun:relay.metered.ca:80" }, { urls: "turn:relay.metered.ca:80", username: "7c6e2dfc7ba5dd33578fc9e1", credential: "18GkZDVEKpCweYAf" }, { urls: "turn:relay.metered.ca:443", username: "7c6e2dfc7ba5dd33578fc9e1", credential: "18GkZDVEKpCweYAf" }, { urls: "turn:relay.metered.ca:443?transport=tcp", username: "7c6e2dfc7ba5dd33578fc9e1", credential: "18GkZDVEKpCweYAf" } ] }; this.pc = new RTCPeerConnection(turn_config); this.pc.oniceconnectionstatechange = () => { console.log(`ICE connection state changed to ${this.pc.iceConnectionState}`); }; // Sending the answerer's ICE candidates to the offerer const icesref = doc(firestore, "answer", "ices"); await setDoc(icesref, { ices: [] }); this.pc.onicecandidate = (e) => { if (e.candidate) { updateDoc(icesref, { ices: arrayUnion(e.candidate.toJSON()) }).then(() => { console.log("Successfully set ICE candidate"); }); } }; // Receiving remote tracks this.pc.ontrack = (event) => { console.log("New track recieved !!!"); event.streams[0].getTracks().forEach((track) => { this.stream.addTrack(track); }); }; // Set the track into the DOM const vid = document.getElementById("vid"); vid.srcObject = this.stream; vid.play(); // Make sure to play the video, otherwise there will be no video // ----Extracting the offer from firestore const offer = await getDoc(doc(firestore, "offer", "main")); // Get the main SDP const iceslist = await getDoc(doc(firestore, "offer", "ices")); // Get offerer ICE candidates let offersdp, iceslistreal; if (offer.exists()) offersdp = offer.data(); if (iceslist.exists()) iceslistreal = iceslist.data(); // Setting remote description here await this.pc.setRemoteDescription(new RTCSessionDescription(offersdp)); // Generating answer and setting the answer let answer = await this.pc.createAnswer(); await setDoc(doc(firestore, "answer", "main"), answer.toJSON()); console.log("Successfully set the answer"); // Setting local description and offerer's ICE candidates here await this.pc.setLocalDescription(new RTCSessionDescription(answer)); iceslistreal["ices"].forEach((e) => { this.pc.addIceCandidate(new RTCIceCandidate(e)); }); } } }; </script>
排查建议
- 验证TURN服务器连通性:
- 用浏览器端的ICE测试工具,输入你的TURN配置,检查能否成功获取
relay类型的候选。如果工具都拿不到relay候选,说明TURN配置(用户名/凭证过期)或服务器本身存在问题,比如端口被防火墙拦截。
- 用浏览器端的ICE测试工具,输入你的TURN配置,检查能否成功获取
- 修复ICE候选交换逻辑:
- 当前代码是一次性读取Firestore中的ICE候选,而ICE候选是逐步生成并写入的,可能导致只拿到部分候选甚至空列表。改为用
onSnapshot实时监听Firestore的ICE候选集合变化,一旦有新候选就立即添加到RTCPeerConnection。 - 推流端和接收端都需要修改这个逻辑,确保所有ICE候选都能被交换。
- 当前代码是一次性读取Firestore中的ICE候选,而ICE候选是逐步生成并写入的,可能导致只拿到部分候选甚至空列表。改为用
- 排查
iceTransportPolicy: "relay"无候选问题:- 这个配置强制仅使用relay候选,若没触发
onicecandidate,大概率是TURN服务器无法访问:- 检查服务器地址、端口是否正确,当前网络是否允许访问这些端口(比如443端口是否开放出站);
- 确认用户名和凭证是否有效,免费TURN服务可能有使用期限或流量限制;
- 更换其他公开免费TURN服务器测试,排除当前服务器的问题。
- 这个配置强制仅使用relay候选,若没触发
- 优化信令流程时序:
- 当前手动点击Connect的逻辑容易导致时序错误,建议推流端创建Offer并设置LocalDescription后,自动监听接收端的Answer和ICE候选变化,无需手动触发Connect;
- 接收端也应自动监听推流端的Offer,避免人为操作导致的流程混乱。
- 增加调试日志:
- 在
onicecandidate事件中打印候选类型(e.candidate.type),确认是否生成了relay候选; - 监听
icegatheringstatechange事件,查看ICE收集状态是否到达complete; - 检查接收端
ontrack事件是否触发,判断是连接未建立还是媒体轨道未传递。
- 在
内容的提问来源于stack exchange,提问作者scriptkiddy
相关产品推荐
相关产品推荐

