要搭建一个完整的WebRTC演示项目,你需要整合信令服务器、STUN/TURN服务器和前端页面。下面是详细的实现文档,涵盖了从服务器搭建到前端代码的完整流程。
1. WebRTC 系统架构
一个基础的WebRTC系统包含三个核心部分,它们协同工作以建立点对点连接:
| 组件 | 作用 | 说明 |
|---|---|---|
| 前端客户端 | 采集音视频、建立P2P连接、交换媒体流 | 使用HTML/CSS/JS(jQuery)实现用户界面和WebRTC API调用。 |
| 信令服务器 | 交换SDP和ICE候选信息 | 协调通信双方,告知彼此的“网络位置”和“媒体能力”。这里使用PHP WebSocket实现。 |
| STUN/TURN服务器 | 实现NAT/防火墙穿透 | STUN用于获取公网地址尝试直连;TURN在直连失败时作为中继转发数据。 |
2. PHP 信令服务器搭建
信令服务器负责在通信双方之间转发SDP提议/应答和ICE候选信息。
2.1 环境准备
首先,使用Composer安装 Ratchet 库,它是一个PHP的WebSocket库。
composer require cboden/ratchet
2.2 服务器代码 (signaling_server.php)
创建一个PHP文件,实现一个基础的WebSocket服务器。
<?php
require dirname(__DIR__) . ‘/vendor/autoload.php’;
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Server\IoServer;
use Ratchet\WebSocket\WsServer;
class SignalingServer implements MessageComponentInterface {
protected $clients;
protected $rooms; // 简单模拟房间,用于用户配对
public function __construct() {
$this->clients = new \SplObjectStorage;
$this->rooms = [];
}
public function onOpen(ConnectionInterface $conn) {
$this->clients->attach($conn);
echo “新连接: ({$conn->resourceId})\n”;
}
public function onMessage(ConnectionInterface $from, $msg) {
$data = json_decode($msg, true);
if (!$data) return;
$numRecv = count($this->clients) - 1;
echo sprintf(‘连接 %d 发送消息: %s 给 %d 个其他连接\n’, $from->resourceId, $msg, $numRecv);
switch ($data[‘type’]) {
case ‘join’:
// 用户加入房间
$roomId = $data[‘room’];
$this->rooms[$roomId][] = $from;
echo “用户 {$from->resourceId} 加入房间 {$roomId}\n”;
// 如果房间有两人,通知对方准备连接
if (count($this->rooms[$roomId]) === 2) {
foreach ($this->rooms[$roomId] as $client) {
$otherId = ($client->resourceId == $from->resourceId) ?
current(array_filter($this->rooms[$roomId], fn($c) => $c->resourceId != $client->resourceId))->resourceId :
$from->resourceId;
$client->send(json_encode([
‘type’ => ‘user_joined’,
‘peerId’ => $otherId
]));
}
}
break;
case ‘offer’:
case ‘answer’:
case ‘candidate’:
// 转发信令给房间内的对等方
$roomId = $data[‘room’];
if (isset($this->rooms[$roomId])) {
foreach ($this->rooms[$roomId] as $client) {
if ($client !== $from) {
$client->send($msg);
}
}
}
break;
}
}
public function onClose(ConnectionInterface $conn) {
$this->clients->detach($conn);
echo “连接 {$conn->resourceId} 已断开\n”;
// 清理房间中的用户
foreach ($this->rooms as $roomId => $clients) {
$this->rooms[$roomId] = array_filter($clients, fn($c) => $c != $conn);
if (empty($this->rooms[$roomId])) {
unset($this->rooms[$roomId]);
}
}
}
public function onError(ConnectionInterface $conn, \Exception $e) {
echo “错误: {$e->getMessage()}\n”;
$conn->close();
}
}
// 启动服务器 (监听端口 8080)
$server = IoServer::factory(
new WsServer(new SignalingServer()),
8080
);
echo “信令服务器运行在 ws://0.0.0.0:8080\n”;
$server->run();
2.3 运行服务器
在命令行执行以下命令启动服务器:
php signaling_server.php
3. STUN/TURN 服务器配置
3.1 公共 STUN 服务器
STUN 服务器用于获取客户端的公网地址。你可以使用以下国内或国际的免费公共服务器:
| 服务提供商 | 服务器地址 | 备注 |
|---|---|---|
| 谷歌公共STUN | stun:stun.l.google.com:19302 |
国际服务,通用性强。 |
| 腾讯云 | stun:stun.qq.com:3478 |
国内节点,延迟可能更低。 |
| Twilio | stun:global.stun.twilio.com:3478 |
国际服务,较稳定。 |
在国内复杂的网络环境下(尤其是运营商4G网络),STUN穿透可能失败,此时需要依赖TURN服务器。
3.2 自建 TURN 服务器
由于公共TURN服务器资源稀缺且可能不稳定,自建是更可靠的选择。推荐使用 coturn。
3.2.1 系统与环境准备 (以CentOS为例)
-
安装依赖:
sudo yum install -y make gcc cc gcc-c++ wget openssl-devel libevent libevent-devel重要:请确保OpenSSL版本为1.0.x或1.1.x,因为coturn 4.5.1.3与OpenSSL 3.x存在已知兼容性问题。
-
安装 libevent 2.x:
wget https://github.com/libevent/libevent/releases/download/release-2.1.12-stable/libevent-2.1.12-stable.tar.gz tar -xvzf libevent-2.1.12-stable.tar.gz cd libevent-2.1.12-stable ./configure make sudo make install cd ..
3.2.2 编译安装 coturn
-
下载并解压:
wget https://github.com/coturn/coturn/archive/refs/tags/4.5.1.3.tar.gz -O coturn-4.5.1.3.tar.gz tar -xvzf coturn-4.5.1.3.tar.gz cd coturn-4.5.1.3 -
编译安装:
./configure make sudo make install
3.2.3 配置 coturn
-
复制并编辑配置文件:
sudo cp /usr/local/etc/turnserver.conf.default /usr/local/etc/turnserver.conf sudo vi /usr/local/etc/turnserver.conf -
修改关键配置项:找到并修改以下配置。
# 监听IP (服务器内网IP) listening-ip=10.0.0.1 # 中继IP (通常与监听IP相同) relay-ip=10.0.0.1 # **服务器公网IP,必须正确设置** external-ip=你的公网IP # 设置用户名和密码(前端WebRTC配置会用到) user=username:password # 限制UDP端口范围,便于防火墙设置 min-port=49152 max-port=65535 # 关闭详细日志,避免磁盘被占满 no-stdout-log -
防火墙开放端口:需要开放3478(TCP/UDP) 以及你设置的UDP端口范围(如
49152-65535)。
3.2.4 启动与测试
-
启动服务:
turnserver -v -r 你的公网IP:3478 -a -o -c /usr/local/etc/turnserver.conf -
测试服务:使用 Trickle ICE 工具进行测试。
- 添加服务器:选择
TURN类型,输入你的公网IP、端口3478及配置的用户名和密码。 - 点击
Gather candidates。如果成功,列表中会出现relay类型的候选地址,并显示你的公网IP。这证明TURN服务器部署成功。
- 添加服务器:选择
4. 前端 WebRTC 客户端
以下是集成了信令、媒体流和连接建立的前端页面示例。
<!DOCTYPE html>
<html>
<head>
<title>WebRTC 视频通话演示</title>
<meta charset=”utf-8”>
<script src=”https://code.jquery.com/jquery-3.6.0.min.js”></script>
<style>
body { font-family: Arial, sans-serif; }
#videos { display: flex; justify-content: space-around; }
video { width: 45%; background: #000; border: 1px solid #ccc; }
#controls { text-align: center; margin-top: 20px; }
input, button { margin: 5px; padding: 10px; }
</style>
</head>
<body>
<h2>WebRTC 1对1 视频通话</h2>
<div id=”videos”>
<video id=”localVideo” autoplay muted playsinline></video>
<video id=”remoteVideo” autoplay playsinline></video>
</div>
<div id=”controls”>
<input type=”text” id=”roomInput” placeholder=”输入房间号”>
<button id=”joinBtn”>加入房间</button>
<button id=”callBtn” disabled>发起通话</button>
<button id=”hangupBtn” disabled>挂断</button>
</div>
<script>
let localStream, peerConnection, signalingSocket, roomId;
// **WebRTC 服务器配置 (核心配置)**
const configuration = {
iceServers: [
// 优先尝试国内STUN服务器
{ urls: “stun:stun.qq.com:3478” },
// STUN失败时,使用自建的TURN服务器进行中继
{
urls: “turn:你的公网IP:3478”,
username: “username”, // 与turnserver.conf中一致
credential: “password”
}
]
};
// 1. 获取本地媒体流
async function startLocalStream() {
try {
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
$(‘#localVideo’).get(0).srcObject = localStream;
$(‘#callBtn’).prop(‘disabled’, false);
} catch (e) {
console.error(‘获取媒体设备失败:’, e);
}
}
// 2. 连接到信令服务器
function connectSignaling() {
const wsScheme = window.location.protocol === ‘https:’ ? ‘wss://’ : ‘ws://’;
const wsUrl = wsScheme + window.location.hostname + ‘:8080’;
signalingSocket = new WebSocket(wsUrl);
signalingSocket.onopen = () => console.log(‘信令服务器连接成功’);
signalingSocket.onerror = (err) => console.error(‘信令连接错误:’, err);
signalingSocket.onmessage = async (event) => {
const message = JSON.parse(event.data);
console.log(‘收到信令:’, message);
if (!peerConnection && message.type === ‘user_joined’) {
// 房间内另一用户已就绪,可以创建连接
createPeerConnection();
}
if (peerConnection) {
switch (message.type) {
case ‘offer’:
await peerConnection.setRemoteDescription(new RTCSessionDescription(message));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
sendSignal({ type: ‘answer’, answer, room: roomId });
break;
case ‘answer’:
await peerConnection.setRemoteDescription(new RTCSessionDescription(message));
break;
case ‘candidate’:
try {
await peerConnection.addIceCandidate(new RTCIceCandidate(message.candidate));
} catch (e) { /* 忽略候选收集结束时的错误 */ }
break;
}
}
};
}
// 3. 创建RTCPeerConnection对象
function createPeerConnection() {
peerConnection = new RTCPeerConnection(configuration);
// 添加本地音视频轨道
localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));
// 监听ICE候选信息,并发送给对端
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
sendSignal({ type: ‘candidate’, candidate: event.candidate, room: roomId });
}
};
// 接收远端媒体流
peerConnection.ontrack = (event) => {
const remoteVideo = $(‘#remoteVideo’).get(0);
if (remoteVideo.srcObject !== event.streams[0]) {
remoteVideo.srcObject = event.streams[0];
}
};
peerConnection.oniceconnectionstatechange = () => {
console.log(‘ICE连接状态:’, peerConnection.iceConnectionState);
if (peerConnection.iceConnectionState === ‘disconnected’) {
hangUpCall();
}
};
}
// 4. 发送信令
function sendSignal(data) {
if (signalingSocket.readyState === WebSocket.OPEN) {
signalingSocket.send(JSON.stringify(data));
}
}
// 5. 发起通话
async function startCall() {
if (!peerConnection) createPeerConnection();
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
sendSignal({ type: ‘offer’, offer, room: roomId });
$(‘#hangupBtn’).prop(‘disabled’, false);
}
// 6. 挂断通话
function hangUpCall() {
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
$(‘#remoteVideo’).get(0).srcObject = null;
$(‘#hangupBtn’).prop(‘disabled’, true);
$(‘#callBtn’).prop(‘disabled’, false);
}
// 页面事件绑定
$(document).ready(function() {
startLocalStream();
connectSignaling();
$(‘#joinBtn’).click(() => {
roomId = $(‘#roomInput’).val();
if (roomId) {
sendSignal({ type: ‘join’, room: roomId });
alert(`已加入房间 ${roomId}`);
}
});
$(‘#callBtn’).click(startCall);
$(‘#hangupBtn’).click(hangUpCall);
});
</script>
</body>
</html>
5. 部署与运行
-
启动服务:
- 在一台具有公网IP的服务器上,按照第3部分启动TURN服务器。
- 在同一台或另一台服务器上,运行
php signaling_server.php启动信令服务器。
-
配置前端:
- 将前端HTML文件中的
你的公网IP替换为TURN服务器的真实公网IP。 - 将信令服务器的WebSocket地址(
ws://...:8080)修改为对应的公网地址或域名。
- 将前端HTML文件中的
-
访问测试:
- 将前端页面部署到Web服务器(如Nginx)。
- 使用两个不同的浏览器标签页或设备,访问该页面,输入相同的房间号,依次点击“加入房间”和“发起通话”,即可建立连接。
常见问题排查
-
无法获取媒体流:确保使用
https://或localhost访问页面,并允许浏览器使用摄像头和麦克风。 - 无法建立WebSocket连接:检查信令服务器进程是否运行,以及防火墙是否放行了8080端口。
-
ICE连接失败/长时间处于
checking状态:这通常是NAT穿透失败。-
首先检查TURN服务器:使用 Trickle ICE 工具测试,确认能获取到
relay类型的候选地址。 -
检查配置:确保前端
configuration中的TURN服务器IP、端口、用户名和密码完全正确。 - 检查防火墙:确认服务器安全组和系统防火墙已开放 3478 (TCP/UDP) 和指定的UDP端口范围。
-
首先检查TURN服务器:使用 Trickle ICE 工具测试,确认能获取到
按照以上步骤,你应该可以成功搭建一个完整的WebRTC演示环境。如果在自建TURN服务器时遇到特定错误,可以尝试搜索错误信息或查阅coturn的官方文档。
直接将TURN服务器的账号密码写在JavaScript前端代码中是极不安全的,这是WebRTC部署中一个严重的安全隐患。
这样做相当于将你服务器的“钥匙”公之于众,任何访问你网页的人都可以通过查看网页源代码轻松获取凭据,并可能滥用你的TURN服务器进行中转流量,导致:
- 服务器资源被耗尽,产生高昂的流量费用。
- 服务完全失控,无法管理使用者。
解决方案:动态凭据(Temporary Credential)
安全的行业标准做法是使用短期、动态生成的凭据。其核心流程如下图所示:
flowchart TD
A[用户加入房间] --> B[前端请求TURN凭据<br>(向信令服务器)]
B --> C[信令服务器生成<br>临时用户名/密码]
C --> D[凭据有效期:<br>12-24小时]
D --> E[前端使用临时凭据<br>建立WebRTC连接]
E --> F[TURN服务器验证临时凭据]
F --> G[连接成功]
下面是如何实现这套安全方案的具体步骤。
1. 修改信令服务器:增加凭据分发接口
在你的 signaling_server.php 中,需要增加生成并返回临时TURN凭据的逻辑。这里使用一种基于时间戳和密钥的常见算法。
// 在信令服务器类中定义密钥(务必使用高强度的随机字符串)
private $turnSecretKey = “你的高强度安全密钥,至少32位”;
// 在 onMessage 方法的 switch 中增加一个 case
case ‘get_turn_credentials’:
// 生成有时效性的用户名(例如: 时间戳:12小时)
$expiryTime = time() + 12*3600; // 12小时后过期
$username = $expiryTime . ‘:’ . uniqid();
// 生成密码(使用HMAC算法,确保服务器端可验证)
$password = hash_hmac(‘sha1’, $username, $this->turnSecretKey);
// 将凭据返回给前端
$from->send(json_encode([
‘type’ => ‘turn_credentials’,
‘iceServers’ => [
[‘urls’ => ‘stun:stun.qq.com:3478’],
[
‘urls’ => ‘turn:你的公网IP:3478’,
‘username’ => $username,
‘credential’ => $password
]
]
]));
break;
2. 修改前端:先获取凭据,再建立连接
前端需要在加入房间或发起通话前,先从信令服务器获取临时凭据。
// 删除前端代码中写死的 configuration 常量
// 新增函数:从信令服务器获取TURN配置
async function getTurnCredentials() {
return new Promise((resolve, reject) => {
// 向信令服务器发送请求
sendSignal({ type: ‘get_turn_credentials’ });
// 临时监听凭据返回(可根据你的架构改进)
const tempHandler = (event) => {
const message = JSON.parse(event.data);
if (message.type === ‘turn_credentials’) {
signalingSocket.removeEventListener(‘message’, tempHandler);
resolve(message.iceServers); // 返回ICE服务器配置数组
}
};
signalingSocket.addEventListener(‘message’, tempHandler);
});
}
// 修改“发起通话”函数
async function startCall() {
// 1. 首先获取动态的TURN配置
const iceServers = await getTurnCredentials();
// 2. 使用动态配置创建RTCPeerConnection
if (!peerConnection) {
peerConnection = new RTCPeerConnection({ iceServers: iceServers });
// ... 设置onicecandidate、ontrack等回调
}
// 3. 继续创建offer等后续步骤
const offer = await peerConnection.createOffer();
// ... 其余代码不变
}
3. 配置TURN服务器验证动态凭据
你必须修改 coturn 服务器的配置,使其能验证我们生成的动态凭据。
编辑 /usr/local/etc/turnserver.conf:
# 启用长期凭据机制(我们动态生成的“用户名”中包含过期时间)
lt-cred-mech
# 设置用于生成密码的共享密钥(必须与信令服务器中的 $turnSecretKey 保持一致)
static-auth-secret=你的高强度安全密钥,至少32位
# 禁用“长期”用户数据库(因为我们不使用固定的user=xxx:xxx配置)
# 注意:注释掉或删除之前的 user=username:password 配置行
# user=username:password
重启coturn服务使配置生效。
方案对比与核心要点
| 特性 | 不安全的硬编码方案 | 安全的动态凭据方案 |
|---|---|---|
| 凭据泄露风险 | 极高,直接暴露在源码中 | 极低,每次会话都不同,且有过期时间 |
| 服务器控制力 | 无,任何人拿到密码即可使用 | 强,可随时更改密钥使所有旧凭据失效 |
| 实现复杂度 | 简单 | 中等,需要前后端配合 |
| 推荐程度 | 绝对不要在生产环境使用 | 生产环境强制要求 |
进一步的安全加固建议
即使采用了动态凭据,你还可以通过以下方式进一步增强安全性:
- 限制TURN服务器使用:在防火墙或coturn配置中,限制只允许你的信令服务器或前端服务器IP来使用TURN中继功能。
-
绑定用户身份:在生成用户名时,可以加入房间ID或用户唯一ID(如
timestamp:userId:roomId),这样可以在服务器端记录和审计用量。 - 缩短凭据有效期:根据你的应用场景,可以将凭据有效期从12小时缩短到更短(如1-2小时)。
- 监控与告警:监控TURN服务器的流量和使用情况,设置异常流量告警。
总结
永远不要将TURN服务器的固定账号密码硬编码在前端。 请务必按照上述动态凭据方案进行改造。虽然实现步骤稍多,但这是保障你的WebRTC服务稳定、可控且不产生意外经济损失的基石。如果你在配置coturn验证部分遇到问题,可以查阅其官方文档中关于 --use-auth-secret 参数的详细说明。