You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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>

排查建议

  1. 验证TURN服务器连通性:
    • 用浏览器端的ICE测试工具,输入你的TURN配置,检查能否成功获取relay类型的候选。如果工具都拿不到relay候选,说明TURN配置(用户名/凭证过期)或服务器本身存在问题,比如端口被防火墙拦截。
  2. 修复ICE候选交换逻辑:
    • 当前代码是一次性读取Firestore中的ICE候选,而ICE候选是逐步生成并写入的,可能导致只拿到部分候选甚至空列表。改为用onSnapshot实时监听Firestore的ICE候选集合变化,一旦有新候选就立即添加到RTCPeerConnection。
    • 推流端和接收端都需要修改这个逻辑,确保所有ICE候选都能被交换。
  3. 排查iceTransportPolicy: "relay"无候选问题:
    • 这个配置强制仅使用relay候选,若没触发onicecandidate,大概率是TURN服务器无法访问:
      • 检查服务器地址、端口是否正确,当前网络是否允许访问这些端口(比如443端口是否开放出站);
      • 确认用户名和凭证是否有效,免费TURN服务可能有使用期限或流量限制;
      • 更换其他公开免费TURN服务器测试,排除当前服务器的问题。
  4. 优化信令流程时序:
    • 当前手动点击Connect的逻辑容易导致时序错误,建议推流端创建Offer并设置LocalDescription后,自动监听接收端的Answer和ICE候选变化,无需手动触发Connect;
    • 接收端也应自动监听推流端的Offer,避免人为操作导致的流程混乱。
  5. 增加调试日志:
    • 在onicecandidate事件中打印候选类型(e.candidate.type),确认是否生成了relay候选;
    • 监听icegatheringstatechange事件,查看ICE收集状态是否到达complete;
    • 检查接收端ontrack事件是否触发,判断是连接未建立还是媒体轨道未传递。

内容的提问来源于stack exchange,提问作者scriptkiddy

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.28 03:42:09