webrtc for php

要搭建一个完整的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为例)

  1. 安装依赖

    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存在已知兼容性问题。

  2. 安装 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

  1. 下载并解压
    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
    
  2. 编译安装
    ./configure
    make
    sudo make install
    

3.2.3 配置 coturn

  1. 复制并编辑配置文件
    sudo cp /usr/local/etc/turnserver.conf.default /usr/local/etc/turnserver.conf
    sudo vi /usr/local/etc/turnserver.conf
    
  2. 修改关键配置项:找到并修改以下配置。
    # 监听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
    
  3. 防火墙开放端口:需要开放3478(TCP/UDP) 以及你设置的UDP端口范围(如 49152-65535)。

3.2.4 启动与测试

  1. 启动服务
    turnserver -v -r 你的公网IP:3478 -a -o -c /usr/local/etc/turnserver.conf
    
  2. 测试服务:使用 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. 部署与运行

  1. 启动服务
    • 在一台具有公网IP的服务器上,按照第3部分启动TURN服务器。
    • 在同一台或另一台服务器上,运行 php signaling_server.php 启动信令服务器。
  2. 配置前端
    • 将前端HTML文件中的 你的公网IP 替换为TURN服务器的真实公网IP。
    • 将信令服务器的WebSocket地址(ws://...:8080)修改为对应的公网地址或域名。
  3. 访问测试
    • 将前端页面部署到Web服务器(如Nginx)。
    • 使用两个不同的浏览器标签页或设备,访问该页面,输入相同的房间号,依次点击“加入房间”和“发起通话”,即可建立连接。

常见问题排查

  • 无法获取媒体流:确保使用 https://localhost 访问页面,并允许浏览器使用摄像头和麦克风。
  • 无法建立WebSocket连接:检查信令服务器进程是否运行,以及防火墙是否放行了8080端口。
  • ICE连接失败/长时间处于checking状态:这通常是NAT穿透失败。
    • 首先检查TURN服务器:使用 Trickle ICE 工具测试,确认能获取到 relay 类型的候选地址。
    • 检查配置:确保前端 configuration 中的TURN服务器IP、端口、用户名和密码完全正确。
    • 检查防火墙:确认服务器安全组和系统防火墙已开放 3478 (TCP/UDP) 和指定的UDP端口范围。

按照以上步骤,你应该可以成功搭建一个完整的WebRTC演示环境。如果在自建TURN服务器时遇到特定错误,可以尝试搜索错误信息或查阅coturn的官方文档。


直接将TURN服务器的账号密码写在JavaScript前端代码中是极不安全的,这是WebRTC部署中一个严重的安全隐患。

这样做相当于将你服务器的“钥匙”公之于众,任何访问你网页的人都可以通过查看网页源代码轻松获取凭据,并可能滥用你的TURN服务器进行中转流量,导致:

  1. 服务器资源被耗尽,产生高昂的流量费用。
  2. 服务完全失控,无法管理使用者。

解决方案:动态凭据(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服务使配置生效。

方案对比与核心要点

特性 不安全的硬编码方案 安全的动态凭据方案
凭据泄露风险 极高,直接暴露在源码中 极低,每次会话都不同,且有过期时间
服务器控制力 无,任何人拿到密码即可使用 强,可随时更改密钥使所有旧凭据失效
实现复杂度 简单 中等,需要前后端配合
推荐程度 绝对不要在生产环境使用 生产环境强制要求

进一步的安全加固建议

即使采用了动态凭据,你还可以通过以下方式进一步增强安全性:

  1. 限制TURN服务器使用:在防火墙或coturn配置中,限制只允许你的信令服务器或前端服务器IP来使用TURN中继功能。
  2. 绑定用户身份:在生成用户名时,可以加入房间ID或用户唯一ID(如 timestamp:userId:roomId),这样可以在服务器端记录和审计用量。
  3. 缩短凭据有效期:根据你的应用场景,可以将凭据有效期从12小时缩短到更短(如1-2小时)。
  4. 监控与告警:监控TURN服务器的流量和使用情况,设置异常流量告警。

总结

永远不要将TURN服务器的固定账号密码硬编码在前端。 请务必按照上述动态凭据方案进行改造。虽然实现步骤稍多,但这是保障你的WebRTC服务稳定、可控且不产生意外经济损失的基石。如果你在配置coturn验证部分遇到问题,可以查阅其官方文档中关于 --use-auth-secret 参数的详细说明。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容