SRS使用pion/webrtc做RTC的自动回归测试,pion实现了全部的协议栈,Go服务器虽然性能差太远,但是做测试工具非常适合。
回归测试中我们就可能需要修改各种包,比如:
- pion有Bug,STAP-A发送SPS和PPS时,Marker设置为了true,应该设置为false。那么我们可以截获即将发送的RTP包,修改后继续发送。
- 推流时,我们从文件读取音频流,但是没有audio-level信息,我们希望能设置RTP Header的扩展,把audio-level信息加进去。
- 计算收发的RTP包个数,来判断是否达到预期。计算上层函数的包,不如底层的合适。
- 主动丢包,模拟随机丢包,或者某些特征的网络,比如丢一大片。
- 模拟异常或特殊的RTP包,比如padding不正确等。
- 测试DTLS丢包的重传(ARQ)是否正常。
- 模拟客户端网络切换,比如端口变更,或者IP变更。
- 等等等等。
这些都需要对包的收发机制进行hijack,而pion中有两种方式。一种叫做interceptor拦截RTP/RTCP包;另外一种是vnet虚拟网络可模拟路由器能力,可对所有ICE/DTLS/RTP/RTCP甚至私有协议包拦截。这样就可以不用修改pion代码来实现非常复杂的包处理。
包计数器
先看一种简单的,计算我们发送了多少个RTP包。先定义一个Interceptor:
type CounterInterceptor struct {
interceptor.Interceptor
nnPackets uint64
}
// 定义RTP Writer,发送RTP包时,使用我们的函数,计数后,调用参数的writer发包
func (v *CounterInterceptor) BindLocalStream(info *interceptor.StreamInfo, writer interceptor.RTPWriter) interceptor.RTPWriter {
return interceptor.RTPWriterFunc(func(header *rtp.Header, payload []byte, attributes interceptor.Attributes) (int, error) {
v.nnPackets++
return writer.Write(header, payload, attributes)
})
}
// 注意其他函数应该返回参数中的reader或writer,这里省略了。
然后,将我们的interceptor注册进去就可以:
m := &webrtc.MediaEngine{}
if err := m.RegisterDefaultCodecs(); err != nil {
return nil, err
}
registry := &interceptor.Registry{}
if err := webrtc.RegisterDefaultInterceptors(m, registry); err != nil {
return nil, err
}
counter := &CounterInterceptor{}
registry.Add(counter)
api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(registry))
pc, err := api.NewPeerConnection(webrtc.Configuration{})
这样,我们就可以从counter中获取发送了多少RTP包。
Note: 如果需要修改包,也是一样的过程,修改完后,调用参数中的writer继续发送。
DTLS包劫持
interceptor只定义了RTCP和RTP包的截获,如果需要处理DTLS或ICE包,就没法用这种方式了。
我们可以通过vnet实现。vnet是pion定义的虚拟网络层,包括虚拟路由器vnet.Router
和虚拟网络vnet.Net
,可以模拟整个网络。
我们需要创建两个网络:
- 客户端一个
vnet.Net
虚拟网络,给客户端用。所有的收发包,都只在Router中流转。 - 服务器一个
vnet.UDPProxy
代理网络,给服务器用。这个vnet.UDPProxy
中,会自动创建了vnet.Net
,vnet.UDPConn
,以及系统的net.UDPConn
,然后启动协程将这两个对象的包互相转发。
Note: 关于UDPProxy,参考本文后面的
Annex A: vnet.UDPProxy
的详细介绍。
Note: 如何创建和使用
vnet.UDPProxy
呢?可以收到SRS的answer后,解析出candidate
,就是真实服务器的address了,然后就可以创建vnet.UDPProxy
。参考代码srs-bench。
我们重点看客户端设置。初始化客户端时,需要把vnet设置到api,这样客户端的底层网络都会走vnet.Router
,参考srs-bench:
// 这两个的设置,参考前面。
m := &webrtc.MediaEngine{}
webrtc.RegisterDefaultInterceptors(m, registry)
// 创建vnet.Router,默认会创建两个虚拟网卡lo和eth0
wan, err := vnet.NewRouter(&RouterConfig{
CIDR: "0.0.0.0/0",
LoggerFactory: logging.NewDefaultLoggerFactory(),
})
// 创建vnet.Net,也会创建两个虚拟网卡lo和eth0
nw := vnet.NewNet(&vnet.NetConfig{
StaticIP: "27.1.2.3",
})
// 将网络添加到Router中,建立联系
err = wan.AddNet(nw)
// 设置虚拟网络
s := webrtc.SettingEngine{}
s.SetVNet(nw)
api := webrtc.NewAPI(
webrtc.WithSettingEngine(s),
webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(registry),
)
pc, err := api.NewPeerConnection(webrtc.Configuration{})
vnet.Net
是和webrtc.API
绑定在一起的,当PC是由不同的webrtc.API
创建时,它们就可能在不同的vnet.Net
中。这些vnet.Net
是否必须在一个vnet.Router
中呢?如果有vnet.UDPProxy
就不用了。
比如我们在回归测试中,创建了一个推流的PeerConnection,创建一个拉流的PeerConnection,它们是否需要在一个
vnet.Router
上呢?不是必须的,如果是要测试它们直接互通,那就要在一个vnet.Router
;如果它们都是通过vnet.UDPProxy
和SRS通信,那就不需要。
在回归测试中,我们会启动多个PC,那么我们是否可以用同一个
webrtc.API
创建这些PC?还是可以用不同的webrtc.API
创建?两种都可以。可以把webrtc.API
认为是一个浏览器,一个浏览器可以创建多个;也可以用多个浏览器创建不同的PC。
如何过滤客户端的包呢?用ChunkFilter,相当于在路由器上直接丢包,使用vnet.Router.AddChunkFilter
添加回调就可以,参考srs-bench:
// AddChunkFilter adds a filter for chunks traversing this router.
// You may add more than one filter. The filters are called in the order of this method call.
// If a chunk is dropped by a filter, subsequent filter will not receive the chunk.
func (r *Router) AddChunkFilter(filter ChunkFilter) {
}
// ChunkFilter is a handler users can add to filter chunks.
// If the filter returns false, the packet will be dropped.
type ChunkFilter func(c Chunk) bool
我们截获到的客户端的包,就和网络抓包很像了,如下图所示:
Note: 这样我们就能截获所有的包,包括ICE和DTLS的包,当然也包括RTP和RTCP。也可以做复杂的丢包模拟了。
Annex A: vnet.UDPProxy
vnet是pion的虚拟网络,和interceptor定位是不同的:
- vnet在网络路由层,interceptor在应用层。
- vnet看到的类似IP包了,interceptor看到的是RTP/RTCP包。
- vnet可以设计路由和NAT,interceptor有应用层的流的信息。
vnet的设计目标:
To make NAT traversal tests easy.
To emulate packet impairment at application level for testing.
To monitor packets at specified arbitrary interfaces.
官网的架构介绍如下图。App即客户端,和Server就是服务器,它们都接入了vnet.Router
:
也就是说,客户端(App)一旦走了vnet,那么就再也不会给真实的服务器发包了,包只会在vnet.Router
之内流转。而我们要做的真实服务器的回归测试,是需要客户端的包在vnet.Router
中流转,这样我们才能截获,同时我们还要把包转发给真实的服务器才行。
所以,我们需要一个正向代理vnet.UDPProxy
,连接vnet和真实服务器。设计图如下:
可以看到:
- 客户端App,创建了一个
vnet.Net
虚拟网络,所有客户端的收发包,都只在Router中流转。 -
vnet.UDPProxy
和真实服务器(RealServer)通信,它创建了vnet.Net
,vnet.UDPConn
,以及系统的net.UDPConn
,然后启动协程将这两个对象的包互转。
Note:
vnet.UDPProxy
的使用,可以参考例子srs-bench/vnet,以及本文前面的例子。
这样vnet的架构就变成了下面的图:
Note: 我们正在提交一个UDPProxy的Patch,可以支持vnet和真实服务器打通。
Note: 使用
vnet.UDPProxy
,也可以很方便模拟客户端的网络地址切换,只要调用更换net.UDPConn
,换个地址或端口就可以。
Annex B:非vnet机制
我们先设置断点,看看DTLS ClientHello的发送过程:
// 最终的UDP发送函数
func (c *candidateBase) writeTo(raw []byte, dst Candidate) (int, error) {
n, err := c.conn.WriteTo(raw, dst.addr())
// 最终的UDP收包函数
func (c *candidateBase) recvLoop(initializedCh <-chan struct{}) {
for {
n, srcAddr, err := c.conn.ReadFrom(buffer)
handleInboundCandidateMsg(c, c, buffer[:n], srcAddr, log)
}
func handleInboundCandidateMsg(ctx context.Context, c Candidate, buffer []byte, srcAddr net.Addr, log logging.LeveledLogger) {
// ICE 包
if stun.IsMessage(buffer) {
// 非ICE包,比如DTLS包
if !c.agent().validateNonSTUNTraffic(c, srcAddr) {
我们设置在writeTo
,如果头两个字节是00 01
就是STUN,第一个字节是22
就是DTLS的ClientHello。调用栈如下:
github.com/pion/ice/v2.(*candidateBase).writeTo at candidate_base.go:308
github.com/pion/ice/v2.(*candidatePair).Write at candidatepair.go:86
github.com/pion/ice/v2.(*Conn).Write at transport.go:103
github.com/pion/webrtc/v3/internal/mux.(*Endpoint).Write at endpoint.go:42
github.com/pion/dtls/v2/internal/net/connctx.(*connCtx).Write at connctx.go:121
github.com/pion/dtls/v2.(*Conn).writePackets at conn.go:403
github.com/pion/dtls/v2.(*handshakeFSM).send at handshaker.go:223
github.com/pion/dtls/v2.(*handshakeFSM).Run at handshaker.go:156
github.com/pion/dtls/v2.(*Conn).handshake.func2 at conn.go:813
- Async stack trace
github.com/pion/dtls/v2.(*Conn).handshake at conn.go:811
Note: 可见是
dtls.Conn.handshake
中启动的一个coroutine,在dtls.handshakeFSM
中生成ClientHello,然后调用dtls.Conn.writePackets
。而dtls.Conn.nextConn.Write
,调用的是mux.Endpoint.Write
,由于ICE和DTLS复用的通道,所以调用的是ice.Connn.Write
,而ICE是pair所以调用了ice.candidatePair.Write
,最终调用到ice.candidateBase.writeTo
。
这个调用栈真实炒鸡长,一眼根本就不知道互相如何调用,中间还有很多interface的隐含匹配。
再看看这个ice.candidateBase.conn
是怎么创建的,它是net.PacketConn
接口,从调试看对象已经是*net.UDPConn
,直接的UDP收发对象了:
// 这个conn是在start时传过来的,设置断点后发现这是个异步函数。
func (c *candidateBase) start(a *Agent, conn net.PacketConn, initializedCh <-chan struct{}) {
c.conn = conn
// 上层实际上是ice.Agent.addCandidate
func (a *Agent) addCandidate(ctx context.Context, c Candidate, candidateConn net.PacketConn) error {
return a.run(ctx, func(ctx context.Context, agent *Agent) {
c.start(a, candidateConn, a.startedCh)
// 最终发现是收集本地地址时创建的,调用了函数listenUDPInPortRange
func (a *Agent) gatherCandidatesLocal(ctx context.Context, networkTypes []NetworkType) {
conn, err = listenUDPInPortRange(a.net, a.log, int(a.portmax), int(a.portmin), network, &net.UDPAddr{IP: ip, Port: 0})
if err := a.addCandidate(ctx, c, conn); err != nil {
继续看创建UDP对象的函数:
// 这个函数,调用的是vnet的函数
func listenUDPInPortRange(vnet *vnet.Net, log logging.LeveledLogger, portMax, portMin int, network string, laddr *net.UDPAddr) (vnet.UDPPacketConn, error) {
if (laddr.Port != 0) || ((portMin == 0) && (portMax == 0)) {
return vnet.ListenUDP(network, laddr)
}
for {
laddr = &net.UDPAddr{IP: laddr.IP, Port: portCurrent}
c, e := vnet.ListenUDP(network, laddr)
// 默认n.v是nil,调用的是系统默认的net.ListenUDP。
// 如果n.v非nil,调用的是n.v.ListenUDP
func (n *Net) ListenUDP(network string, locAddr *net.UDPAddr) (UDPPacketConn, error) {
if n.v == nil {
return net.ListenUDP(network, locAddr)
}
return n.v.listenUDP(network, locAddr)
}
由于我们没设置vnet,所以默认走的是系统的net.ListenUDP
,所以我们继续看下如何让vent生效:
// 只要config不为nil,就启用vnet,默认是nil
func NewNet(config *NetConfig) *Net {
return &Net{
v: v,
}
// 是需要在AgentConfig.Net中设置
func NewAgent(config *AgentConfig) (*Agent, error) {
a := &Agent{
net: config.Net,
if a.net == nil {
a.net = vnet.NewNet(nil)
// 最终是在
func (g *ICEGatherer) createAgent() error {
config := &ice.AgentConfig{
Net: g.api.settingEngine.vnet,
agent, err := ice.NewAgent(config)
可见只要创建api时指定vnet就可以:
// API bundles the global functions of the WebRTC and ORTC API.
// Some of these functions are also exported globally using the
// defaultAPI object. Note that the global version of the API
// may be phased out in the future.
type API struct {
settingEngine *SettingEngine
}
// SettingEngine allows influencing behavior in ways that are not
// supported by the WebRTC API. This allows us to support additional
// use-cases without deviating from the WebRTC API elsewhere.
type SettingEngine struct {
vnet *vnet.Net
}
// AgentConfig collects the arguments to ice.Agent construction into
// a single structure, for future-proofness of the interface
type AgentConfig struct {
// Net is the our abstracted network interface for internal development purpose only
// (see github.com/pion/transport/vnet)
Net *vnet.Net
}
// 创建自定义的vnet就可以
func WithSettingEngine(s SettingEngine) func(a *API) {
return func(a *API) {
a.settingEngine = &s
}
}
// 这部分就是公开的API了。
func NewAPI(options ...func(*API)) *API {
a := &API{}
for _, o := range options {
o(a)
}
if a.settingEngine == nil {
a.settingEngine = &SettingEngine{}
}
Annex C: vnet机制分析
vnet下,包是如何流转的?
// vnet下,ListenUDP不是返回系统对象,而是vnet.UDPConn对象
func (n *Net) ListenUDP(network string, locAddr *net.UDPAddr) (UDPPacketConn, error) {
return n.v.listenUDP(network, locAddr)
}
因此,我们只需要在这个对象的vnet.UDPConn.ReadFrom
和vnet.UDPConn.WriteTo
函数上,设置断点,可以知道堆栈。
我们先看发送包,比如,发送binding request
:
github.com/pion/transport/vnet.(*Router).push at router.go:393
github.com/pion/transport/vnet.(*vNet).write at net.go:299
github.com/pion/transport/vnet.(*UDPConn).WriteTo at conn.go:139
github.com/pion/ice/v2.(*candidateBase).writeTo at candidate_base.go:301
github.com/pion/ice/v2.(*Agent).sendSTUN at candidatepair.go:90
github.com/pion/ice/v2.(*Agent).sendBindingRequest at agent.go:957
github.com/pion/ice/v2.(*controllingSelector).PingCandidate at selection.go:159
github.com/pion/ice/v2.(*Agent).pingAllCandidates at agent.go:608
github.com/pion/ice/v2.(*controllingSelector).ContactCandidates at selection.go:65
github.com/pion/ice/v2.(*Agent).connectivityChecks.func1.1 at agent.go:504
github.com/pion/ice/v2.(*Agent).taskLoop at agent.go:222
- Async stack trace
github.com/pion/ice/v2.NewAgent at agent.go:349
写入Router队列后,就可以被我们的回调函数捕获到:
github.com/ossrs/srs-bench/srs.TestRTCServerDTLSArq.func2 at rtc_test.go:625
github.com/pion/transport/vnet.(*Router).processChunks at router.go:459
github.com/pion/transport/vnet.(*Router).Start.func1 at router.go:236
- Async stack trace
github.com/pion/transport/vnet.(*Router).Start at router.go:233
可是看到包的内容就是binding request
。如果我们返回了false
则包丢弃。如果返回true
则不丢弃,Router继续转发。转发时则判断是否在一个子网(能被路由)转发,还要目标地址也是vnet,否则就认为地址不可达(unreachable
):
// 客户端包送达Router,查找服务器的vnet,否则丢弃
func (r *Router) processChunks() (time.Duration, error) {
if r.ipv4Net.Contains(dstIP) { // 要求源和目标在一个子网。
var nic NIC // 目标也要是vnet才行,否则丢包
if nic, ok = r.nics[dstIP.String()]; !ok {
r.log.Debugf("[%s] %s unreachable", r.name, c.String())
continue
}
// 把包送给vnet处理
nic.onInboundChunk(c)
}
// 找到服务器的vnet后,还会判断是否有vnet.UDPConn,否则丢弃
func (v *vNet) onInboundChunk(c Chunk) {
v.mutex.Lock()
defer v.mutex.Unlock()
if c.Network() == udpString {
if conn, ok := v.udpConns.find(c.DestinationAddr()); ok {
conn.onInboundChunk(c)
}
}
}
Note: 如果我们需要将服务器vnet的包,发送到真实的SRS,那么就需要创建一个
vnet.UDPConn
,IP就是服务器的IP比如192.168.3.8
,port就是服务器的端口比如8000
。由于服务器是UDP端口复用的,我们可以直接创建,当然解析answer后创建会更通用。