chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

43
internal/sfu/cert.go Normal file
View 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
View 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.BufferDTLS 握手依赖读超时
// 推进重传与失败退出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} }

View 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
View 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 // 转发的视频 ssrcSIM[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/pwdICE 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 clientisClient=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发布端报告→ 转发给其他主 endpointA/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
}
// v1ReceiverVideoConstraints 仅记录(转发策略固定为 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)
}

View 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 headerid=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
}
// 视频选层:发布端三层 simulcastSFU 只转发 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 %#xSFU 不得重写 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: %vspeaking 指示依赖逐包透传)", 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
View file

@ -0,0 +1,140 @@
// Package sfu 是群通话媒体面Selective Forwarding Unit的接口与实现。
//
// 角色契约(来自 tgcalls GroupNetworkManager 的硬性要求):客户端 ICE=CONTROLLED、
// DTLS setup="passive"等待握手SFU 必须跑 full-ICE CONTROLLINGICE-Lite 不可用)
// 并以 DTLS clientsetup="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 拆除参与者的 endpointkind=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客户端无可连地址保持 ConnectingM0 预期)。
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
}