使用php构建webrtc服务器

songshu

一个基于 PHP >= 7.3.9 原生 socket 实现的轻量级 WebRTC SFU(Selective Forwarding Unit)服务器。
采用单进程异步事件循环,内置 WebSocket 信令、STUN、DTLS、SCTP(DataChannel)和 SRTP 音视频转发;不依赖第三方 Composer 运行时包。
支持通过事件回调无侵入扩展业务逻辑,无需修改 SDK 核心代码即可实现多房间、鉴权、计费、聊天广播、自定义信令等能力。


特性

能力 说明
WebSocket 信令服务 内置独立的 HTTP/WS 服务 (默认 8088 端口),自带静态文件托管
STUN / ICE 独立 STUN Binding 服务默认监听 3478;WebRTC 媒体端口上的 ICE Binding Response 包含 MESSAGE-INTEGRITY 和 FINGERPRINT
DTLS 握手 RFC 6347 / RFC 5764(DTLS-SRTP),内置证书,SRTP 主密钥自动协商派生
SRTP 协议 AES-128-ICM 加密、HMAC-SHA1-80 认证、ROC 和重复包防护
SCTP DataChannel PPID=51 文本 / 53 二进制 / 56/57 部分可靠流 (onmessage 回调)
SFU 媒体转发 SSRC 自动重写 + 多订阅者分发,push 端一路推流 -> 多路 play 端观看
事件驱动接口 提供连接、信令、Publisher、Subscriber、RTP 和 DataChannel 等回调;部分前置事件可通过 &$handled 接管默认处理
元数据管理 Client 级 KV metadata,房间 / 角色 / 并发限制等业务字段自由扩展
无第三方运行时包 使用 PHP 的 openssljsonsockets 扩展

环境要求

  • PHP >= 7.3.9
  • PHP 扩展:
    • openssl
    • json
    • sockets
  • Composer
  • 操作系统:Windows / Linux / macOS

安装依赖:

composer require xiaosongshu/webrtc

端口与网络模型

端口 协议 作用 公网部署方式
8088 TCP (HTTP/WS) 页面、WebSocket 信令、WHIP/WHEP HTTP API 服务当前绑定 0.0.0.0;公网部署时用防火墙限制,仅允许 Nginx 访问
8089 UDP ICE、DTLS、SRTP/SRTCP、RTP/RTCP 媒体 必须让客户端直接访问,普通 Nginx HTTP 代理不能转发
3478 UDP 可选的独立 STUN Binding 服务 使用该服务时直接开放

浏览器或 OBS 首先通过 HTTP/WebSocket 完成信令和 SDP 协商,随后根据 SDP candidate 直接连接 服务器IP:8089/UDP。HTTPS 代理成功并不代表媒体端口已经连通。


本地部署

源码仓库根目录提供 start.php 以及 index.htmlpush.htmlplay.htmlwhip.htmlwhep.html 示例页面。通过 Composer 安装时,可参考仓库中的 start.php 在业务项目创建启动入口;服务器默认读取 SDK 包根目录中的示例静态文件,仅复制 start.php 不会改变静态文件目录。

1. 启动服务

php start.php

启动成功后会看到类似输出:

Using certificate: .../src/Core/certs/server.crt
WebSocket signaling server listening on ws://0.0.0.0:8088/
UDP media server listening on udp://0.0.0.0:8089
STUN server listening on udp://0.0.0.0:3478

2. 本地推流和拉流

页面/API 地址 用途
WebSocket 推流 http://127.0.0.1:8088/index.html 浏览器datachannel演示
WebSocket 推流 http://127.0.0.1:8088/push.html 浏览器屏幕/窗口推流
WebSocket 拉流 http://127.0.0.1:8088/play.html 播放相同 streamId 的媒体
WHIP 推流 http://127.0.0.1:8088/whip.html 浏览器通过 WHIP 推流
WHEP 拉流 http://127.0.0.1:8088/whep.html 浏览器通过 WHEP 拉流
OBS WHIP http://127.0.0.1:8088/whip/stream_001 OBS 的 WHIP 服务地址

推流端与拉流端必须使用相同的 streamId。示例默认使用 stream_001

push.htmlplay.html 和首页会根据当前页面地址自动选择 ws://whip.htmlwhep.html 会自动使用当前页面的同源 HTTP 地址,因此本地运行无需修改页面配置。


WHIP / WHEP HTTP 接口

项目提供 WHIP/WHEP 风格的 SDP POST 和资源删除接口,已配套仓库示例页面,并可供 OBS 使用 WHIP 推流。当前 Trickle ICE 候选接口采用项目自定义的 JSON POST 路径,并非 IETF 标准的 SDP fragment PATCH;接入其他客户端前请核对其请求格式。

WHIP 推流

创建请求

POST /whip/<streamId> HTTP/1.1
Content-Type: application/sdp

<整个 SDP offer 文本>

成功响应

HTTP/1.1 201 Created
Location: /whip/<resourceId>
Content-Type: application/sdp

<服务端 SDP answer>
  • streamId 是业务定义的流标识,例如 stream_001
  • 当前 resourceId 是服务端生成的数字 clientId。
  • 停止推流时向响应中的相对 Location 发送 DELETE,成功返回 204 No Content
DELETE /whip/<resourceId> HTTP/1.1

WHEP 拉流

创建请求

POST /whep/<streamId> HTTP/1.1
Content-Type: application/sdp

<整个 SDP offer 文本>

成功响应

HTTP/1.1 201 Created
Location: /whep/<resourceId>
Content-Type: application/sdp

<服务端 SDP answer>

必须先存在相同 streamId 的 Publisher;否则立即返回 404 Not Found,响应正文为 Stream not available。停止拉流使用:

DELETE /whep/<resourceId> HTTP/1.1

Trickle ICE 候选

当前候选接口接受以下项目自定义请求,成功返回 204 No Content

POST /whip/<resourceId>/candidate HTTP/1.1
Content-Type: application/json

{"candidate":"candidate:..."}

WHEP 对应路径为 /whep/<resourceId>/candidate;服务端也接受将最后一段 candidate 写为 ice。仓库中的 whip.html 使用上述 JSON POST 接口。

公网线上部署(推荐 Nginx)

生产环境建议保持职责分离:

  • Nginx:域名、HTTPS 证书、WSS、静态页面及 WHIP/WHEP HTTP 反向代理。
  • WebRTCServer:SDP、ICE/STUN、DTLS、SRTP/SRTCP 和 RTP/RTCP 转发。
  • UDP 8089:客户端直接连接 WebRTCServer,不经过普通 Nginx proxy_pass

1. 设置 SDP 对外公布的公网 IP

SDP candidate 使用 WebRTCServer::getLocalIP() 返回的地址。服务器网卡直接绑定公网 IPv4 时通常无需处理;云主机通过 NAT 使用公网 IP 时,在业务启动文件中继承服务器类即可,无需修改 SDK 核心:

<?php

use Xiaosongshu\Webrtc\WebRTCServer;

require_once __DIR__ . '/vendor/autoload.php';

$server = new WebRTCServer(
    8088,
    8089,
    3478,
    __DIR__ . '/debug.log'
);
// 替换为你的服务器公网 IPv4 ,本地测试环境可以不设置
$server->publicIp = '127.0.0.1';
// 是否开启调试模式 ,生成环境建议关闭
$server->isDev = false;
// 启动服务,后面的代码将不会执行
$server->start();

公网 NAT 必须把 8089/UDP 映射到服务器同一端口。当前 SDP 默认公布端口 8089,如果公网端口与内网端口不同,需要同步调整服务端 UDP 端口配置。

2. 配置域名和 HTTPS 证书

不要求购买商业证书,可以使用 Let's Encrypt 或其他受浏览器信任的 CA 证书。公网浏览器采集摄像头、麦克风或屏幕通常要求 HTTPS 安全上下文。

以下配置放在 Nginx http 配置范围内:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name webrtc.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name webrtc.example.com;

    ssl_certificate     /etc/letsencrypt/live/webrtc.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/webrtc.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8088;
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_buffering off;
        proxy_request_buffering off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

该配置将页面、WebSocket、WHIP 和 WHEP 都代理到本机 8088/TCP。示例页面会自动使用:

wss://webrtc.example.com
https://webrtc.example.com/whip/stream_001
https://webrtc.example.com/whep/stream_001

OBS 的服务类型选择 WHIP,服务地址填写:

https://webrtc.example.com/whip/stream_001

WHIP/WHEP 返回的资源 Location 使用相对 URL,可继续通过同一域名访问和删除。

3. 配置防火墙和云安全组

至少放行:

规则 来源 说明
80/TCP 公网 可选,仅用于跳转 HTTPS
443/TCP 公网 HTTPS、WSS、WHIP、WHEP
8089/UDP 公网 WebRTC 媒体,必须开放
3478/UDP 公网 仅在使用内置独立 STUN 时开放
8088/TCP 本机 Nginx 到 PHP 服务;不建议直接向公网开放

如果服务器位于路由器、容器或云 NAT 后面,还必须配置 8089/UDP 端口映射,并确保 getLocalIP() 返回外部客户端实际可达的 IPv4。

4. HTTPS 证书与 DTLS 证书的区别

Nginx HTTPS 证书用于保护页面、WebSocket 和 WHIP/WHEP HTTP 请求;项目内置 DTLS 证书用于 WebRTC 媒体密钥协商。服务端将该证书的 SHA-256 fingerprint 写入 SDP,由 WebRTC 客户端验证服务端证书。两者用途不同,使用 Nginx 后仍需保留项目的 DTLS 证书和指纹逻辑。

5. TURN 说明

Nginx 不是 TURN 服务。服务器拥有公网可达的 8089/UDP 时,多数客户端可以直接建立连接;如果客户端处于严格企业防火墙、对称 NAT 或禁止 UDP 的网络,需要另行部署 TURN,并在客户端 RTCPeerConnectioniceServers 中配置,不能依靠 Nginx HTTP 代理解决。


推流 / 拉流默认信令协议

push.htmlplay.html 均通过 WebSocket 发送 JSON 文本消息与服务端通信。

1) join(进入房间)

推流端:

{ "type": "join", "role": "push", "streamId": "ROOM123" }

拉流端:

{ "type": "join", "role": "play", "streamId": "ROOM123" }

服务端默认回:

{ "type": "joined" }

2) offer(SDP 协商)

  • push.html(推流端):浏览器 offerer → 服务端 answerer
  • play.html(拉流端):服务端 offerer → 浏览器 answerer(缺省 SFU 自动由 makeSfuOfferForSubscriber() 生成)

3) candidate(ICE 候选地址)

服务端缺省:把 push 端的 candidate 转发给同 streamId 的所有 play 端;play 端的 candidate 只转发给对应的 push 端,无需额外业务代码。

当前 WebSocket 信令没有固定 path;8088 上任意 URL 的有效 WebSocket Upgrade 都进入同一信令处理器。


事件接口(无侵入扩展)

核心设计思想:通过回调扩展业务;其中部分信令前置事件支持可选的 &$handled 引用参数,具体以事件表为准:

  • 支持 &$handled 的回调中不设置 true → 继续执行 SDK 的缺省处理
  • 设置 $handled=true → 跳过该事件对应的缺省处理,由业务代码接管

事件总览

事件属性 触发时机 签名 是否支持 &$handled
$onOpen TCP/HTTP 连接 accept 时触发;收到 DataChannel CHANNEL_OPEN 后还会以 channel label 再次触发 fn(mixed $label, int $clientId, WebRTCServer $srv):void
$onSignaling 任何 WebSocket 信令到达,全局前置钩子 fn(int $id, array $msg, WebRTCServer $srv, &$handled):void
$onJoin 收到 join 信令 fn(int $id, array $msg, WebRTCServer $srv, &$handled):void
$onOffer Offer 已生成 Answer;WS 流程中已回发,HTTP 流程中尚未写出响应 fn(int $id, string $offerSdp, string $answerSdp, WebRTCServer $srv):void
$onPublisher onOffer 后若 metadata.role==='push',Publisher 就绪 fn(int $id, array $ctx, WebRTCServer $srv):void
$onSubscriber WebSocket role=play 客户端 join 且已找到同流 Publisher 后触发;HTTP WHEP 不触发 fn(int $id, array $ctx, WebRTCServer $srv):void
$onAnswer 客户端发来 answer(通常 play 端) fn(int $id, string $answerSdp, WebRTCServer $srv, &$handled):void
$onCandidate ICE candidate 到达 fn(int $id, array $msg, WebRTCServer $srv, &$handled):void
$onMediaConnected 首个 SRTP RTP 包成功 unprotect(媒体首帧落地) fn(int $id, array $rtpHeader, WebRTCServer $srv):void
$onRtp 收到并成功解密明文 RTP;注册后由业务处理该包 fn(int $id, string $plainRtp, array $header, WebRTCServer $srv):void
$onmessage 收到 SCTP DataChannel 文本或二进制消息 fn(string $data, int $clientId, WebRTCServer $srv):void
$onLeave removeClient() 中的业务离开回调 fn(int $clientId, WebRTCServer $srv):void
$onClose 底层连接关闭回调 fn(int $clientId, WebRTCServer $srv):void

上下文 $ctx 字段说明

  • onPublisher $ctx:
    [
      'streamId'   => (string),
      'localSsrc'  => ['video' => int, 'audio' => int],  // SDK 发给远端的 SSRC
      'videoPTs'   => [int => array], // PT => rtpmap/codec/clock/fmtp 等信息
      'audioPTs'   => [int => array],
    ]
  • onSubscriber $ctx:
    [
      'streamId'     => (string),
      'pushClientId' => int  // 同 streamId 当前 push 端 clientId;不存在时不触发回调
    ]
  • onMediaConnected $rtpHeader:
    [
      'pt' => int,          // RTP Payload Type
      'seq' => int,         // 序列号
      'ts' => int,          // RTP timestamp
      'ssrc' => int,        // 同步源
      'payloadLen' => int,  // RTP 载荷长度 (不含头)
    ]

使用示例:加入房间前鉴权

onJoin 支持通过 $handled 阻止默认 join 流程,适合在写入 role、streamId 和生成 Subscriber Offer 前完成鉴权:

$verifyToken = function (string $token): bool {
    // 替换为业务自己的数据库、Redis 或 JWT 校验。
    return $token !== '';
};

$server->onJoin = function (
    int $clientId,
    array $msg,
    WebRTCServer $srv,
    &$handled
) use ($verifyToken) {
    if ($verifyToken((string)($msg['token'] ?? ''))) {
        return;
    }

    $handled = true;
    $srv->sendSignaling($clientId, [
        'type' => 'error',
        'msg' => '鉴权失败',
    ]);
};

并发上限应由业务根据压测结果配置;仓库不对固定订阅者数量作容量承诺。

使用示例:私有加密信令

$server->onSignaling = function (int $id, array $msg, WebRTCServer $srv, &$handled) {
    $raw = $msg['_enc'] ?? null;
    if ($raw === null) return; // 不是私有加密消息 → 走默认 JSON 协议

    $secret = my_get_secret_for_client($id); // 业务自行实现
    $plain = my_aes_decrypt(base64_decode($raw), $secret);
    $realMsg = json_decode($plain, true);

    // 业务自己处理 → 告诉 SDK 跳过默认
    $handled = true;
    my_business_dispatch($id, $realMsg, $srv);
};

常用公共 API

客户端元数据

方法 作用
getClientIds(): array 返回当前所有 clientId
&getClientMeta(int $clientId, ?string $key=null, $default=null) 读取字段;$key=null 时按引用返回整个 metadata 数组
setClientMeta(int $clientId, string $key, $value): bool 写入业务字段;clientId 不存在时返回 false
getClientsByMeta(string $key, $value=null): array 根据 metadata 筛选 clientId
getPublisherIdByStreamId(string $streamId): ?int 查找指定 streamId 下 role=push 的 clientId
getClientTrackInfo(int $clientId): array 获取客户端 PT 和本地 SSRC 映射

信令和 DataChannel

方法 作用
sendSignaling(int $clientId, array $msg): bool WebSocket 发送 JSON 信令给一个客户端
broadcastSignaling(array $clientIds, array $msg): int WebSocket 广播给多个客户端,返回成功数
sendDataChannel(int $clientId, string $message, int $ppid=51, int $sid=0) 发送 DataChannel 消息;当前实现返回 bool,但未声明返回类型
getClientsWithDataChannel(array $excludeIds=[]): array 返回 SCTP 已建立的客户端列表
broadcastDataChannel(array $clientIds, string $message, int $ppid=51, int $sid=0): int 批量发送 DataChannel 消息,返回成功数

SFU 订阅者 Offer 和媒体转发

方法 作用
makeSfuOfferForSubscriber(int $subscriberId, int $publisherId, string $setup='passive'): ?string 基于 Publisher Offer 生成 Subscriber 的 SFU Offer,默认让服务器作为 DTLS passive 端
forwardRtpToClient(int $targetClientId, string $plainRtp, bool $ssrcRewrite=true): bool 将明文 RTP 重写并加密后发给指定客户端
forwardRtpToAllSubscribers(string $streamId, string $plainRtp, int $excludeClientId=-1): int 给同 streamId 的所有 role=play 客户端分发,返回成功数

缺省 SFU 工作流程(零代码自动跑通)

  1. push.html 发送 join(role=push, streamId=X) → 服务端存 metadata
  2. push.htmlcreateOffer()setLocalDescription(offer) → 发送 {"type":"offer","sdp":...}
  3. 服务端 handleOffer()
    • 提取远端 ice-ufrag / ice-pwd / setup
    • generateAnswerSDP() 生成 answer,填好 serverVideoSsrc / serverAudioSsrc / PT 表
    • 回发 answer → 触发 onOffer → 若 role=push → 触发 onPublisher
  4. play.html 发送 join(role=play, streamId=X)_fireSubscriberIfReady()
    • 若已有 pushClientId → 自动makeSfuOfferForSubscriber() 生成 SFU offer
    • sendSignaling(['type'=>'offer','sdp'=>...]) 发送给 play.html
  5. play.html 收到 offer → setRemoteDescriptioncreateAnswer → 回发 answer
  6. 服务端 handle answer → 提取 play 的 ice-ufrag/pwd 给 STUN / DTLS
  7. 两端 ICE 连通 → DTLS 握手 → SRTP keys 导出
  8. push 端 UDP 收 RTP:unprotect 成功 → onMediaConnected 触发 → forwardRtpToAllSubscribers(streamId) → 对每个 play 端:
    • 按 play 端 PT 重写 SSRC 为 serverVideoSsrc / serverAudioSsrc(与步骤 4 发的 offer 中的 SSRC 严格一致)
    • 用 play 端的 srtpTx protect → UDP send
  9. play 端 SRTP 解密 → 浏览器 video/audio tag 自动渲染

已知的问题

  • 编解码器固定为 H.264 + Opus
    为了最大化兼容主流浏览器、OBS 及常见移动设备,服务器目前仅支持 H.264 视频与 Opus 音频。
    若业务侧需引入 VP8、VP9 或 H.265 等编码格式,需自行扩展 SDP 协商逻辑及 RTP 转发规则。

  • OBS 推流偶发自动断开
    在高动态画面或网络波动场景下,OBS 推流可能意外终止。
    该问题与 libwebrtc 对 ICE consent、RTCP 反馈及带宽估计的严格要求有关,后续版本将持续优化 ICE 保活机制与 RTCP REMB 反馈以提升稳定性。
    临时缓解建议:在 OBS 输出设置中启用 CBR 码率控制,并适当降低视频码率与分辨率。

  • OBS 编码器兼容性限制
    当前已验证可正常推流的 OBS H.264 编码器:

    • x264
    • QuickSync H.264
    • AMD HW H.264 (AVC)
      这些编码器在 baselinemainhigh 三个 Profile 下均可成功播放。
      不兼容的编码器H264/AVC Encoder (AMD Advanced Media Framework) —— 使用该编码器可能导致推流失败或画面异常。
      如遇其他编码器问题,建议优先切换至上述已支持的编码器。
111 0 0
0个评论

songshu

750
积分
0
获赞数
0
粉丝数
2022-06-23 加入
🔝