chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
445
internal/sfu/pion_e2e_test.go
Normal file
445
internal/sfu/pion_e2e_test.go
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
package sfu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pion/dtls/v3"
|
||||
"github.com/pion/dtls/v3/pkg/crypto/selfsign"
|
||||
dtlsnet "github.com/pion/dtls/v3/pkg/net"
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/pion/srtp/v3"
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
var errClientDTLSStateMissing = errors.New("client dtls state missing")
|
||||
|
||||
// fakeTgcallsClient 按 tgcalls GroupNetworkManager 的角色契约模拟客户端:
|
||||
// ICE CONTROLLED(等待 SFU 发起连通性检查)、DTLS setup="passive"(server 角色,
|
||||
// 等 SFU 主动握手)、SRTP isClient=false。
|
||||
type fakeTgcallsClient struct {
|
||||
t *testing.T
|
||||
ufrag, pwd string
|
||||
cert tls.Certificate
|
||||
fingerprint string
|
||||
ssrc uint32
|
||||
groups []SsrcGroup
|
||||
|
||||
session *srtp.SessionSRTP
|
||||
write *srtp.WriteStreamSRTP
|
||||
closeFn []func()
|
||||
}
|
||||
|
||||
func newFakeTgcallsClient(t *testing.T, ssrc uint32) *fakeTgcallsClient {
|
||||
t.Helper()
|
||||
cert, err := selfsign.GenerateSelfSigned()
|
||||
if err != nil {
|
||||
t.Fatalf("client cert: %v", err)
|
||||
}
|
||||
fp, err := certificateFingerprint(cert.Certificate[0])
|
||||
if err != nil {
|
||||
t.Fatalf("client fingerprint: %v", err)
|
||||
}
|
||||
ufrag, _ := randomICEString(8)
|
||||
pwd, _ := randomICEString(24)
|
||||
return &fakeTgcallsClient{t: t, ufrag: ufrag, pwd: pwd, cert: cert, fingerprint: fp, ssrc: ssrc}
|
||||
}
|
||||
|
||||
func (c *fakeTgcallsClient) offer() ClientOffer {
|
||||
return ClientOffer{
|
||||
AudioSSRC: c.ssrc,
|
||||
Ufrag: c.ufrag,
|
||||
Pwd: c.pwd,
|
||||
FingerprintSHA256: c.fingerprint,
|
||||
SsrcGroups: c.groups,
|
||||
}
|
||||
}
|
||||
|
||||
// connect 完成 ICE(controlled)+DTLS(server)+SRTP 建链。
|
||||
func (c *fakeTgcallsClient) connect(ctx context.Context, answer ServerAnswer) error {
|
||||
agent, err := ice.NewAgent(&ice.AgentConfig{
|
||||
NetworkTypes: []ice.NetworkType{ice.NetworkTypeUDP4},
|
||||
CandidateTypes: []ice.CandidateType{ice.CandidateTypeHost},
|
||||
LocalUfrag: c.ufrag,
|
||||
LocalPwd: c.pwd,
|
||||
IncludeLoopback: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.closeFn = append(c.closeFn, func() { _ = agent.Close() })
|
||||
if err := agent.OnCandidate(func(ice.Candidate) {}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := agent.GatherCandidates(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, cand := range answer.Candidates {
|
||||
remote, err := ice.NewCandidateHost(&ice.CandidateHostConfig{
|
||||
Network: "udp",
|
||||
Address: cand.IP,
|
||||
Port: cand.Port,
|
||||
Component: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := agent.AddRemoteCandidate(remote); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
conn, err := agent.Accept(ctx, answer.Ufrag, answer.Pwd) // CONTROLLED
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
demux := newDemuxer(conn)
|
||||
c.closeFn = append(c.closeFn, demux.Close)
|
||||
dtlsRaw := demux.dtlsConn()
|
||||
dtlsConn, err := dtls.Server(dtlsnet.PacketConnFromConn(dtlsRaw), dtlsRaw.RemoteAddr(), &dtls.Config{
|
||||
Certificates: []tls.Certificate{c.cert},
|
||||
ClientAuth: dtls.RequireAnyClientCert,
|
||||
InsecureSkipVerify: true,
|
||||
ExtendedMasterSecret: dtls.RequireExtendedMasterSecret,
|
||||
SRTPProtectionProfiles: []dtls.SRTPProtectionProfile{
|
||||
dtls.SRTP_AEAD_AES_128_GCM,
|
||||
dtls.SRTP_AES128_CM_HMAC_SHA1_80,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hsCtx, hsCancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
err = dtlsConn.HandshakeContext(hsCtx)
|
||||
hsCancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, ok := dtlsConn.ConnectionState()
|
||||
if !ok {
|
||||
return errClientDTLSStateMissing
|
||||
}
|
||||
// 服务端证书指纹应等于信令面下发的 answer.FingerprintSHA256。
|
||||
gotFP, err := certificateFingerprint(state.PeerCertificates[0])
|
||||
if err != nil || normalizeFingerprint(gotFP) != normalizeFingerprint(answer.FingerprintSHA256) {
|
||||
return fmt.Errorf("sfu fingerprint mismatch: %v / %s vs %s", err, gotFP, answer.FingerprintSHA256)
|
||||
}
|
||||
profile, _ := dtlsConn.SelectedSRTPProtectionProfile()
|
||||
srtpConfig := &srtp.Config{}
|
||||
switch profile {
|
||||
case dtls.SRTP_AEAD_AES_128_GCM:
|
||||
srtpConfig.Profile = srtp.ProtectionProfileAeadAes128Gcm
|
||||
case dtls.SRTP_AES128_CM_HMAC_SHA1_80:
|
||||
srtpConfig.Profile = srtp.ProtectionProfileAes128CmHmacSha1_80
|
||||
default:
|
||||
return fmt.Errorf("unexpected srtp profile %v", profile)
|
||||
}
|
||||
if err := srtpConfig.ExtractSessionKeysFromDTLS(&state, false); err != nil {
|
||||
return err
|
||||
}
|
||||
c.session, err = srtp.NewSessionSRTP(demux.srtpConn(), srtpConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.write, err = c.session.OpenWriteStream()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *fakeTgcallsClient) close() {
|
||||
for i := len(c.closeFn) - 1; i >= 0; i-- {
|
||||
c.closeFn[i]()
|
||||
}
|
||||
}
|
||||
|
||||
// sendOpusPacket 构造带 audio-level 扩展(one-byte header,id=1)的 RTP 包。
|
||||
func (c *fakeTgcallsClient) sendOpusPacket(seq uint16, payload []byte, audioLevel byte) error {
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
PayloadType: 111, // opus
|
||||
SequenceNumber: seq,
|
||||
Timestamp: uint32(seq) * 960,
|
||||
SSRC: c.ssrc,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
pkt.Header.Extension = true
|
||||
pkt.Header.ExtensionProfile = 0xBEDE
|
||||
if err := pkt.Header.SetExtension(1, []byte{audioLevel}); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := pkt.Marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.write.Write(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
// sendVideoPacket 构造指定 ssrc 的视频 RTP 包(VP8 PT=100)。
|
||||
func (c *fakeTgcallsClient) sendVideoPacket(ssrc uint32, seq uint16) error {
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
PayloadType: 100, // VP8
|
||||
SequenceNumber: seq,
|
||||
Timestamp: uint32(seq) * 3000,
|
||||
SSRC: ssrc,
|
||||
},
|
||||
Payload: []byte{0x90, 0x00, byte(seq)},
|
||||
}
|
||||
raw, err := pkt.Marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.write.Write(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
// 视频选层:发布端三层 simulcast,SFU 只转发 SIM[0] 层(订阅端只为该 ssrc 建
|
||||
// 解码 sink,高层包客户端会丢弃,SFU 侧直接终结省下行)。
|
||||
func TestPionSFUForwardsOnlyBaseSimulcastLayer(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("e2e sfu test")
|
||||
}
|
||||
port := pickUDPPort(t)
|
||||
svc, err := NewPion(PionConfig{UDPPort: port, AdvertiseIP: "127.0.0.1", Logger: zaptest.NewLogger(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("new pion sfu: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const callID = int64(901)
|
||||
alice := newFakeTgcallsClient(t, 0x500)
|
||||
base := alice.ssrc + 1
|
||||
alice.groups = []SsrcGroup{
|
||||
{Semantics: "SIM", Sources: []uint32{base, base + 2, base + 4}},
|
||||
{Semantics: "FID", Sources: []uint32{base, base + 1}},
|
||||
{Semantics: "FID", Sources: []uint32{base + 2, base + 3}},
|
||||
{Semantics: "FID", Sources: []uint32{base + 4, base + 5}},
|
||||
}
|
||||
bob := newFakeTgcallsClient(t, 0x600)
|
||||
defer alice.close()
|
||||
defer bob.close()
|
||||
|
||||
answerA, err := svc.Join(ctx, callID, 1, EndpointMain, alice.offer())
|
||||
if err != nil {
|
||||
t.Fatalf("join alice: %v", err)
|
||||
}
|
||||
answerB, err := svc.Join(ctx, callID, 2, EndpointMain, bob.offer())
|
||||
if err != nil {
|
||||
t.Fatalf("join bob: %v", err)
|
||||
}
|
||||
errCh := make(chan error, 2)
|
||||
go func() { errCh <- alice.connect(ctx, answerA) }()
|
||||
go func() { errCh <- bob.connect(ctx, answerB) }()
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("client connect: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// alice 同时在 layer0(应转发)与 layer1(应被 SFU 终结)发包。
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
seq := uint16(1)
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = alice.sendVideoPacket(base, seq)
|
||||
_ = alice.sendVideoPacket(base+2, seq)
|
||||
seq++
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// bob 第一条转发流必须是 layer0;观察窗口内绝不能出现 layer1。
|
||||
type accepted struct{ ssrc uint32 }
|
||||
got := make(chan accepted, 4)
|
||||
go func() {
|
||||
for {
|
||||
stream, ssrc, err := bob.session.AcceptStream()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
got <- accepted{ssrc}
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
if _, err := stream.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
deadline := time.After(10 * time.Second)
|
||||
sawBase := false
|
||||
for !sawBase {
|
||||
select {
|
||||
case in := <-got:
|
||||
if in.ssrc == base+2 {
|
||||
t.Fatalf("higher simulcast layer %#x leaked through SFU", in.ssrc)
|
||||
}
|
||||
if in.ssrc == base {
|
||||
sawBase = true
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatalf("bob never received base layer stream")
|
||||
}
|
||||
}
|
||||
// 再观察一段时间确认高层不漏。
|
||||
quiet := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case in := <-got:
|
||||
if in.ssrc == base+2 {
|
||||
t.Fatalf("higher simulcast layer %#x leaked through SFU", in.ssrc)
|
||||
}
|
||||
case <-quiet:
|
||||
_ = svc.CloseRoom(ctx, callID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPionSFUForwardsOpusBetweenClients(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("e2e sfu test")
|
||||
}
|
||||
port := pickUDPPort(t)
|
||||
svc, err := NewPion(PionConfig{
|
||||
UDPPort: port,
|
||||
AdvertiseIP: "127.0.0.1",
|
||||
Logger: zaptest.NewLogger(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new pion sfu: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
const callID = int64(900)
|
||||
alice := newFakeTgcallsClient(t, 0xA11CE)
|
||||
bob := newFakeTgcallsClient(t, 0xB0B)
|
||||
defer alice.close()
|
||||
defer bob.close()
|
||||
|
||||
answerA, err := svc.Join(ctx, callID, 1, EndpointMain, alice.offer())
|
||||
if err != nil {
|
||||
t.Fatalf("join alice: %v", err)
|
||||
}
|
||||
if len(answerA.Candidates) != 1 || answerA.Candidates[0].Port != port {
|
||||
t.Fatalf("answer candidates = %+v", answerA.Candidates)
|
||||
}
|
||||
answerB, err := svc.Join(ctx, callID, 2, EndpointMain, bob.offer())
|
||||
if err != nil {
|
||||
t.Fatalf("join bob: %v", err)
|
||||
}
|
||||
// 两端建链(ICE prflx → DTLS 由 SFU 主动握手 → SRTP)。
|
||||
errCh := make(chan error, 2)
|
||||
go func() { errCh <- alice.connect(ctx, answerA) }()
|
||||
go func() { errCh <- bob.connect(ctx, answerB) }()
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatalf("client connect: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// alice 持续发包(SFU AcceptStream 在首包后建立读流)。
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
seq := uint16(1)
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = alice.sendOpusPacket(seq, []byte{0xDE, 0xAD, byte(seq)}, 0x7F)
|
||||
seq++
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// bob 收到来自 alice 的转发流:SSRC 不重写、扩展保留。
|
||||
acceptCtx, acceptCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer acceptCancel()
|
||||
type accepted struct {
|
||||
stream *srtp.ReadStreamSRTP
|
||||
ssrc uint32
|
||||
}
|
||||
got := make(chan accepted, 1)
|
||||
go func() {
|
||||
stream, ssrc, err := bob.session.AcceptStream()
|
||||
if err == nil {
|
||||
got <- accepted{stream, ssrc}
|
||||
}
|
||||
}()
|
||||
var inbound accepted
|
||||
select {
|
||||
case inbound = <-got:
|
||||
case <-acceptCtx.Done():
|
||||
t.Fatalf("bob never received forwarded stream")
|
||||
}
|
||||
if inbound.ssrc != alice.ssrc {
|
||||
t.Fatalf("forwarded ssrc = %#x, want alice %#x(SFU 不得重写 SSRC)", inbound.ssrc, alice.ssrc)
|
||||
}
|
||||
buf := make([]byte, 1500)
|
||||
n, err := inbound.stream.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("bob read: %v", err)
|
||||
}
|
||||
var pkt rtp.Packet
|
||||
if err := pkt.Unmarshal(buf[:n]); err != nil {
|
||||
t.Fatalf("unmarshal forwarded packet: %v", err)
|
||||
}
|
||||
if pkt.PayloadType != 111 || pkt.SSRC != alice.ssrc {
|
||||
t.Fatalf("forwarded packet = %+v", pkt.Header)
|
||||
}
|
||||
if ext := pkt.Header.GetExtension(1); len(ext) != 1 || ext[0] != 0x7F {
|
||||
t.Fatalf("audio-level extension lost: %v(speaking 指示依赖逐包透传)", ext)
|
||||
}
|
||||
|
||||
// 媒体面活性:双端都应在 alive 集合(alice 发包、bob 收包均计活性…bob 仅收不发,
|
||||
// 其活性来自 SFU 写出?写出不计;bob 至少 connected+握手活性在窗口内)。
|
||||
alive := svc.AliveUserIDs(callID)
|
||||
aliveSet := map[int64]bool{}
|
||||
for _, id := range alive {
|
||||
aliveSet[id] = true
|
||||
}
|
||||
if !aliveSet[1] {
|
||||
t.Fatalf("alice must be media-alive, got %v", alive)
|
||||
}
|
||||
|
||||
if err := svc.Leave(ctx, callID, 1, EndpointMain); err != nil {
|
||||
t.Fatalf("leave: %v", err)
|
||||
}
|
||||
if err := svc.CloseRoom(ctx, callID); err != nil {
|
||||
t.Fatalf("close room: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func pickUDPPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
if err != nil {
|
||||
t.Fatalf("pick port: %v", err)
|
||||
}
|
||||
port := conn.LocalAddr().(*net.UDPAddr).Port
|
||||
_ = conn.Close()
|
||||
return port
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue