chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
43
internal/sfu/cert.go
Normal file
43
internal/sfu/cert.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package sfu
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/dtls/v3/pkg/crypto/selfsign"
|
||||
)
|
||||
|
||||
// newDTLSCertificate 生成进程级自签证书(DTLS 身份不靠 CA,靠信令面下发的指纹)。
|
||||
func newDTLSCertificate() (tls.Certificate, string, error) {
|
||||
cert, err := selfsign.GenerateSelfSigned()
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "", fmt.Errorf("sfu: self-signed cert: %w", err)
|
||||
}
|
||||
fp, err := certificateFingerprint(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "", err
|
||||
}
|
||||
return cert, fp, nil
|
||||
}
|
||||
|
||||
// certificateFingerprint 计算 RFC 4572 风格 sha-256 指纹("AA:BB:...")。
|
||||
func certificateFingerprint(der []byte) (string, error) {
|
||||
if _, err := x509.ParseCertificate(der); err != nil {
|
||||
return "", fmt.Errorf("sfu: parse cert: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(der)
|
||||
parts := make([]string, len(sum))
|
||||
for i, b := range sum {
|
||||
parts[i] = strings.ToUpper(hex.EncodeToString([]byte{b}))
|
||||
}
|
||||
return strings.Join(parts, ":"), nil
|
||||
}
|
||||
|
||||
// normalizeFingerprint 去除大小写/分隔差异后比较用。
|
||||
func normalizeFingerprint(fp string) string {
|
||||
return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(fp), ":", ""))
|
||||
}
|
||||
108
internal/sfu/demux.go
Normal file
108
internal/sfu/demux.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package sfu
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/pion/transport/v4/packetio"
|
||||
)
|
||||
|
||||
// demuxer 把 ICE conn 上的混合流量按 RFC 7983 首字节分发:
|
||||
// - [20,63] → DTLS(握手与应用数据)
|
||||
// - [128,191] → RTP/RTCP(再按 RTCP PT 200..207 细分)
|
||||
//
|
||||
// 写方向全部直写底层 ICE conn(同一 5 元组,RTCP-mux)。
|
||||
type demuxer struct {
|
||||
base net.Conn
|
||||
dtls *packetio.Buffer
|
||||
srtp *packetio.Buffer
|
||||
srtcp *packetio.Buffer
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newDemuxer(base net.Conn) *demuxer {
|
||||
d := &demuxer{
|
||||
base: base,
|
||||
dtls: packetio.NewBuffer(),
|
||||
srtp: packetio.NewBuffer(),
|
||||
srtcp: packetio.NewBuffer(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go d.readLoop()
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *demuxer) readLoop() {
|
||||
defer close(d.done)
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, err := d.base.Read(buf)
|
||||
if err != nil {
|
||||
_ = d.dtls.Close()
|
||||
_ = d.srtp.Close()
|
||||
_ = d.srtcp.Close()
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
first := buf[0]
|
||||
switch {
|
||||
case first >= 20 && first <= 63:
|
||||
_, _ = d.dtls.Write(buf[:n])
|
||||
case first >= 128 && first <= 191:
|
||||
if n >= 2 && isRTCPPayloadType(buf[1]) {
|
||||
_, _ = d.srtcp.Write(buf[:n])
|
||||
} else {
|
||||
_, _ = d.srtp.Write(buf[:n])
|
||||
}
|
||||
default:
|
||||
// STUN 已被 ICE 层消费;其余丢弃。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isRTCPPayloadType(pt byte) bool {
|
||||
// RTCP packet type 范围(SR/RR/SDES/BYE/APP/RTPFB/PSFB...)。
|
||||
return pt >= 192 && pt <= 223
|
||||
}
|
||||
|
||||
func (d *demuxer) Close() {
|
||||
_ = d.dtls.Close()
|
||||
_ = d.srtp.Close()
|
||||
_ = d.srtcp.Close()
|
||||
}
|
||||
|
||||
// demuxConn 把单一 buffer 包成 net.Conn:读取自 buffer,写直达底层。
|
||||
type demuxConn struct {
|
||||
buf *packetio.Buffer
|
||||
base net.Conn
|
||||
}
|
||||
|
||||
func (c *demuxConn) Read(b []byte) (int, error) {
|
||||
n, err := c.buf.Read(b)
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, packetio.ErrFull) {
|
||||
return n, err
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *demuxConn) Write(b []byte) (int, error) { return c.base.Write(b) }
|
||||
func (c *demuxConn) Close() error { return c.buf.Close() }
|
||||
func (c *demuxConn) LocalAddr() net.Addr { return c.base.LocalAddr() }
|
||||
func (c *demuxConn) RemoteAddr() net.Addr { return c.base.RemoteAddr() }
|
||||
|
||||
// SetDeadline/SetReadDeadline 透传给 packetio.Buffer:DTLS 握手依赖读超时
|
||||
// 推进重传与失败退出(pion/dtls v3 默认 Handshake 无限期阻塞,调用方必须
|
||||
// 在握手期间设硬上限,否则停滞的握手会把 goroutine 挂死)。
|
||||
func (c *demuxConn) SetDeadline(t time.Time) error { return c.buf.SetReadDeadline(t) }
|
||||
func (c *demuxConn) SetReadDeadline(t time.Time) error { return c.buf.SetReadDeadline(t) }
|
||||
|
||||
// 写方向直达底层 ICE conn,无队列可超时。
|
||||
func (c *demuxConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
func (d *demuxer) dtlsConn() net.Conn { return &demuxConn{buf: d.dtls, base: d.base} }
|
||||
func (d *demuxer) srtpConn() net.Conn { return &demuxConn{buf: d.srtp, base: d.base} }
|
||||
func (d *demuxer) srtcpConn() net.Conn { return &demuxConn{buf: d.srtcp, base: d.base} }
|
||||
51
internal/sfu/mediaplan_test.go
Normal file
51
internal/sfu/mediaplan_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package sfu
|
||||
|
||||
import "testing"
|
||||
|
||||
// 选层契约:订阅端只为 SIM[0](最低层)与其 FID RTX 伙伴建解码 sink,
|
||||
// SFU 只转发这两个视频 ssrc,其余层在 SFU 终结。
|
||||
func TestBuildMediaPlanSimulcast(t *testing.T) {
|
||||
offer := ClientOffer{
|
||||
AudioSSRC: 1000,
|
||||
SsrcGroups: []SsrcGroup{
|
||||
{Semantics: "SIM", Sources: []uint32{1001, 1003, 1005}},
|
||||
{Semantics: "FID", Sources: []uint32{1001, 1002}},
|
||||
{Semantics: "FID", Sources: []uint32{1003, 1004}},
|
||||
{Semantics: "FID", Sources: []uint32{1005, 1006}},
|
||||
},
|
||||
}
|
||||
plan := buildMediaPlan(offer)
|
||||
for _, ssrc := range []uint32{1000, 1001, 1002} {
|
||||
if !plan.shouldForward(ssrc) {
|
||||
t.Fatalf("ssrc %d must be forwarded (audio / SIM[0] / its RTX)", ssrc)
|
||||
}
|
||||
}
|
||||
for _, ssrc := range []uint32{1003, 1004, 1005, 1006} {
|
||||
if plan.shouldForward(ssrc) {
|
||||
t.Fatalf("ssrc %d must be dropped (higher simulcast layer)", ssrc)
|
||||
}
|
||||
}
|
||||
// 未声明 ssrc(探测/未来扩展)放行,订阅端安全丢弃。
|
||||
if !plan.shouldForward(9999) {
|
||||
t.Fatalf("undeclared ssrc must pass through")
|
||||
}
|
||||
}
|
||||
|
||||
// 单层发布(conference 模式):无 SIM 组、唯一 FID 组的第一个 ssrc 即主流。
|
||||
func TestBuildMediaPlanSingleLayer(t *testing.T) {
|
||||
plan := buildMediaPlan(ClientOffer{
|
||||
AudioSSRC: 2000,
|
||||
SsrcGroups: []SsrcGroup{{Semantics: "FID", Sources: []uint32{2001, 2002}}},
|
||||
})
|
||||
if !plan.shouldForward(2001) || !plan.shouldForward(2002) {
|
||||
t.Fatalf("single-layer media+rtx must be forwarded")
|
||||
}
|
||||
}
|
||||
|
||||
// 纯音频(无视频组):一切放行。
|
||||
func TestBuildMediaPlanAudioOnly(t *testing.T) {
|
||||
plan := buildMediaPlan(ClientOffer{AudioSSRC: 3000})
|
||||
if !plan.shouldForward(3000) || !plan.shouldForward(3001) {
|
||||
t.Fatalf("audio-only plan must forward everything")
|
||||
}
|
||||
}
|
||||
735
internal/sfu/pion.go
Normal file
735
internal/sfu/pion.go
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
package sfu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/datachannel"
|
||||
"github.com/pion/dtls/v3"
|
||||
dtlsnet "github.com/pion/dtls/v3/pkg/net"
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/logging"
|
||||
"github.com/pion/rtcp"
|
||||
"github.com/pion/sctp"
|
||||
"github.com/pion/srtp/v3"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PionConfig 是内嵌 SFU 的运行参数。
|
||||
type PionConfig struct {
|
||||
// UDPPort 是单 UDP 监听端口(pion ICE UDPMux,全部 endpoint 复用)。
|
||||
UDPPort int
|
||||
// AdvertiseIP 是写进下行 candidate 的客户端可达地址。⚠ 127.0.0.1 会让
|
||||
// 真机 ICE 永远连不上且无任何 RPC 错误(纯媒体面静默失败)。
|
||||
AdvertiseIP string
|
||||
Logger *zap.Logger
|
||||
// Touch 是媒体面活性回报钩子:对仍存活(近期 SRTP 收包/ICE 连通)的
|
||||
// endpoint 周期性刷新 group_call_participants.last_check_date,使 sweeper 的
|
||||
// 单一水位同时承载「心跳 ∨ 媒体活性」(P0-2 双过期判据的实现方式)。
|
||||
Touch func(callID, userID int64)
|
||||
// LivenessInterval 是活性回报周期(默认 15s,远小于 sweeper 的 45s TTL)。
|
||||
LivenessInterval time.Duration
|
||||
// ActivityWindow 判定 endpoint 媒体面存活的最近收包窗口(默认 25s)。
|
||||
ActivityWindow time.Duration
|
||||
}
|
||||
|
||||
// pionSFU 是 Service 的 pion 实现:full-ICE CONTROLLING + DTLS 主动握手(client
|
||||
// 角色,对应下行 setup:"active")+ SRTP 房间内原样转发(不重写 SSRC、保留
|
||||
// audio-level 等 RTP 扩展,speaking 指示由客户端渲染)。
|
||||
type pionSFU struct {
|
||||
cfg PionConfig
|
||||
log *zap.Logger
|
||||
udpConn net.PacketConn
|
||||
mux *ice.UDPMuxDefault
|
||||
cert tls.Certificate
|
||||
fingerprint string
|
||||
|
||||
mu sync.Mutex
|
||||
rooms map[int64]*room
|
||||
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type epKey struct {
|
||||
userID int64
|
||||
kind EndpointKind
|
||||
}
|
||||
|
||||
type room struct {
|
||||
callID int64
|
||||
endpoints map[epKey]*endpoint
|
||||
}
|
||||
|
||||
// mediaPlan 是一个发布 endpoint 的 ssrc 转发计划(Join 时从 ssrc-groups 算定):
|
||||
// 订阅端只为 SIM[0](最低层)建解码 sink——只转发该层与其 FID RTX 伙伴,
|
||||
// 其余 simulcast 层在 SFU 侧丢弃(客户端反正会丢,转发纯浪费下行)。
|
||||
type mediaPlan struct {
|
||||
audioSSRC uint32
|
||||
forward map[uint32]bool // 转发的视频 ssrc(SIM[0] 层 + 其 RTX)
|
||||
drop map[uint32]bool // 其余已声明视频 ssrc(高层及其 RTX)
|
||||
}
|
||||
|
||||
func buildMediaPlan(offer ClientOffer) mediaPlan {
|
||||
plan := mediaPlan{
|
||||
audioSSRC: offer.AudioSSRC,
|
||||
forward: make(map[uint32]bool),
|
||||
drop: make(map[uint32]bool),
|
||||
}
|
||||
var primary uint32
|
||||
for _, g := range offer.SsrcGroups {
|
||||
if g.Semantics == "SIM" && len(g.Sources) > 0 {
|
||||
primary = g.Sources[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
// 单层发布(conference 模式)没有 SIM 组:唯一组的第一个 ssrc 即主流。
|
||||
if primary == 0 && len(offer.SsrcGroups) == 1 && len(offer.SsrcGroups[0].Sources) > 0 {
|
||||
primary = offer.SsrcGroups[0].Sources[0]
|
||||
}
|
||||
for _, g := range offer.SsrcGroups {
|
||||
for i, ssrc := range g.Sources {
|
||||
keep := ssrc == primary ||
|
||||
(g.Semantics == "FID" && i == 1 && len(g.Sources) == 2 && g.Sources[0] == primary)
|
||||
if keep {
|
||||
plan.forward[ssrc] = true
|
||||
} else if !plan.forward[ssrc] {
|
||||
plan.drop[ssrc] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for ssrc := range plan.forward {
|
||||
delete(plan.drop, ssrc)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
// shouldForward 报告该 ssrc 的流是否进入扇出(true)或在 SFU 终结(false)。
|
||||
// 未声明的 ssrc 一律转发:音频、未来的探测流——订阅端对无 sink 的包安全丢弃。
|
||||
func (p mediaPlan) shouldForward(ssrc uint32) bool {
|
||||
return !p.drop[ssrc]
|
||||
}
|
||||
|
||||
type endpoint struct {
|
||||
callID int64
|
||||
userID int64
|
||||
kind EndpointKind
|
||||
plan mediaPlan
|
||||
sfu *pionSFU
|
||||
|
||||
agent *ice.Agent
|
||||
demux *demuxer
|
||||
closed chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
writeStream *srtp.WriteStreamSRTP
|
||||
rtcpWriter *srtp.WriteStreamSRTCP
|
||||
lastActivity time.Time
|
||||
connected bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewPion 启动内嵌 SFU:绑定 UDP 端口、生成进程级 DTLS 证书。
|
||||
func NewPion(cfg PionConfig) (Service, error) {
|
||||
if cfg.UDPPort <= 0 {
|
||||
return nil, fmt.Errorf("sfu: invalid udp port %d", cfg.UDPPort)
|
||||
}
|
||||
if cfg.AdvertiseIP == "" {
|
||||
cfg.AdvertiseIP = "127.0.0.1"
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = zap.NewNop()
|
||||
}
|
||||
if cfg.LivenessInterval <= 0 {
|
||||
cfg.LivenessInterval = 15 * time.Second
|
||||
}
|
||||
if cfg.ActivityWindow <= 0 {
|
||||
cfg.ActivityWindow = 25 * time.Second
|
||||
}
|
||||
udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: cfg.UDPPort})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sfu: listen udp %d: %w", cfg.UDPPort, err)
|
||||
}
|
||||
cert, fingerprint, err := newDTLSCertificate()
|
||||
if err != nil {
|
||||
_ = udpConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
s := &pionSFU{
|
||||
cfg: cfg,
|
||||
log: cfg.Logger,
|
||||
udpConn: udpConn,
|
||||
mux: ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: udpConn}),
|
||||
cert: cert,
|
||||
fingerprint: fingerprint,
|
||||
rooms: make(map[int64]*room),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
go s.livenessLoop(ctx)
|
||||
s.log.Info("sfu listening",
|
||||
zap.Int("udp_port", cfg.UDPPort),
|
||||
zap.String("advertise_ip", cfg.AdvertiseIP))
|
||||
if cfg.AdvertiseIP == "127.0.0.1" {
|
||||
s.log.Warn("TELESRV_SFU_ADVERTISE_IP 为 127.0.0.1:真机 ICE 将无法连接(纯媒体面静默失败),多设备联调必须设为宿主机 LAN IP")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *pionSFU) Enabled() bool { return true }
|
||||
|
||||
// Join 为参与者建立媒体 endpoint:本端生成独立 ufrag/pwd,ICE CONTROLLING 等待
|
||||
// 客户端 Binding Request(上行 JSON 无 candidates,对端地址靠 peer-reflexive 学得),
|
||||
// 连通后以 DTLS client 主动握手并建 SRTP 会话。
|
||||
func (s *pionSFU) Join(ctx context.Context, callID, userID int64, kind EndpointKind, offer ClientOffer) (ServerAnswer, error) {
|
||||
if offer.Ufrag == "" || offer.Pwd == "" {
|
||||
return ServerAnswer{}, fmt.Errorf("sfu: empty remote ice credentials")
|
||||
}
|
||||
localUfrag, err := randomICEString(8)
|
||||
if err != nil {
|
||||
return ServerAnswer{}, err
|
||||
}
|
||||
localPwd, err := randomICEString(24)
|
||||
if err != nil {
|
||||
return ServerAnswer{}, err
|
||||
}
|
||||
agent, err := ice.NewAgent(&ice.AgentConfig{
|
||||
NetworkTypes: []ice.NetworkType{ice.NetworkTypeUDP4},
|
||||
CandidateTypes: []ice.CandidateType{ice.CandidateTypeHost},
|
||||
UDPMux: s.mux,
|
||||
LocalUfrag: localUfrag,
|
||||
LocalPwd: localPwd,
|
||||
IncludeLoopback: true,
|
||||
LoggerFactory: &pionLoggerFactory{log: s.log.Named("ice")},
|
||||
})
|
||||
if err != nil {
|
||||
return ServerAnswer{}, fmt.Errorf("sfu: new ice agent: %w", err)
|
||||
}
|
||||
// pion 要求注册 OnCandidate 才能收集;候选地址由我们直接以
|
||||
// AdvertiseIP:UDPPort 写进下行 JSON,回调本身无需消费。
|
||||
if err := agent.OnCandidate(func(ice.Candidate) {}); err != nil {
|
||||
_ = agent.Close()
|
||||
return ServerAnswer{}, fmt.Errorf("sfu: on candidate: %w", err)
|
||||
}
|
||||
if err := agent.GatherCandidates(); err != nil {
|
||||
_ = agent.Close()
|
||||
return ServerAnswer{}, fmt.Errorf("sfu: gather candidates: %w", err)
|
||||
}
|
||||
ep := &endpoint{
|
||||
callID: callID,
|
||||
userID: userID,
|
||||
kind: kind,
|
||||
plan: buildMediaPlan(offer),
|
||||
sfu: s,
|
||||
agent: agent,
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
s.attachEndpoint(ep)
|
||||
go ep.run(offer)
|
||||
return ServerAnswer{
|
||||
Ufrag: localUfrag,
|
||||
Pwd: localPwd,
|
||||
FingerprintSHA256: s.fingerprint,
|
||||
Candidates: []Candidate{{
|
||||
IP: s.cfg.AdvertiseIP,
|
||||
Port: s.cfg.UDPPort,
|
||||
Protocol: "udp",
|
||||
Type: "host",
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *pionSFU) attachEndpoint(ep *endpoint) {
|
||||
key := epKey{userID: ep.userID, kind: ep.kind}
|
||||
s.mu.Lock()
|
||||
rm, ok := s.rooms[ep.callID]
|
||||
if !ok {
|
||||
rm = &room{callID: ep.callID, endpoints: make(map[epKey]*endpoint)}
|
||||
s.rooms[ep.callID] = rm
|
||||
}
|
||||
old := rm.endpoints[key]
|
||||
rm.endpoints[key] = ep
|
||||
var oldPresentation *endpoint
|
||||
if ep.kind == EndpointMain {
|
||||
// 主连接 rejoin:旧 presentation 登记一并作废(信令侧同样清 presentation_json,
|
||||
// 客户端随后重发 joinGroupCallPresentation)。
|
||||
pk := epKey{userID: ep.userID, kind: EndpointPresentation}
|
||||
oldPresentation = rm.endpoints[pk]
|
||||
delete(rm.endpoints, pk)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if old != nil {
|
||||
old.close() // rejoin:替换旧 endpoint
|
||||
}
|
||||
if oldPresentation != nil {
|
||||
oldPresentation.close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *pionSFU) Leave(_ context.Context, callID, userID int64, kind EndpointKind) error {
|
||||
var victims []*endpoint
|
||||
s.mu.Lock()
|
||||
if rm, ok := s.rooms[callID]; ok {
|
||||
keys := []epKey{{userID: userID, kind: kind}}
|
||||
if kind == EndpointMain {
|
||||
// 整体离会从不补发 leaveGroupCallPresentation:主 endpoint 离开联动拆屏幕。
|
||||
keys = append(keys, epKey{userID: userID, kind: EndpointPresentation})
|
||||
}
|
||||
for _, key := range keys {
|
||||
if ep := rm.endpoints[key]; ep != nil {
|
||||
victims = append(victims, ep)
|
||||
delete(rm.endpoints, key)
|
||||
}
|
||||
}
|
||||
if len(rm.endpoints) == 0 {
|
||||
delete(s.rooms, callID)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, ep := range victims {
|
||||
ep.close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *pionSFU) CloseRoom(_ context.Context, callID int64) error {
|
||||
s.mu.Lock()
|
||||
rm := s.rooms[callID]
|
||||
delete(s.rooms, callID)
|
||||
s.mu.Unlock()
|
||||
if rm != nil {
|
||||
for _, ep := range rm.endpoints {
|
||||
ep.close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *pionSFU) AliveUserIDs(callID int64) []int64 {
|
||||
cutoff := time.Now().Add(-s.cfg.ActivityWindow)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rm, ok := s.rooms[callID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]bool)
|
||||
var out []int64
|
||||
for key, ep := range rm.endpoints {
|
||||
// 任一连接(主/屏幕)存活即视为该参与者媒体面存活。
|
||||
if !seen[key.userID] && ep.aliveSince(cutoff) {
|
||||
seen[key.userID] = true
|
||||
out = append(out, key.userID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// livenessLoop 周期性把媒体面存活回报给信令侧(刷新保活水位)。
|
||||
func (s *pionSFU) livenessLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(s.cfg.LivenessInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.cfg.Touch == nil {
|
||||
continue
|
||||
}
|
||||
cutoff := time.Now().Add(-s.cfg.ActivityWindow)
|
||||
type key struct{ callID, userID int64 }
|
||||
seen := make(map[key]bool)
|
||||
var alive []key
|
||||
s.mu.Lock()
|
||||
for callID, rm := range s.rooms {
|
||||
for ek, ep := range rm.endpoints {
|
||||
k := key{callID, ek.userID}
|
||||
if !seen[k] && ep.aliveSince(cutoff) {
|
||||
seen[k] = true
|
||||
alive = append(alive, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, k := range alive {
|
||||
s.cfg.Touch(k.callID, k.userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// forwardRTP 把一条解密后的 RTP 包原样转发给房间内其他参与者的**主** endpoint
|
||||
// (不重写 SSRC,扩展头随包透传——audio-level 即 speaking 指示数据源;
|
||||
// 观看屏幕共享也走观看者的主连接,presentation 连接不收任何远端流)。
|
||||
func (s *pionSFU) forwardRTP(from *endpoint, packet []byte) {
|
||||
for _, ep := range s.mainTargets(from) {
|
||||
ep.writeRTP(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// mainTargets 返回房间内除 from 所属参与者外的全部主 endpoint。
|
||||
func (s *pionSFU) mainTargets(from *endpoint) []*endpoint {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rm, ok := s.rooms[from.callID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
targets := make([]*endpoint, 0, len(rm.endpoints))
|
||||
for key, ep := range rm.endpoints {
|
||||
if key.userID != from.userID && key.kind == EndpointMain {
|
||||
targets = append(targets, ep)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// publisherBySSRC 找到房间内声明了该 ssrc(音频/任一视频层/RTX)的发布 endpoint,
|
||||
// 供 RTCP 反馈(PLI/NACK)按 media ssrc 路由回发布端。房间规模小,直接遍历。
|
||||
func (s *pionSFU) publisherBySSRC(callID int64, ssrc uint32) *endpoint {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rm, ok := s.rooms[callID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, ep := range rm.endpoints {
|
||||
if ep.plan.audioSSRC == ssrc || ep.plan.forward[ssrc] || ep.plan.drop[ssrc] {
|
||||
return ep
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- endpoint ----
|
||||
|
||||
func (ep *endpoint) run(offer ClientOffer) {
|
||||
s := ep.sfu
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
// CONTROLLING:主动发起连通性检查;对端候选靠带 ufrag/pwd 认证的
|
||||
// Binding Request 学得(peer-reflexive)。
|
||||
conn, err := ep.agent.Dial(ctx, offer.Ufrag, offer.Pwd)
|
||||
if err != nil {
|
||||
s.log.Debug("sfu ice dial", zap.Int64("call_id", ep.callID), zap.Int64("user_id", ep.userID), zap.Error(err))
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
ep.markActivity()
|
||||
ep.demux = newDemuxer(conn)
|
||||
|
||||
dtlsConfig := &dtls.Config{
|
||||
Certificates: []tls.Certificate{s.cert},
|
||||
InsecureSkipVerify: true, // 身份校验走指纹(信令面 commit),非 CA 链
|
||||
ExtendedMasterSecret: dtls.RequireExtendedMasterSecret,
|
||||
SRTPProtectionProfiles: []dtls.SRTPProtectionProfile{
|
||||
dtls.SRTP_AEAD_AES_128_GCM,
|
||||
dtls.SRTP_AES128_CM_HMAC_SHA1_80,
|
||||
},
|
||||
}
|
||||
dtlsRaw := ep.demux.dtlsConn()
|
||||
dtlsConn, err := dtls.Client(dtlsnet.PacketConnFromConn(dtlsRaw), dtlsRaw.RemoteAddr(), dtlsConfig)
|
||||
if err != nil {
|
||||
s.log.Debug("sfu dtls client", zap.Int64("user_id", ep.userID), zap.Error(err))
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
// pion/dtls v3 构造器是惰性握手(首次读写才握手):必须显式 HandshakeContext
|
||||
// 完成并设硬上限,否则 ConnectionState 拿不到状态、停滞的握手会挂死 goroutine。
|
||||
hsCtx, hsCancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
err = dtlsConn.HandshakeContext(hsCtx)
|
||||
hsCancel()
|
||||
if err != nil {
|
||||
s.log.Debug("sfu dtls handshake", zap.Int64("user_id", ep.userID), zap.Error(err))
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
// 指纹核验:DTLS 证书必须与 join JSON 承诺的 sha-256 指纹一致。
|
||||
state, ok := dtlsConn.ConnectionState()
|
||||
if !ok || len(state.PeerCertificates) == 0 {
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
gotFP, err := certificateFingerprint(state.PeerCertificates[0])
|
||||
if err != nil || normalizeFingerprint(gotFP) != normalizeFingerprint(offer.FingerprintSHA256) {
|
||||
s.log.Warn("sfu dtls fingerprint mismatch", zap.Int64("user_id", ep.userID))
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
profile, ok := dtlsConn.SelectedSRTPProtectionProfile()
|
||||
if !ok {
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
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:
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
// SFU 是 DTLS client:isClient=true 决定 SRTP 读写密钥方向。
|
||||
if err := srtpConfig.ExtractSessionKeysFromDTLS(&state, true); err != nil {
|
||||
s.log.Debug("sfu srtp keys", zap.Error(err))
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
session, err := srtp.NewSessionSRTP(ep.demux.srtpConn(), srtpConfig)
|
||||
if err != nil {
|
||||
s.log.Debug("sfu srtp session", zap.Error(err))
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
writeStream, err := session.OpenWriteStream()
|
||||
if err != nil {
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
// RTCP 升级为 SRTCP session(同一份 srtpConfig 复用):读侧做 PLI/NACK 路由,
|
||||
// 写侧供把反馈转投给发布端。注意:建了 SessionSRTCP 后绝不能再裸读
|
||||
// srtcpConn(双读互偷包)。
|
||||
rtcpSession, err := srtp.NewSessionSRTCP(ep.demux.srtcpConn(), srtpConfig)
|
||||
if err != nil {
|
||||
s.log.Debug("sfu srtcp session", zap.Error(err))
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
rtcpWriter, err := rtcpSession.OpenWriteStream()
|
||||
if err != nil {
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
ep.mu.Lock()
|
||||
ep.writeStream = writeStream
|
||||
ep.rtcpWriter = rtcpWriter
|
||||
ep.connected = true
|
||||
ep.mu.Unlock()
|
||||
ep.markActivity()
|
||||
s.log.Info("sfu endpoint connected",
|
||||
zap.Int64("call_id", ep.callID),
|
||||
zap.Int64("user_id", ep.userID),
|
||||
zap.Int("kind", int(ep.kind)))
|
||||
|
||||
go ep.serveRTCP(rtcpSession)
|
||||
// SCTP 数据通道骑在同一条 DTLS 连接上(握手后的 application data 即 SCTP 包)。
|
||||
go ep.serveData(dtlsConn)
|
||||
|
||||
// 入站 SRTP:按 SSRC 接收流。转发/终结决策在流粒度一次性做出
|
||||
//(AcceptStream 即按 SSRC 分流,selective forwarding 零每包开销)。
|
||||
for {
|
||||
stream, ssrc, err := session.AcceptStream()
|
||||
if err != nil {
|
||||
ep.close()
|
||||
return
|
||||
}
|
||||
if ep.plan.shouldForward(ssrc) {
|
||||
go ep.readStream(stream, ssrc)
|
||||
} else {
|
||||
// 非转发层(高层 simulcast 及其 RTX):必须持续排空防 buffer 堆积。
|
||||
go ep.drainStream(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *endpoint) readStream(stream *srtp.ReadStreamSRTP, ssrc uint32) {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, err := stream.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ep.markActivity()
|
||||
packet := make([]byte, n)
|
||||
copy(packet, buf[:n])
|
||||
ep.sfu.forwardRTP(ep, packet)
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *endpoint) drainStream(stream *srtp.ReadStreamSRTP) {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
if _, err := stream.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
ep.markActivity()
|
||||
}
|
||||
}
|
||||
|
||||
// serveRTCP 消费本 endpoint 的入站 RTCP 并路由:
|
||||
// - PLI / FIR / NACK(订阅端反馈)→ 按 media ssrc 找到发布 endpoint 转投——
|
||||
// 恒等转发下订阅端反馈里的 ssrc 就是发布端的原始 ssrc,无需改写;
|
||||
// 发布端自带 RTX 重传与 300ms keyframe 限流;
|
||||
// - SR(发布端报告)→ 转发给其他主 endpoint(A/V 同步的 NTP↔RTP 映射来源);
|
||||
// - RR / REMB / transport-cc:丢弃(v1 不做带宽自适应;layer0 所需码率
|
||||
// 远低于发布端 400kbps 起始值,不会饿死)。
|
||||
func (ep *endpoint) serveRTCP(session *srtp.SessionSRTCP) {
|
||||
for {
|
||||
stream, _, err := session.AcceptStream()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, err := stream.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ep.markActivity()
|
||||
pkts, err := rtcp.Unmarshal(buf[:n])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ep.routeRTCP(pkts)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *endpoint) routeRTCP(pkts []rtcp.Packet) {
|
||||
for _, pkt := range pkts {
|
||||
switch fb := pkt.(type) {
|
||||
case *rtcp.PictureLossIndication:
|
||||
ep.sfu.writeRTCPToPublisher(ep.callID, fb.MediaSSRC, pkt)
|
||||
case *rtcp.FullIntraRequest:
|
||||
ep.sfu.writeRTCPToPublisher(ep.callID, fb.MediaSSRC, pkt)
|
||||
case *rtcp.TransportLayerNack:
|
||||
ep.sfu.writeRTCPToPublisher(ep.callID, fb.MediaSSRC, pkt)
|
||||
case *rtcp.SenderReport:
|
||||
for _, target := range ep.sfu.mainTargets(ep) {
|
||||
target.writeRTCP(pkt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *pionSFU) writeRTCPToPublisher(callID int64, mediaSSRC uint32, pkt rtcp.Packet) {
|
||||
publisher := s.publisherBySSRC(callID, mediaSSRC)
|
||||
if publisher != nil {
|
||||
publisher.writeRTCP(pkt)
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *endpoint) writeRTCP(pkt rtcp.Packet) {
|
||||
ep.mu.Lock()
|
||||
w := ep.rtcpWriter
|
||||
ep.mu.Unlock()
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
raw, err := rtcp.Marshal([]rtcp.Packet{pkt})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
|
||||
// serveData 在 DTLS 连接上被动承接 SCTP 关联与 DCEP data channel:
|
||||
// - 客户端总是 SCTP INIT 与 DATA_CHANNEL_OPEN 的发起方(sid=0, label="data"),
|
||||
// SFU 绝不主动 OPEN(客户端没有 accept 入站 OPEN 的逻辑);
|
||||
// - 关联被客户端重建(restartDataChannel)时在同一 DTLS 上重新 accept;
|
||||
// - 数据通道上没有任何消息是音视频工作的硬前置:v1 只消费(记录)
|
||||
// ReceiverVideoConstraints,其余忽略。
|
||||
func (ep *endpoint) serveData(conn *dtls.Conn) {
|
||||
logf := logging.NewDefaultLoggerFactory()
|
||||
logf.DefaultLogLevel = logging.LogLevelError
|
||||
for {
|
||||
select {
|
||||
case <-ep.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
assoc, err := sctp.Server(sctp.Config{
|
||||
NetConn: conn,
|
||||
LoggerFactory: logf,
|
||||
})
|
||||
if err != nil {
|
||||
return // DTLS 关闭/endpoint 拆除
|
||||
}
|
||||
for {
|
||||
dc, err := datachannel.Accept(assoc, &datachannel.Config{LoggerFactory: logf})
|
||||
if err != nil {
|
||||
break // 关联结束:外层重试承接客户端重建的新关联
|
||||
}
|
||||
go ep.serveDataChannel(dc)
|
||||
}
|
||||
_ = assoc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *endpoint) serveDataChannel(dc *datachannel.DataChannel) {
|
||||
defer func() { _ = dc.Close() }()
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
n, isString, err := dc.ReadDataChannel(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !isString || n == 0 {
|
||||
continue
|
||||
}
|
||||
ep.markActivity()
|
||||
var msg struct {
|
||||
ColibriClass string `json:"colibriClass"`
|
||||
}
|
||||
if err := json.Unmarshal(buf[:n], &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
// v1:ReceiverVideoConstraints 仅记录(转发策略固定为 SIM[0] 层广播,
|
||||
// 客户端对未订阅 endpoint 的包安全丢弃);选层/按订阅裁剪留下一轮。
|
||||
ep.sfu.log.Debug("sfu data channel message",
|
||||
zap.Int64("call_id", ep.callID),
|
||||
zap.Int64("user_id", ep.userID),
|
||||
zap.String("colibri_class", msg.ColibriClass))
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *endpoint) writeRTP(packet []byte) {
|
||||
ep.mu.Lock()
|
||||
ws := ep.writeStream
|
||||
ep.mu.Unlock()
|
||||
if ws == nil {
|
||||
return
|
||||
}
|
||||
_, _ = ws.Write(packet)
|
||||
}
|
||||
|
||||
func (ep *endpoint) markActivity() {
|
||||
ep.mu.Lock()
|
||||
ep.lastActivity = time.Now()
|
||||
ep.mu.Unlock()
|
||||
}
|
||||
|
||||
func (ep *endpoint) aliveSince(cutoff time.Time) bool {
|
||||
ep.mu.Lock()
|
||||
defer ep.mu.Unlock()
|
||||
return ep.connected && ep.lastActivity.After(cutoff)
|
||||
}
|
||||
|
||||
func (ep *endpoint) close() {
|
||||
ep.closeOnce.Do(func() {
|
||||
close(ep.closed)
|
||||
if ep.demux != nil {
|
||||
ep.demux.Close()
|
||||
}
|
||||
_ = ep.agent.Close()
|
||||
ep.mu.Lock()
|
||||
ep.connected = false
|
||||
ep.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// pionLoggerFactory 把 pion 日志降为静默(按需可接 zap)。
|
||||
type pionLoggerFactory struct{ log *zap.Logger }
|
||||
|
||||
func (f *pionLoggerFactory) NewLogger(scope string) logging.LeveledLogger {
|
||||
return logging.NewDefaultLoggerFactory().NewLogger(scope)
|
||||
}
|
||||
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
|
||||
}
|
||||
140
internal/sfu/sfu.go
Normal file
140
internal/sfu/sfu.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// Package sfu 是群通话媒体面(Selective Forwarding Unit)的接口与实现。
|
||||
//
|
||||
// 角色契约(来自 tgcalls GroupNetworkManager 的硬性要求):客户端 ICE=CONTROLLED、
|
||||
// DTLS setup="passive"(等待握手);SFU 必须跑 full-ICE CONTROLLING(ICE-Lite 不可用)
|
||||
// 并以 DTLS client(setup="active")主动发起握手。上行 join JSON 不含 ICE candidates,
|
||||
// SFU 只能凭 ufrag/pwd 认证的 STUN Binding Request 学到客户端地址(peer-reflexive)。
|
||||
//
|
||||
// M0 提供 Disabled 实现(纯信令联调):下发语法完备的 ufrag/pwd/sha-256 指纹与空
|
||||
// candidates——客户端解析成功后停留在 Connecting 态(持续 4s checkGroupCall 心跳),
|
||||
// 属预期行为;M1 换 pion 实现后客户端无感切换。
|
||||
package sfu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SsrcGroup 是上行 join JSON 的 ssrc-groups 条目:semantics ∈ {"SIM","FID"}。
|
||||
// SIM 的 sources 按质量低→高排列;FID 是 [媒体 ssrc, RTX 重传 ssrc] 对。
|
||||
type SsrcGroup struct {
|
||||
Semantics string
|
||||
Sources []uint32
|
||||
}
|
||||
|
||||
// EndpointKind 区分同一参与者的两条媒体连接(M4:屏幕共享是独立的第二连接,
|
||||
// 独立 ICE/DTLS/ssrc 集,但 TL 层归属同一 participant)。
|
||||
type EndpointKind int
|
||||
|
||||
const (
|
||||
EndpointMain EndpointKind = iota
|
||||
EndpointPresentation
|
||||
)
|
||||
|
||||
// ClientOffer 解析自 phone.joinGroupCall / joinGroupCallPresentation 的上行 params JSON。
|
||||
type ClientOffer struct {
|
||||
AudioSSRC uint32
|
||||
Ufrag string
|
||||
Pwd string
|
||||
FingerprintSHA256 string // 形如 "AA:BB:..."(RFC 4572 hex)
|
||||
// SsrcGroups 是视频 simulcast/RTX 源组(主 join 即带,无论摄像头开关)。
|
||||
// SFU 据此做层选择:订阅端只为 SIM[0](最低层)建解码 sink,发到其它层
|
||||
// 原始 ssrc 的包会被客户端静默丢弃——v1 转发策略=只转 SIM[0] 层与其 FID
|
||||
// RTX 伙伴,其余层在 SFU 侧丢弃(省下行且行为正确)。
|
||||
SsrcGroups []SsrcGroup
|
||||
}
|
||||
|
||||
// Candidate 是下发给客户端的 ICE 候选(updateGroupCallConnection JSON 的
|
||||
// transport.candidates 条目,字段语义照抄 tgcalls 解析代码)。
|
||||
type Candidate struct {
|
||||
IP string
|
||||
Port int
|
||||
Protocol string // "udp"
|
||||
Type string // "host"
|
||||
}
|
||||
|
||||
// ServerAnswer 是 SFU 端的传输参数,由 rpc 层组装进下行 JSON。
|
||||
type ServerAnswer struct {
|
||||
Ufrag string
|
||||
Pwd string
|
||||
FingerprintSHA256 string
|
||||
Candidates []Candidate
|
||||
}
|
||||
|
||||
// Service 是信令层⇄媒体面的边界(未来横向扩展/远端 SFU 的替换点)。
|
||||
type Service interface {
|
||||
// Enabled 报告媒体面是否真实可用(false=M0 纯信令模式)。
|
||||
Enabled() bool
|
||||
// Join 为参与者分配/重建媒体 endpoint,返回 SFU 端传输参数。
|
||||
// kind=EndpointPresentation 时是同一参与者的第二连接(独立 ICE/DTLS)。
|
||||
Join(ctx context.Context, callID, userID int64, kind EndpointKind, offer ClientOffer) (ServerAnswer, error)
|
||||
// Leave 拆除参与者的 endpoint;kind=EndpointMain 时联动拆其 presentation
|
||||
//(客户端整体离会从不补发 leaveGroupCallPresentation)。
|
||||
Leave(ctx context.Context, callID, userID int64, kind EndpointKind) error
|
||||
CloseRoom(ctx context.Context, callID int64) error
|
||||
// AliveUserIDs 返回 callID 房间内媒体面仍存活(ICE consent/近期 SRTP 收包)
|
||||
// 的参与者。sweeper 的判死条件 = 心跳过期 ∧ 媒体面不存活(双过期)。
|
||||
AliveUserIDs(callID int64) []int64
|
||||
}
|
||||
|
||||
// disabled 是 M0 纯信令实现。
|
||||
type disabled struct{}
|
||||
|
||||
// Disabled 返回纯信令模式的 SFU。
|
||||
func Disabled() Service {
|
||||
return disabled{}
|
||||
}
|
||||
|
||||
func (disabled) Enabled() bool { return false }
|
||||
|
||||
func (disabled) Join(_ context.Context, _, _ int64, _ EndpointKind, _ ClientOffer) (ServerAnswer, error) {
|
||||
ufrag, err := randomICEString(8)
|
||||
if err != nil {
|
||||
return ServerAnswer{}, err
|
||||
}
|
||||
pwd, err := randomICEString(24)
|
||||
if err != nil {
|
||||
return ServerAnswer{}, err
|
||||
}
|
||||
fp, err := randomFingerprint()
|
||||
if err != nil {
|
||||
return ServerAnswer{}, err
|
||||
}
|
||||
// 空 candidates:客户端无可连地址,保持 Connecting(M0 预期)。
|
||||
return ServerAnswer{Ufrag: ufrag, Pwd: pwd, FingerprintSHA256: fp, Candidates: nil}, nil
|
||||
}
|
||||
|
||||
func (disabled) Leave(context.Context, int64, int64, EndpointKind) error { return nil }
|
||||
func (disabled) CloseRoom(context.Context, int64) error { return nil }
|
||||
func (disabled) AliveUserIDs(int64) []int64 { return nil }
|
||||
|
||||
const iceAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
func randomICEString(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("sfu: random ice string: %w", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, v := range buf {
|
||||
b.WriteByte(iceAlphabet[int(v)%len(iceAlphabet)])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// randomFingerprint 生成语法合法的 sha-256 指纹串(32 字节 hex,冒号分隔)。
|
||||
// M0 无 DTLS 端点,指纹值不会被验证(客户端连不上任何 candidate)。
|
||||
func randomFingerprint() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("sfu: random fingerprint: %w", err)
|
||||
}
|
||||
parts := make([]string, len(buf))
|
||||
for i, v := range buf {
|
||||
parts[i] = strings.ToUpper(hex.EncodeToString([]byte{v}))
|
||||
}
|
||||
return strings.Join(parts, ":"), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue