接入自定义 RTC 服务
自定义 RTC 服务需自行部署和运维,通过 TiRTC 为设备提供实时音视频或媒体业务。
只有需要自行部署和运维 RTC 服务时,才需要阅读本页。客户端直接连接设备端时,参考连接设备。完整的接入步骤见概览。
核心概念
| 概念 | 含义 |
|---|---|
| 自定义 RTC 服务 | 自行部署和运维,通过 TiRTC 为设备提供实时音视频、命令或媒体业务的服务 |
| WHIP | WebRTC-HTTP Ingestion Protocol(WebRTC-HTTP 摄取协议)的缩写,是 IETF RFC 9725 定义的标准协议。设备端通过探鸽平台使用 WHIP 协议连接自定义 RTC 服务 |
| Region | TiRTC 服务区域。探鸽平台按设备所在 Region 选择已注册的自定义 RTC 服务地址 |
peer_id | 格式为 whips://{service_name}?{query} 的服务标识和连接参数 |
| 连接 Token | 绑定设备 ID、服务名和完整 Query 的签名凭证,用于认证创建会话的请求 |
架构
自定义 RTC 服务建连时序
下图展示从业务授权、设备发起连接,到自定义 RTC 服务接受 WHIP 建连请求的完整时序。业务授权服务和自定义 RTC 服务均需自行部署和运维。
服务端 SDK 架构
本指南使用服务端 SDK构建自定义 RTC 服务。下图展示自定义 RTC 服务与服务端 SDK 各 Go 包的关系。
服务端 SDK 的 Go Module 为 github.com/tangeai/tirtc-service-sdk/v2。本指南使用其 pkg/ 下的三个 Go 包:
| Go 包 | 职责 | 使用方式 |
|---|---|---|
tirtcx | 初始化 TiRTC 引擎,接受 WHIP 会话,处理连接、媒体与命令 | 自定义 RTC 服务的核心必选包 |
tirtcxauth | 签发和验证连接 Token,提供 HTTP Bearer 认证 | 业务授权服务可用于签发 Token;自定义 RTC 服务用于验签 |
whipecho | 在现有 HTTP Handler 中嵌入 Echo 诊断能力 | 可选,仅在联调和链路诊断时使用 |
开通与准备
部署前,请向探鸽智能提交以下信息以注册服务:
- 全局唯一的
service_name。 - 至少一对 Custom RTC Key ID 和 Custom RTC Key。Custom RTC Key 是 Ed25519 公钥;配对私钥需自行生成,并且只保存在业务授权服务中。
- 每个目标 Region 的公网 WHIP 地址。按设备能力使用 HTTP 或 HTTPS。
| Region | 区域 |
|---|---|
cn01 | 中国大陆 |
na01 | 美洲 |
ea01 | 亚洲 |
we01 | 欧洲 |
设备联调还需要测试设备的 device_id(设备标识)和 device_secret_key(设备密钥)。尚未取得服务注册信息或这两项设备身份信息时,请联系探鸽智能技术支持。
从下载服务端 SDK获取 tirtc-service-sdk/v2,并按该页面标注的版本准备 TiRTC C 库。
实现业务授权服务
业务授权服务用于校验设备的业务访问权限,生成 peer_id,并向设备签发连接 Token。设备将这两个参数传给 TiRtcWhipConnect,向目标服务发起连接。
签发设备连接参数
业务授权服务校验设备的访问权限后,为设备生成以下格式的 peer_id,并签发对应的连接 Token:
peer_id = whips://{service_name}?{raw_query}Query 必须使用标准 URL 编码,总长度不得超过 2048 字节。不要在 Query 中放置密码、私钥或长期凭证。
连接 Token 使用 Ed25519 签名,签名算法如下:
# CanonicalizeQuery 按 application/x-www-form-urlencoded 解析参数,将参数名按字节序升序排列,
# 保留同名参数值的顺序,并使用标准 URL Query 规则重新编码。
canonical_query = CanonicalizeQuery(raw_query)
query_digest = Hex(SHA256(canonical_query))
claims = {
sub: device_id,
scope: "connect:" + service_name + ":" + query_digest,
iss: custom_rtc_key_id,
iat: issued_at,
exp: expires_at
}
payload = Base64URL(JSON(claims))
signature = Ed25519Sign(private_key, payload)
token = "v1." + payload + "." + Base64URL(signature)可以使用 SDK 的 tirtcxauth 包完成上述 Token 签发。完整算法见 tirtcxauth 鉴权说明。
Token 在 exp 到期前可以重复使用,tirtcxauth 本身不执行防重放检查。设备 SDK 会自动保护 TiRtcWhipConnect 调用整体,避免该调用被其他设备重放。如需阻止对自定义 RTC 服务请求的直接重放,建议在 peer_id Query 中加入一次性业务参数,并由自定义 RTC 服务在首次使用后核销。
issuer, err := tirtcxauth.NewEd25519TokenIssuer(map[string]string{
customRtcKeyID: ed25519PrivateKey,
})
if err != nil {
return err
}
token, err := issuer.Issue(
serviceName,
customRtcKeyID,
deviceID,
rawQuery,
30*24*time.Hour,
)
if err != nil {
return err
}业务授权服务只向已授权设备返回匹配的 peer_id 和 Token。设备将二者传给 TiRtcWhipConnect(peer_id, token, ...);设备不应自行拼接 peer_id。设备 SDK 的初始化与回调处理见连接设备。
实现自定义 RTC 服务
自定义 RTC 服务需要支持 WHIP 协议,用于接收探鸽平台转发的 SDP Offer、创建会话资源并返回 SDP Answer。实现服务前,请先了解 TiRTC 的 WHIP 协议约束。
WHIP 协议约束
TiRTC 基于 IETF RFC 9725,当前使用非 Trickle ICE:探鸽平台根据 Region,将设备的完整 SDP Offer 和 Bearer Token 通过一次 HTTP POST 转发到自定义 RTC 服务;服务返回 SDP Answer 后,探鸽平台完成设备建连,媒体随后通过 TiRTC 传输。
| 能力 | 当前状态 | 实现说明 |
|---|---|---|
| SDP Offer/Answer over HTTP POST | 支持 | 自定义 RTC 服务接收 Offer 并返回 Answer |
Location 会话资源 | 支持 | 自定义 RTC 服务返回并维护会话资源 |
DELETE 结束会话 | 支持 | 自定义 RTC 服务实现 |
Trickle ICE / PATCH | 暂不支持 | — |
| ICE、DTLS、SRTP | 支持 | 由 tirtc-service-sdk 处理 |
成功创建资源时返回 201 Created、Content-Type: application/sdp、SDP Answer 和唯一的相对 Location。Location 中的会话 ID 应使用密码学安全随机数生成,并至少包含 128 bit 随机熵。DELETE <Location> 不要求 Authorization;无论资源刚被删除还是已经不存在,都返回 204 No Content。
| 状态码 | 含义 |
|---|---|
400 Bad Request | SDP 或请求参数无效 |
401 Unauthorized | 缺少 Bearer Token |
403 Forbidden | Token 验证失败 |
405 Method Not Allowed | 端点不支持该方法 |
415 Unsupported Media Type | Content-Type 不是 application/sdp |
500 Internal Server Error | 服务内部错误 |
初始化及启动 tirtc-service-sdk
初始化参数来自服务注册信息和部署环境:
| 参数 | 含义 |
|---|---|
serviceName | 服务注册确认的 service_name |
customRtcKeyId | 服务注册确认的 Custom RTC Key ID,用于选择验签公钥 |
customRtcKey | 与业务授权服务签发私钥配对、并已注册的 Ed25519 公钥 |
candidateIP | 可选。服务的公网 IP。服务在 NAT 后时需要配置 |
进程级 TiRTC 引擎只初始化和启动一次。tirtcx.Init 必须在调用 tirtcx 的其他 API 之前完成:
if err := tirtcx.Init(); err != nil {
return err
}
defer tirtcx.Uninit()
verifier, err := tirtcxauth.NewEd25519TokenVerifier(map[string]string{
customRtcKeyId: customRtcKey,
})
if err != nil {
return err
}
// auth 是一个 http 拦截器,用于验证 Bearer Token,并将 Claims 写入请求 Context。
auth := tirtcxauth.BearerInterceptor(serviceName, verifier)
// acceptor 用于接受 SDP Offer 并创建 WHIP Session。
acceptor := tirtcx.NewWhipAcceptor(candidateIP)
// 其他初始化代码
...
// 启动
if err := tirtcx.Start(context.Background()); err != nil {
return err
}
defer tirtcx.Stop()认证创建请求
只在创建会话的 POST Handler 外应用 BearerInterceptor:
mux.HandleFunc("POST /whip", auth(postWHIP))
mux.HandleFunc("DELETE /whip/resource/{session_id}", deleteResource)认证成功后,可从 Context 读取设备身份:
claims, ok := tirtcxauth.ClaimsFromContext(r.Context())接受 WHIP 会话
func (s *mediaService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 只接受 SDP Offer。
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || !strings.EqualFold(mediaType, "application/sdp") {
http.Error(w, "content-type must be application/sdp", http.StatusUnsupportedMediaType)
return
}
// 限制 SDP Offer 大小为 1 MiB。
offer, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 使用 16 字节密码学安全随机数生成不可预测的会话 ID。
sessionID, err := newSessionID()
if err != nil {
http.Error(w, "create session ID: "+err.Error(), http.StatusInternalServerError)
return
}
// 在 30 秒内接受 SDP Offer 并生成 SDP Answer。
acceptCtx, cancelAccept := context.WithTimeout(r.Context(), 30*time.Second)
// acceptor 在初始化时创建
whipSession, err := acceptor.WhipAccept(
acceptCtx,
offer,
tirtcx.ConnEventOptions{},
)
cancelAccept()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 保存会话,并在独立 Goroutine 中等待建连和处理业务媒体。
sessionCtx, cancelSession := context.WithCancel(context.Background())
session := &mediaSession{cancel: cancelSession, whipSession: whipSession}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
cancelSession()
_ = whipSession.Close()
http.Error(w, "service is shutting down", http.StatusServiceUnavailable)
return
}
s.sessions[sessionID] = session
s.wg.Add(1)
s.mu.Unlock()
go s.run(sessionCtx, sessionID, session)
// 返回 201、SDP Answer 和用于删除会话的 Location。
w.Header().Set("Content-Type", "application/sdp")
w.Header().Set("Location", "/whip/resource/"+sessionID)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(whipSession.AnswerSDP())
}
func newSessionID() (string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
return hex.EncodeToString(value[:]), nil
}管理连接生命周期
在独立 Goroutine 中等待连接:
connectCtx, cancelConnect := context.WithTimeout(sessionCtx, 30*time.Second)
defer cancelConnect()
defer session.whipSession.Close()
conn, err := session.whipSession.WaitConn(connectCtx)
if err != nil {
return
}
select {
case <-conn.Done():
if err := conn.Err(); err != nil {
// 异常终止
}
case <-sessionCtx.Done():
}WaitConn 可并发、重复调用。单个等待者超时不会关闭会话,因此应在等待前注册 WhipSession.Close(),确保建连超时、失败和正常断开都会释放会话。该方法可重复调用。
应用负责普通会话的注册表和 DELETE。DELETE 不做 Token 鉴权,随机会话 ID 即访问凭据。收到 DELETE <Location> 时,应先从注册表移除资源,再取消业务 Context 并关闭 WhipSession;未知或已删除的 ID 也返回 204 No Content。入口按设备能力支持 HTTP 或 HTTPS,并应限制请求速率、避免记录完整资源路径。
处理事件
只为需要消费的事件配置缓冲区。启用后必须持续读取,否则缓冲区满时事件会被丢弃。
订阅决策不再通过事件通道异步返回,而是由同步 Handler 决定:
options := tirtcx.ConnEventOptions{
AudioBuffer: 16,
VideoBuffer: 16,
SubscribeVideoHandler: func(streamID uint8) int {
return 0
},
}订阅 Handler 运行在原生回调中,必须快速返回且不能阻塞。使用 Conn.EventStats() 监控事件丢弃。
可选:嵌入 Echo 诊断能力
echo, err := whipecho.NewHTTPAdapter(
acceptor,
"/whip/echo/resource",
whipecho.HTTPOptions{},
)
if err != nil {
return err
}
mux.HandleFunc("POST /whip", auth(echo.Wrap(businessHandler)))
mux.HandleFunc(
"DELETE /whip/echo/resource/{session_id}",
echo.DeleteHandler(),
)whipecho 自动根据 _tg_mode=echo 参数拦截并管理 Echo 诊断会话。_tg_* 参数为探鸽保留参数,业务不应使用。 普通请求不会被 whipecho 拦截。
运行 Echo 诊断示例
tangeai/tirtc-service-sdk 仓库中的 examples/quick-start 是可直接运行的自定义 RTC 服务示例。它实现 Token 验证、WHIP 会话创建与删除、业务媒体测试、Echo 测试和退出清理。
设置服务注册时使用的验证参数:
export TIRTC_CUSTOM_RTC_KEY_ID='<Custom RTC Key ID>'
export TIRTC_CUSTOM_RTC_KEY='<已注册的 Ed25519 Custom RTC Key>'启动服务:
git clone https://github.com/tangeai/tirtc-service-sdk.git
cd tirtc-service-sdk/examples/quick-start
go run -tags tirtc_clib . \
-listen :8080 \
-service your_service_name \
-custom-rtc-key-id "$TIRTC_CUSTOM_RTC_KEY_ID" \
-custom-rtc-key "$TIRTC_CUSTOM_RTC_KEY" \
-candidate 203.0.113.10只有服务需要公布不同于本机网卡的公网地址时,才设置 -candidate。入口需要把 POST /whip、普通会话的 DELETE /whip/resource/{session_id} 和 Echo 会话的 DELETE /whip/echo/resource/{session_id} 转发到应用。
使用同一仓库中的 examples/whip-token-signer 为测试设备生成 Echo 连接参数:
export TIRTC_PRIVATE_KEY='<与已注册公钥配对的 Ed25519 私钥>'
cd tirtc-service-sdk/examples
go run ./whip-token-signer \
-service your_service_name \
-custom-rtc-key-id "$TIRTC_CUSTOM_RTC_KEY_ID" \
-private-key "$TIRTC_PRIVATE_KEY" \
-device-id your_device_id \
-query '_tg_mode=echo'将输出的 peer_id 和 token 用于设备联调。签发工具只用于开发和联调;生产环境必须由业务授权服务先执行授权校验。
使用 tirn_probe_device 验证
从 tangeai/tirn-probe-device 获取工具源码,使用匹配的 TiRTC C SDK 构建后运行:
./build/linux-x86_64/tirn_probe_device media \
--device-id your_device_id \
--device-secret-key your_device_secret_key \
--peer-id "$ECHO_PEER_ID" \
--token "$CONNECT_TOKEN" \
--audio-output /tmp/tirn-probe-echo.pcm \
--duration-sec 10media 子命令发送内置测试音频和测试帧,同时接收 Echo 返回的媒体。发送和接收的音视频四项计数均大于零时,工具输出“媒体联调通过”。tirn_probe_device 是联调工具,不是量产设备 SDK 或自定义 RTC 服务的组成部分。
部署要求
部署自定义 RTC 服务的 Linux 主机必须将本地端口范围设置为 12768 63999:
sudo sysctl -w net.ipv4.ip_local_port_range="12768 63999"需要持久生效时,在 /etc/sysctl.d/ 下增加配置:
net.ipv4.ip_local_port_range = 12768 63999然后执行 sudo sysctl --system。主机防火墙、云安全组及上游网络设备必须开放 12768-63999 范围内的全部 UDP 端口。
优雅退出
退出顺序:
- 停止接受新的 HTTP 请求。
- 关闭普通业务会话并等待 Goroutine。
- 如启用了 Echo,调用
echo.Shutdown(ctx)。 - 调用
tirtcx.Stop()和tirtcx.Uninit()。
上线时监控建连成功率、建连耗时、活跃连接、异常断开、发送失败和事件丢弃。Echo 可通过 Stats() 和 Observer 接入现有监控系统。
安全要求
- Ed25519 私钥只保存在业务授权服务的密钥管理系统中,不得下发到设备、自定义 RTC 服务或前端。
- 自定义 RTC 服务只需要验签公钥。完整 Token、私钥和随机会话路径不得写入日志、指标标签或错误响应。
- 使用 HTTP 时,Token 和随机会话 ID 不受传输加密保护,只能在设备确有需要且网络路径受控时启用。
- 对 Query 的长度、字符集和允许值进行校验;不要把 Query 当作可信输入。
常见问题
| 现象 | 检查项 |
|---|---|
| HTTP 401/403 | Access Key、公钥、Token 有效期、服务名和完整 Query |
| HTTP 413 | SDP Offer 是否超过入口或应用的大小限制 |
| HTTP 415 | 代理是否保留 Content-Type: application/sdp |
| 已返回 SDP 但连接失败 | UDP 端口、防火墙、NAT 和 Candidate 地址 |
| 收不到 Echo 媒体 | 是否使用 _tg_mode=echo,发送和接收事件是否持续消费 |
| 运行一段时间后事件缺失 | 事件缓冲区、Conn.EventStats() 和业务 Handler 是否阻塞 |
提交问题时请提供发生时间、Region、服务版本、TiRTC SDK 版本、脱敏后的 HTTP 状态与响应、服务日志以及 EventStats()。不要提供完整 Token、私钥或设备密钥。