如何模拟各种罕见场景,照亮服务器的暗角落

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.Netvnet.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.Netvnet.UDPConn,以及系统的net.UDPConn,然后启动协程将这两个对象的包互转。

Note: vnet.UDPProxy的使用,可以参考例子srs-bench/vnet,以及本文前面的例子。

这样vnet的架构就变成了下面的图:

如何模拟各种罕见场景,照亮服务器的暗角落_第1张图片

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.ReadFromvnet.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后创建会更通用。

你可能感兴趣的:(如何模拟各种罕见场景,照亮服务器的暗角落)