chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
58
internal/app/phone/dh.go
Normal file
58
internal/app/phone/dh.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Package phone 实现私聊 1:1 通话的信令状态机与 DH 参数下发。
|
||||
//
|
||||
// 服务端职责边界:信令转发、状态机、commit-reveal 核验(SHA256(g_a)==g_a_hash)、
|
||||
// connections 下发。媒体面由客户端 tgcalls 走 P2P/TURN,密钥交换是 E2E 的,
|
||||
// 服务端不知道也无法验证共享密钥本身。
|
||||
package phone
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DHConfigVersion 是 messages.getDhConfig 的静态版本号。p/g 是编译期常量,
|
||||
// 客户端缓存命中(请求 version 相同)时只回 dhConfigNotModified{random}。
|
||||
const DHConfigVersion = 1
|
||||
|
||||
// DHG 是 DH generator。与官方一致取 3:TDesktop MTP::IsPrimeAndGood 对
|
||||
// 「官方 2048-bit prime + g∈{3,4,5,7}」有白名单快速通过路径,DrKLO native 同。
|
||||
const DHG = 3
|
||||
|
||||
// dhPrimeHex 是官方 2048-bit safe prime。与 internal/app/account/srp.go 的
|
||||
// baseP 同值(SRP 与通话 DH 共用官方参数);改动任一处须同步另一处。
|
||||
const dhPrimeHex = "c71caeb9c6b1c9048e6c522f70f13f73980d40238e3e21c14934d037563d930f48198a0aa7c14058229493d22530f4dbfa336f6e0ac925139543aed44cce7c3720fd51f69458705ac68cd4fe6b6b13abdc9746512969328454f18faf8c595f642477fe96bb2a941d5bcd1d4ac8cc49880708fa9b378e3c4f3a9060bee67cf9a4a4a695811051907e162753b56b0f6b410dba74d8a84b2a14b3144e0ef1284754fd17ed950d5965b4b9dd46582db1178d169c6bc465b0d6ff9ca3928fef5b9ae4e418fc15e83ebea0f87fa9ff5eed70050ded2849f47bf959d956850ce929851f0d8115f635b105ee2e4e15d04b2454bf6f4fadf034b10403119cd8e3b92fcc5b"
|
||||
|
||||
var dhPrime = mustDecodeHex(dhPrimeHex)
|
||||
|
||||
// maxDHRandomLength 钳制客户端请求的随机字节数,防御恶意大请求。
|
||||
const maxDHRandomLength = 1024
|
||||
|
||||
// DHPrime 返回官方 2048-bit prime 的拷贝。
|
||||
func DHPrime() []byte {
|
||||
return append([]byte(nil), dhPrime...)
|
||||
}
|
||||
|
||||
// DHRandom 生成恰好 n 字节加密随机数;n 钳制到 [0, maxDHRandomLength]。
|
||||
// 客户端契约要求 random 尺寸与请求一致(TDesktop 校验 random.size() 与其请求相同)。
|
||||
func DHRandom(n int) ([]byte, error) {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
if n > maxDHRandomLength {
|
||||
n = maxDHRandomLength
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return nil, fmt.Errorf("phone: dh random: %w", err)
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func mustDecodeHex(s string) []byte {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("phone: invalid dh prime hex: %v", err))
|
||||
}
|
||||
return b
|
||||
}
|
||||
120
internal/app/phone/registry.go
Normal file
120
internal/app/phone/registry.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package phone
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// registry 是 active call 的进程内权威存储:单实现、不进 store 双实现体系。
|
||||
//
|
||||
// 论证(设计已确认,勿改回双 store):active call 是秒级短命状态,服务端重启后
|
||||
// 客户端侧媒体连接早已断开,「重启恢复半截通话」不是有效需求;单一实现让测试与
|
||||
// 生产共用同一份代码,从构造上消灭 memory/postgres 行为漂移。多实例化时以同样的
|
||||
// 窄接口换 Redis 实现,信令层零改动。
|
||||
type registry struct {
|
||||
mu sync.Mutex
|
||||
byID map[int64]*entry
|
||||
byRandom map[randomKey]int64 // (callerID, randomID) → callID,吸收客户端 RPC 重试
|
||||
active map[int64]int // userID → 非终态通话数(并发上限依据)
|
||||
}
|
||||
|
||||
type randomKey struct {
|
||||
callerID int64
|
||||
randomID int64
|
||||
}
|
||||
|
||||
// entry 持有一通通话;call 字段由 registry.mu 保护。
|
||||
// sigMu 单独串行化该通话的信令转发(锁内做推送入队),与状态锁分离,
|
||||
// 保证 discard 等状态迁移不被对端出站队列堵塞拖住。
|
||||
type entry struct {
|
||||
call domain.PhoneCall
|
||||
|
||||
sigMu sync.Mutex
|
||||
sigWindowSec int64
|
||||
sigCount int
|
||||
}
|
||||
|
||||
func newRegistry() *registry {
|
||||
return ®istry{
|
||||
byID: make(map[int64]*entry),
|
||||
byRandom: make(map[randomKey]int64),
|
||||
active: make(map[int64]int),
|
||||
}
|
||||
}
|
||||
|
||||
// sweepLocked 是 P1 的纯年龄 GC(调用方持有 r.mu):
|
||||
// - 终态 tombstone 超过 tombstoneTTL → 回收(密钥材料随之销毁);
|
||||
// - 非终态超过 2×ringTimeout → 直接回收(双端同时崩溃的兜底,防僵尸通话
|
||||
// 吃满并发上限;不推送、不落历史,正常超时由客户端定时器与 P2 dispatcher 处理)。
|
||||
func (r *registry) sweepLocked(nowUnix int64, ringTimeoutSec, tombstoneTTLSec int64) {
|
||||
for id, e := range r.byID {
|
||||
switch {
|
||||
case e.call.Terminal():
|
||||
if nowUnix-int64(e.call.DiscardedAt) > tombstoneTTLSec {
|
||||
r.removeLocked(id, e, false)
|
||||
}
|
||||
default:
|
||||
if nowUnix-int64(e.call.Date) > 2*ringTimeoutSec {
|
||||
r.removeLocked(id, e, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *registry) removeLocked(id int64, e *entry, wasActive bool) {
|
||||
delete(r.byID, id)
|
||||
delete(r.byRandom, randomKey{callerID: e.call.AdminID, randomID: e.call.RandomID})
|
||||
if wasActive {
|
||||
r.decActiveLocked(e.call.AdminID)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *registry) decActiveLocked(userID int64) {
|
||||
if n := r.active[userID]; n <= 1 {
|
||||
delete(r.active, userID)
|
||||
} else {
|
||||
r.active[userID] = n - 1
|
||||
}
|
||||
}
|
||||
|
||||
// markDiscardedLocked 把非终态 entry 迁入终态并更新并发计数。
|
||||
func (r *registry) markDiscardedLocked(e *entry, reason domain.PhoneCallDiscardReason, duration, nowUnix int) {
|
||||
if e.call.Terminal() {
|
||||
return
|
||||
}
|
||||
e.call.State = domain.PhoneCallStateDiscarded
|
||||
e.call.DiscardReason = reason
|
||||
e.call.Duration = duration
|
||||
e.call.DiscardedAt = nowUnix
|
||||
r.decActiveLocked(e.call.AdminID)
|
||||
}
|
||||
|
||||
// newID 生成 registry 内唯一的正 int64(调用方持有 r.mu)。
|
||||
func (r *registry) newIDLocked() (int64, error) {
|
||||
for i := 0; i < 32; i++ {
|
||||
id, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, exists := r.byID[id]; !exists {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("phone: exhausted call id attempts")
|
||||
}
|
||||
|
||||
func randomPositiveInt64() (int64, error) {
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return 0, fmt.Errorf("phone: random id: %w", err)
|
||||
}
|
||||
v := int64(binary.BigEndian.Uint64(buf[:]) >> 1)
|
||||
if v == 0 {
|
||||
v = 1
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
502
internal/app/phone/service.go
Normal file
502
internal/app/phone/service.go
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
package phone
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// 客户端可识别的业务错误;rpc 层映射为对应 RPC_ERROR(CALL_* 等)。
|
||||
var (
|
||||
ErrPeerInvalid = errors.New("phone: call peer invalid")
|
||||
ErrAlreadyAccepted = errors.New("phone: call already accepted")
|
||||
ErrAlreadyDeclined = errors.New("phone: call already declined")
|
||||
ErrOccupyFailed = errors.New("phone: too many active calls")
|
||||
ErrProtocolLayerInvalid = errors.New("phone: protocol layer invalid")
|
||||
ErrProtocolCompatLayerInvalid = errors.New("phone: protocol compat layer invalid")
|
||||
ErrProtocolFlagsInvalid = errors.New("phone: protocol flags invalid")
|
||||
// ErrGAHashMismatch:confirmCall 揭示的 g_a 与 requestCall 承诺的 SHA256 不符。
|
||||
// 服务端同时把通话强制置为 discarded(disconnect),防止攻击者卡死状态机。
|
||||
ErrGAHashMismatch = errors.New("phone: g_a does not match committed hash")
|
||||
)
|
||||
|
||||
// minSupportedLayer 是 libtgvoip/tgcalls 的最低协议层(TDesktop kMinLayer、
|
||||
// DrKLO VoIPService.CALL_MIN_LAYER 均为 65)。
|
||||
const minSupportedLayer = 65
|
||||
|
||||
const (
|
||||
gaHashSize = sha256.Size // 32
|
||||
dhPubSize = 256 // g_a / g_b 都是 2048-bit
|
||||
)
|
||||
|
||||
// Config 是通话服务的运行参数。
|
||||
type Config struct {
|
||||
// RingTimeout 是服务端兜底超时(与下发给客户端的 callRingTimeoutMs 同源,默认 90s)。
|
||||
RingTimeout time.Duration
|
||||
// TombstoneTTL 是终态 tombstone 保留期(吸收双方同时挂断/晚到 RPC 的幂等窗口)。
|
||||
TombstoneTTL time.Duration
|
||||
// MaxActivePerUser 是单用户并发非终态通话上限(防呼叫轰炸自锁)。
|
||||
MaxActivePerUser int
|
||||
// SignalingRatePerSecond 是单通话每秒信令转发上限;超限静默丢弃(不破坏客户端状态机)。
|
||||
SignalingRatePerSecond int
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
if c.RingTimeout <= 0 {
|
||||
c.RingTimeout = 90 * time.Second
|
||||
}
|
||||
if c.TombstoneTTL <= 0 {
|
||||
c.TombstoneTTL = 60 * time.Second
|
||||
}
|
||||
if c.MaxActivePerUser <= 0 {
|
||||
c.MaxActivePerUser = 4
|
||||
}
|
||||
if c.SignalingRatePerSecond <= 0 {
|
||||
c.SignalingRatePerSecond = 50
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Service 实现私聊通话信令状态机。所有方法返回的 domain.PhoneCall 都是当时快照。
|
||||
type Service struct {
|
||||
cfg Config
|
||||
clk clock.Clock
|
||||
reg *registry
|
||||
}
|
||||
|
||||
// Option 配置 Service。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithClock 注入测试时钟。
|
||||
func WithClock(clk clock.Clock) Option {
|
||||
return func(s *Service) { s.clk = clk }
|
||||
}
|
||||
|
||||
// NewService 创建通话服务。
|
||||
func NewService(cfg Config, opts ...Option) *Service {
|
||||
s := &Service{cfg: cfg.withDefaults(), clk: clock.System, reg: newRegistry()}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RequestCall 受理主叫请求:校验协议与配额、(callerID, randomID) 幂等去重、建档。
|
||||
// 隐私/拉黑/目标用户合法性由 rpc 层先行校验。
|
||||
func (s *Service) RequestCall(ctx context.Context, callerID int64, in domain.PhoneCallRequest) (domain.PhoneCall, error) {
|
||||
if err := validateProtocol(in.Protocol); err != nil {
|
||||
return domain.PhoneCall{}, err
|
||||
}
|
||||
if len(in.GAHash) != gaHashSize {
|
||||
return domain.PhoneCall{}, ErrProtocolFlagsInvalid
|
||||
}
|
||||
now := s.clk.Now()
|
||||
nowUnix := now.Unix()
|
||||
|
||||
s.reg.mu.Lock()
|
||||
defer s.reg.mu.Unlock()
|
||||
s.reg.sweepLocked(nowUnix, int64(s.cfg.RingTimeout/time.Second), int64(s.cfg.TombstoneTTL/time.Second))
|
||||
|
||||
// 幂等:同一 (callerID, randomID) 的未终结通话直接返回快照,吸收客户端重试。
|
||||
key := randomKey{callerID: callerID, randomID: in.RandomID}
|
||||
if id, ok := s.reg.byRandom[key]; ok {
|
||||
if e, ok := s.reg.byID[id]; ok && !e.call.Terminal() {
|
||||
return e.call, nil
|
||||
}
|
||||
}
|
||||
if s.reg.active[callerID] >= s.cfg.MaxActivePerUser {
|
||||
return domain.PhoneCall{}, ErrOccupyFailed
|
||||
}
|
||||
|
||||
id, err := s.reg.newIDLocked()
|
||||
if err != nil {
|
||||
return domain.PhoneCall{}, err
|
||||
}
|
||||
accessHash, err := randomPositiveInt64()
|
||||
if err != nil {
|
||||
return domain.PhoneCall{}, err
|
||||
}
|
||||
call := domain.PhoneCall{
|
||||
ID: id,
|
||||
AccessHash: accessHash,
|
||||
AdminID: callerID,
|
||||
ParticipantID: in.CalleeID,
|
||||
Video: in.Video,
|
||||
State: domain.PhoneCallStateRequested,
|
||||
Date: int(nowUnix),
|
||||
GAHash: append([]byte(nil), in.GAHash...),
|
||||
CallerProtocol: in.Protocol,
|
||||
Protocol: in.Protocol,
|
||||
RandomID: in.RandomID,
|
||||
CallerDevice: in.CallerDevice,
|
||||
PrivacyP2P: in.PrivacyP2P,
|
||||
Connections: append([]domain.PhoneCallConnection(nil), in.Connections...),
|
||||
}
|
||||
s.reg.byID[id] = &entry{call: call}
|
||||
s.reg.byRandom[key] = id
|
||||
s.reg.active[callerID]++
|
||||
return call, nil
|
||||
}
|
||||
|
||||
// ReceivedCall 标记被叫设备已收到来电。首次(Requested→Ringing)返回 transitioned=true,
|
||||
// 其余状态幂等成功(多设备各自上报、晚到无害)。
|
||||
func (s *Service) ReceivedCall(ctx context.Context, userID, callID, accessHash int64) (domain.PhoneCall, bool, error) {
|
||||
s.reg.mu.Lock()
|
||||
defer s.reg.mu.Unlock()
|
||||
e, err := s.lookupLocked(callID, accessHash)
|
||||
if err != nil {
|
||||
return domain.PhoneCall{}, false, err
|
||||
}
|
||||
if userID != e.call.ParticipantID {
|
||||
// receivedCall 只能由被叫上报。
|
||||
return domain.PhoneCall{}, false, ErrPeerInvalid
|
||||
}
|
||||
if e.call.State == domain.PhoneCallStateRequested {
|
||||
e.call.State = domain.PhoneCallStateRinging
|
||||
e.call.ReceiveDate = int(s.clk.Now().Unix())
|
||||
return e.call, true, nil
|
||||
}
|
||||
return e.call, false, nil
|
||||
}
|
||||
|
||||
// AcceptCall 受理被叫接听。多设备并发竞争由 registry 锁串行化:首个完成迁移者赢,
|
||||
// 后到者收 ErrAlreadyAccepted(其 UI 自行收场)。
|
||||
func (s *Service) AcceptCall(ctx context.Context, userID, callID, accessHash int64, gb []byte, proto domain.PhoneCallProtocol, device domain.SessionRef) (domain.PhoneCall, error) {
|
||||
if err := validateProtocol(proto); err != nil {
|
||||
return domain.PhoneCall{}, err
|
||||
}
|
||||
if len(gb) != dhPubSize || allZero(gb) {
|
||||
return domain.PhoneCall{}, ErrProtocolFlagsInvalid
|
||||
}
|
||||
s.reg.mu.Lock()
|
||||
defer s.reg.mu.Unlock()
|
||||
e, err := s.lookupLocked(callID, accessHash)
|
||||
if err != nil {
|
||||
return domain.PhoneCall{}, err
|
||||
}
|
||||
if userID != e.call.ParticipantID {
|
||||
return domain.PhoneCall{}, ErrPeerInvalid
|
||||
}
|
||||
switch e.call.State {
|
||||
case domain.PhoneCallStateRequested, domain.PhoneCallStateRinging:
|
||||
// 合法路径;未 receivedCall 直接 accept 也允许。
|
||||
case domain.PhoneCallStateAccepted, domain.PhoneCallStateConfirmed:
|
||||
return domain.PhoneCall{}, ErrAlreadyAccepted
|
||||
case domain.PhoneCallStateDiscarded:
|
||||
return domain.PhoneCall{}, ErrAlreadyDeclined
|
||||
}
|
||||
negotiated, err := negotiateProtocol(e.call.CallerProtocol, proto)
|
||||
if err != nil {
|
||||
return domain.PhoneCall{}, err
|
||||
}
|
||||
e.call.GB = append([]byte(nil), gb...)
|
||||
e.call.CalleeProtocol = proto
|
||||
e.call.Protocol = negotiated
|
||||
e.call.CalleeDevice = device
|
||||
e.call.State = domain.PhoneCallStateAccepted
|
||||
// 不解除超时:同一只表继续走,主叫永不 confirm 时由兜底超时收尾。
|
||||
return e.call, nil
|
||||
}
|
||||
|
||||
// ConfirmCall 受理主叫确认:核验 SHA256(g_a) 与承诺一致后进入 Confirmed。
|
||||
// 核验失败时通话被强制置为 discarded(disconnect),返回 (终态快照, true, ErrGAHashMismatch),
|
||||
// 调用方须把终态推送给双方。
|
||||
func (s *Service) ConfirmCall(ctx context.Context, userID, callID, accessHash int64, ga []byte, keyFingerprint int64, proto domain.PhoneCallProtocol) (domain.PhoneCall, bool, error) {
|
||||
s.reg.mu.Lock()
|
||||
defer s.reg.mu.Unlock()
|
||||
e, err := s.lookupLocked(callID, accessHash)
|
||||
if err != nil {
|
||||
return domain.PhoneCall{}, false, err
|
||||
}
|
||||
if userID != e.call.AdminID {
|
||||
return domain.PhoneCall{}, false, ErrPeerInvalid
|
||||
}
|
||||
switch e.call.State {
|
||||
case domain.PhoneCallStateAccepted:
|
||||
case domain.PhoneCallStateConfirmed:
|
||||
return domain.PhoneCall{}, false, ErrAlreadyAccepted
|
||||
case domain.PhoneCallStateDiscarded:
|
||||
return domain.PhoneCall{}, false, ErrAlreadyDeclined
|
||||
default: // Requested / Ringing:被叫尚未 accept,confirm 非法。
|
||||
return domain.PhoneCall{}, false, ErrPeerInvalid
|
||||
}
|
||||
// confirmCall 携带的第三份 protocol 仅校验合法性,不改 accept 时的协商结果
|
||||
//(官方语义:协商在 accept 完成,confirm 的 protocol 是回显)。
|
||||
if err := validateProtocol(proto); err != nil {
|
||||
return domain.PhoneCall{}, false, err
|
||||
}
|
||||
if len(ga) != dhPubSize || sha256Mismatch(ga, e.call.GAHash) {
|
||||
now := int(s.clk.Now().Unix())
|
||||
s.reg.markDiscardedLocked(e, domain.PhoneCallDiscardReasonDisconnect, 0, now)
|
||||
return e.call, true, ErrGAHashMismatch
|
||||
}
|
||||
e.call.GA = append([]byte(nil), ga...)
|
||||
e.call.KeyFingerprint = keyFingerprint
|
||||
e.call.StartDate = int(s.clk.Now().Unix())
|
||||
// p2p_allowed = 双方 protocol 都允许 P2P ∧ phone_p2p 隐私双向放行(P3 起
|
||||
// PrivacyP2P 由 rpc 层算定,强制 relay 时也走它置 false)。false 时 tgcalls
|
||||
// 只用 relay candidates——前提是 connections 里有可用 TURN。
|
||||
e.call.P2PAllowed = e.call.CallerProtocol.UDPP2P && e.call.CalleeProtocol.UDPP2P && e.call.PrivacyP2P
|
||||
e.call.State = domain.PhoneCallStateConfirmed
|
||||
return e.call, false, nil
|
||||
}
|
||||
|
||||
// DiscardCall 挂断:任意非终态可达,幂等。already=true 表示通话此前已是终态
|
||||
// (双方同时挂断:先到者定 reason,后到者拿快照)。
|
||||
func (s *Service) DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (domain.PhoneCall, bool, error) {
|
||||
s.reg.mu.Lock()
|
||||
defer s.reg.mu.Unlock()
|
||||
e, err := s.lookupLocked(callID, accessHash)
|
||||
if err != nil {
|
||||
return domain.PhoneCall{}, false, err
|
||||
}
|
||||
if !e.call.HasParticipant(userID) {
|
||||
return domain.PhoneCall{}, false, ErrPeerInvalid
|
||||
}
|
||||
if e.call.Terminal() {
|
||||
return e.call, true, nil
|
||||
}
|
||||
if reason == "" {
|
||||
reason = domain.PhoneCallDiscardReasonHangup
|
||||
}
|
||||
// duration 只在通话真正建立(Confirmed)后才认,防止客户端把振铃时长报成通话时长。
|
||||
if e.call.StartDate == 0 || duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
s.reg.markDiscardedLocked(e, reason, duration, int(s.clk.Now().Unix()))
|
||||
return e.call, false, nil
|
||||
}
|
||||
|
||||
// ExpireDue 把超时的非终态通话迁入终态并返回快照(调用方负责推送与落历史):
|
||||
// Requested/Ringing 超时 → missed(即「未接来电」来源);Accepted 悬挂(主叫
|
||||
// 永不 confirm)→ disconnect。Confirmed 通话没有服务端时长上限,不在此回收。
|
||||
// 顺带做 tombstone GC。
|
||||
func (s *Service) ExpireDue(ctx context.Context, now time.Time) []domain.PhoneCall {
|
||||
nowUnix := now.Unix()
|
||||
ringSec := int64(s.cfg.RingTimeout / time.Second)
|
||||
s.reg.mu.Lock()
|
||||
defer s.reg.mu.Unlock()
|
||||
var expired []domain.PhoneCall
|
||||
for _, e := range s.reg.byID {
|
||||
if e.call.Terminal() || e.call.State == domain.PhoneCallStateConfirmed {
|
||||
continue
|
||||
}
|
||||
if nowUnix-int64(e.call.Date) <= ringSec {
|
||||
continue
|
||||
}
|
||||
reason := domain.PhoneCallDiscardReasonMissed
|
||||
if e.call.State == domain.PhoneCallStateAccepted {
|
||||
reason = domain.PhoneCallDiscardReasonDisconnect
|
||||
}
|
||||
s.reg.markDiscardedLocked(e, reason, 0, int(nowUnix))
|
||||
expired = append(expired, e.call)
|
||||
}
|
||||
s.reg.sweepLocked(nowUnix, ringSec, int64(s.cfg.TombstoneTTL/time.Second))
|
||||
return expired
|
||||
}
|
||||
|
||||
// Signal 校验并串行转发一条信令。forward 在该通话专属的信令顺序锁内执行
|
||||
// (保证转发顺序与受理顺序一致),状态锁不跨 forward 持有。peerDevice 是对端
|
||||
// 受理设备锚点(可能为零值/已失效),仅作定向推送 fast-path 提示。
|
||||
// drop=true 表示按契约静默吞掉(tombstone 尾包 / 超过速率上限),调用方应返回成功。
|
||||
func (s *Service) Signal(ctx context.Context, userID, callID, accessHash int64, forward func(peerUserID int64, peerDevice domain.SessionRef)) (drop bool, err error) {
|
||||
s.reg.mu.Lock()
|
||||
e, lookupErr := s.lookupLocked(callID, accessHash)
|
||||
if lookupErr != nil {
|
||||
s.reg.mu.Unlock()
|
||||
return false, lookupErr
|
||||
}
|
||||
if !e.call.HasParticipant(userID) {
|
||||
s.reg.mu.Unlock()
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
state := e.call.State
|
||||
peer := e.call.PeerOf(userID)
|
||||
peerDevice := e.call.CalleeDevice
|
||||
if peer == e.call.AdminID {
|
||||
peerDevice = e.call.CallerDevice
|
||||
}
|
||||
s.reg.mu.Unlock()
|
||||
|
||||
switch state {
|
||||
case domain.PhoneCallStateAccepted, domain.PhoneCallStateConfirmed:
|
||||
// 可转发。DrKLO 在 confirm 前后都可能发信令,Accepted 即放行。
|
||||
case domain.PhoneCallStateDiscarded:
|
||||
// 挂断瞬间的尾包:返回错误会让 TDesktop 把正常挂断渲染成「通话失败」
|
||||
//(其 sendSignalingData 的 .done 校验 mtpIsTrue),静默丢弃。
|
||||
return true, nil
|
||||
default:
|
||||
return false, ErrPeerInvalid
|
||||
}
|
||||
|
||||
e.sigMu.Lock()
|
||||
defer e.sigMu.Unlock()
|
||||
nowSec := s.clk.Now().Unix()
|
||||
if e.sigWindowSec != nowSec {
|
||||
e.sigWindowSec = nowSec
|
||||
e.sigCount = 0
|
||||
}
|
||||
if e.sigCount >= s.cfg.SignalingRatePerSecond {
|
||||
return true, nil
|
||||
}
|
||||
e.sigCount++
|
||||
forward(peer, peerDevice)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Lookup 返回通话快照(rpc 层宽容校验 setCallRating/saveCallDebug 等晚到请求用)。
|
||||
func (s *Service) Lookup(ctx context.Context, callID, accessHash int64) (domain.PhoneCall, bool) {
|
||||
s.reg.mu.Lock()
|
||||
defer s.reg.mu.Unlock()
|
||||
e, err := s.lookupLocked(callID, accessHash)
|
||||
if err != nil {
|
||||
return domain.PhoneCall{}, false
|
||||
}
|
||||
return e.call, true
|
||||
}
|
||||
|
||||
func (s *Service) lookupLocked(callID, accessHash int64) (*entry, error) {
|
||||
e, ok := s.reg.byID[callID]
|
||||
if !ok || e.call.AccessHash != accessHash {
|
||||
return nil, ErrPeerInvalid
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func validateProtocol(p domain.PhoneCallProtocol) error {
|
||||
if p.MinLayer > p.MaxLayer {
|
||||
return ErrProtocolLayerInvalid
|
||||
}
|
||||
if p.MaxLayer < minSupportedLayer {
|
||||
return ErrProtocolCompatLayerInvalid
|
||||
}
|
||||
if !p.UDPP2P && !p.UDPReflector {
|
||||
return ErrProtocolFlagsInvalid
|
||||
}
|
||||
if len(p.LibraryVersions) == 0 {
|
||||
return ErrProtocolFlagsInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// negotiateProtocol 在 accept 时合并双方 protocol(官方语义:服务端只回「最优」单值
|
||||
// library version)。版本无交集时透传被叫列表(被叫是先实例化 tgcalls 的一方,
|
||||
// 主叫侧有 kDefaultVersion 兜底)——绝不因版本差异拒绝通话。
|
||||
func negotiateProtocol(caller, callee domain.PhoneCallProtocol) (domain.PhoneCallProtocol, error) {
|
||||
out := domain.PhoneCallProtocol{
|
||||
UDPP2P: caller.UDPP2P && callee.UDPP2P,
|
||||
UDPReflector: caller.UDPReflector || callee.UDPReflector,
|
||||
MinLayer: maxInt(caller.MinLayer, callee.MinLayer),
|
||||
MaxLayer: minInt(caller.MaxLayer, callee.MaxLayer),
|
||||
}
|
||||
if out.MinLayer > out.MaxLayer {
|
||||
return domain.PhoneCallProtocol{}, ErrProtocolCompatLayerInvalid
|
||||
}
|
||||
if best, ok := bestCommonVersion(caller.LibraryVersions, callee.LibraryVersions); ok {
|
||||
out.LibraryVersions = []string{best}
|
||||
} else {
|
||||
out.LibraryVersions = append([]string(nil), callee.LibraryVersions...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// preferredVersions 是交集命中时的优先选择序(读客户端源码定下的硬约束):
|
||||
// - "9.0.0"=InstanceV2Impl+V2 消息级信令,TDesktop 与 DrKLO 注册表都有,最稳;
|
||||
// - ⚠ 绝不能选 "10.0.0"/"11.0.0"/"12.0.0"/"13.0.0" 当 [0]:DrKLO 的视频可用性
|
||||
// 判断是 `"2.7.7".compareTo(versions[0]) <= 0` 的**字符串字典序**比较
|
||||
// (VoIPService.java:3464),"1x.0.0" 字典序小于 "2.7.7" 会让 Android 直接
|
||||
// 销毁摄像头采集(视频通话黑屏);"12/13" 还是 V3 SCTP-over-signaling,
|
||||
// 对服务端信令限速不友好;
|
||||
// - 也绝不能选 "2.7.7"/"5.0.0"/"2.4.4":legacy 实现依赖我们不下发的
|
||||
// reflector endpoints,无路可走。
|
||||
var preferredVersions = []string{"9.0.0", "8.0.0", "7.0.0"}
|
||||
|
||||
// bestCommonVersion 取两侧版本集合交集:优先 preferredVersions 顺位命中,
|
||||
// 否则退化为语义化最高者(容忍未来未知版本集)。
|
||||
func bestCommonVersion(a, b []string) (string, bool) {
|
||||
inB := make(map[string]struct{}, len(b))
|
||||
for _, v := range b {
|
||||
inB[v] = struct{}{}
|
||||
}
|
||||
inBoth := make(map[string]struct{}, len(a))
|
||||
for _, v := range a {
|
||||
if _, ok := inB[v]; ok {
|
||||
inBoth[v] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, v := range preferredVersions {
|
||||
if _, ok := inBoth[v]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
best, found := "", false
|
||||
for v := range inBoth {
|
||||
if !found || compareVersion(v, best) > 0 {
|
||||
best, found = v, true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
// compareVersion 按点分十进制比较("9.0.0" < "10.0.0");非数字段退化为字符串比较。
|
||||
func compareVersion(a, b string) int {
|
||||
as, bs := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; i < len(as) || i < len(bs); i++ {
|
||||
var av, bv string
|
||||
if i < len(as) {
|
||||
av = as[i]
|
||||
}
|
||||
if i < len(bs) {
|
||||
bv = bs[i]
|
||||
}
|
||||
an, aerr := strconv.Atoi(av)
|
||||
bn, berr := strconv.Atoi(bv)
|
||||
switch {
|
||||
case aerr == nil && berr == nil:
|
||||
if an != bn {
|
||||
return an - bn
|
||||
}
|
||||
default:
|
||||
if c := strings.Compare(av, bv); c != 0 {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func sha256Mismatch(data, want []byte) bool {
|
||||
got := sha256.Sum256(data)
|
||||
return !bytes.Equal(got[:], want)
|
||||
}
|
||||
|
||||
func allZero(b []byte) bool {
|
||||
for _, v := range b {
|
||||
if v != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
479
internal/app/phone/service_test.go
Normal file
479
internal/app/phone/service_test.go
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
package phone
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/clock"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type testClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func newTestClock() *testClock {
|
||||
return &testClock{now: time.Unix(1_700_000_000, 0)}
|
||||
}
|
||||
|
||||
func (c *testClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *testClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
func (c *testClock) Timer(d time.Duration) clock.Timer { return clock.System.Timer(d) }
|
||||
func (c *testClock) Ticker(d time.Duration) clock.Ticker { return clock.System.Ticker(d) }
|
||||
|
||||
func testProtocol(versions ...string) domain.PhoneCallProtocol {
|
||||
if len(versions) == 0 {
|
||||
versions = []string{"11.0.0", "10.0.0"}
|
||||
}
|
||||
return domain.PhoneCallProtocol{
|
||||
UDPP2P: true,
|
||||
UDPReflector: true,
|
||||
MinLayer: 65,
|
||||
MaxLayer: 92,
|
||||
LibraryVersions: versions,
|
||||
}
|
||||
}
|
||||
|
||||
func testGA() ([]byte, []byte) {
|
||||
ga := make([]byte, 256)
|
||||
for i := range ga {
|
||||
ga[i] = byte(i + 1)
|
||||
}
|
||||
hash := sha256.Sum256(ga)
|
||||
return ga, hash[:]
|
||||
}
|
||||
|
||||
func testGB() []byte {
|
||||
gb := make([]byte, 256)
|
||||
for i := range gb {
|
||||
gb[i] = byte(255 - i%200)
|
||||
}
|
||||
return gb
|
||||
}
|
||||
|
||||
func newTestService(clk clock.Clock, mutate ...func(*Config)) *Service {
|
||||
cfg := Config{
|
||||
RingTimeout: 90 * time.Second,
|
||||
TombstoneTTL: 60 * time.Second,
|
||||
MaxActivePerUser: 4,
|
||||
SignalingRatePerSecond: 50,
|
||||
}
|
||||
for _, fn := range mutate {
|
||||
fn(&cfg)
|
||||
}
|
||||
return NewService(cfg, WithClock(clk))
|
||||
}
|
||||
|
||||
func mustRequest(t *testing.T, s *Service, caller, callee int64, gaHash []byte) domain.PhoneCall {
|
||||
t.Helper()
|
||||
call, err := s.RequestCall(context.Background(), caller, domain.PhoneCallRequest{
|
||||
CalleeID: callee,
|
||||
RandomID: caller*1000 + callee,
|
||||
GAHash: gaHash,
|
||||
Protocol: testProtocol(),
|
||||
PrivacyP2P: true, // rpc 层算定的 phone_p2p 双向放行(P3 起参与 p2p_allowed AND)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RequestCall: %v", err)
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func TestPhoneCallHappyPath(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk)
|
||||
ctx := context.Background()
|
||||
ga, gaHash := testGA()
|
||||
gb := testGB()
|
||||
|
||||
call := mustRequest(t, s, 1, 2, gaHash)
|
||||
if call.State != domain.PhoneCallStateRequested || call.AdminID != 1 || call.ParticipantID != 2 {
|
||||
t.Fatalf("requested call = %+v", call)
|
||||
}
|
||||
|
||||
clk.Advance(2 * time.Second)
|
||||
ringing, transitioned, err := s.ReceivedCall(ctx, 2, call.ID, call.AccessHash)
|
||||
if err != nil || !transitioned || ringing.State != domain.PhoneCallStateRinging || ringing.ReceiveDate == 0 {
|
||||
t.Fatalf("ReceivedCall = %+v transitioned=%v err=%v", ringing, transitioned, err)
|
||||
}
|
||||
if _, again, err := s.ReceivedCall(ctx, 2, call.ID, call.AccessHash); err != nil || again {
|
||||
t.Fatalf("second ReceivedCall transitioned=%v err=%v, want idempotent", again, err)
|
||||
}
|
||||
|
||||
accepted, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{SessionID: 22})
|
||||
if err != nil || accepted.State != domain.PhoneCallStateAccepted {
|
||||
t.Fatalf("AcceptCall = %+v err=%v", accepted, err)
|
||||
}
|
||||
if string(accepted.GB) != string(gb) {
|
||||
t.Fatalf("accepted.GB mismatch")
|
||||
}
|
||||
|
||||
confirmed, forced, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, ga, 0x1234, testProtocol())
|
||||
if err != nil || forced || confirmed.State != domain.PhoneCallStateConfirmed {
|
||||
t.Fatalf("ConfirmCall = %+v forced=%v err=%v", confirmed, forced, err)
|
||||
}
|
||||
if !confirmed.P2PAllowed || confirmed.KeyFingerprint != 0x1234 || confirmed.StartDate == 0 {
|
||||
t.Fatalf("confirmed snapshot = %+v", confirmed)
|
||||
}
|
||||
|
||||
clk.Advance(30 * time.Second)
|
||||
discarded, already, err := s.DiscardCall(ctx, 2, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 30)
|
||||
if err != nil || already || discarded.State != domain.PhoneCallStateDiscarded || discarded.Duration != 30 {
|
||||
t.Fatalf("DiscardCall = %+v already=%v err=%v", discarded, already, err)
|
||||
}
|
||||
// 双方同时挂断:后到者幂等拿快照,reason 由先到者决定。
|
||||
again, already, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonBusy, 0)
|
||||
if err != nil || !already || again.DiscardReason != domain.PhoneCallDiscardReasonHangup {
|
||||
t.Fatalf("second DiscardCall = %+v already=%v err=%v", again, already, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallStateErrors(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk)
|
||||
ctx := context.Background()
|
||||
ga, gaHash := testGA()
|
||||
gb := testGB()
|
||||
call := mustRequest(t, s, 1, 2, gaHash)
|
||||
|
||||
// confirm 前置必须 Accepted。
|
||||
if _, _, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, ga, 1, testProtocol()); !errors.Is(err, ErrPeerInvalid) {
|
||||
t.Fatalf("confirm before accept err = %v, want ErrPeerInvalid", err)
|
||||
}
|
||||
// 非被叫不能 accept / receivedCall。
|
||||
if _, err := s.AcceptCall(ctx, 1, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrPeerInvalid) {
|
||||
t.Fatalf("accept by caller err = %v, want ErrPeerInvalid", err)
|
||||
}
|
||||
if _, _, err := s.ReceivedCall(ctx, 1, call.ID, call.AccessHash); !errors.Is(err, ErrPeerInvalid) {
|
||||
t.Fatalf("receivedCall by caller err = %v, want ErrPeerInvalid", err)
|
||||
}
|
||||
// access_hash 不符。
|
||||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash+1, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrPeerInvalid) {
|
||||
t.Fatalf("wrong access hash err = %v, want ErrPeerInvalid", err)
|
||||
}
|
||||
|
||||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrAlreadyAccepted) {
|
||||
t.Fatalf("double accept err = %v, want ErrAlreadyAccepted", err)
|
||||
}
|
||||
if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrAlreadyDeclined) {
|
||||
t.Fatalf("accept after discard err = %v, want ErrAlreadyDeclined", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallGAHashMismatchForcesDiscard(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk)
|
||||
ctx := context.Background()
|
||||
_, gaHash := testGA()
|
||||
call := mustRequest(t, s, 1, 2, gaHash)
|
||||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
wrong := make([]byte, 256)
|
||||
wrong[0] = 0x7
|
||||
snap, forced, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, wrong, 1, testProtocol())
|
||||
if !errors.Is(err, ErrGAHashMismatch) || !forced {
|
||||
t.Fatalf("confirm with wrong ga: forced=%v err=%v", forced, err)
|
||||
}
|
||||
if snap.State != domain.PhoneCallStateDiscarded || snap.DiscardReason != domain.PhoneCallDiscardReasonDisconnect {
|
||||
t.Fatalf("forced discard snapshot = %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallConcurrentAcceptSingleWinner(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk)
|
||||
ctx := context.Background()
|
||||
_, gaHash := testGA()
|
||||
call := mustRequest(t, s, 1, 2, gaHash)
|
||||
|
||||
const devices = 8
|
||||
var wg sync.WaitGroup
|
||||
wins := make(chan int64, devices)
|
||||
losses := make(chan error, devices)
|
||||
for i := 0; i < devices; i++ {
|
||||
wg.Add(1)
|
||||
go func(sessionID int64) {
|
||||
defer wg.Done()
|
||||
_, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{SessionID: sessionID})
|
||||
if err == nil {
|
||||
wins <- sessionID
|
||||
} else {
|
||||
losses <- err
|
||||
}
|
||||
}(int64(100 + i))
|
||||
}
|
||||
wg.Wait()
|
||||
close(wins)
|
||||
close(losses)
|
||||
if len(wins) != 1 {
|
||||
t.Fatalf("winners = %d, want exactly 1", len(wins))
|
||||
}
|
||||
for err := range losses {
|
||||
if !errors.Is(err, ErrAlreadyAccepted) {
|
||||
t.Fatalf("loser err = %v, want ErrAlreadyAccepted", err)
|
||||
}
|
||||
}
|
||||
winner := <-wins
|
||||
snap, ok := s.Lookup(ctx, call.ID, call.AccessHash)
|
||||
if !ok || snap.CalleeDevice.SessionID != winner {
|
||||
t.Fatalf("callee device = %+v, want session %d", snap.CalleeDevice, winner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallRandomIDIdempotent(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk)
|
||||
ctx := context.Background()
|
||||
_, gaHash := testGA()
|
||||
req := domain.PhoneCallRequest{CalleeID: 2, RandomID: 777, GAHash: gaHash, Protocol: testProtocol()}
|
||||
first, err := s.RequestCall(ctx, 1, req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
second, err := s.RequestCall(ctx, 1, req)
|
||||
if err != nil || second.ID != first.ID {
|
||||
t.Fatalf("retry id = %d err=%v, want %d", second.ID, err, first.ID)
|
||||
}
|
||||
// 终结后同 random_id 重新可用(新通话)。
|
||||
if _, _, err := s.DiscardCall(ctx, 1, first.ID, first.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
third, err := s.RequestCall(ctx, 1, req)
|
||||
if err != nil || third.ID == first.ID {
|
||||
t.Fatalf("post-discard request id = %d err=%v, want fresh call", third.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallQuotaAndSweep(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk, func(c *Config) { c.MaxActivePerUser = 2 })
|
||||
ctx := context.Background()
|
||||
_, gaHash := testGA()
|
||||
|
||||
for i := int64(0); i < 2; i++ {
|
||||
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 10 + i, RandomID: i, GAHash: gaHash, Protocol: testProtocol()}); err != nil {
|
||||
t.Fatalf("request %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); !errors.Is(err, ErrOccupyFailed) {
|
||||
t.Fatalf("over quota err = %v, want ErrOccupyFailed", err)
|
||||
}
|
||||
// 双端崩溃兜底:超过 2×RingTimeout 的僵尸通话被纯年龄 GC 回收,配额释放。
|
||||
clk.Advance(181 * time.Second)
|
||||
if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); err != nil {
|
||||
t.Fatalf("request after sweep: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallTombstoneGC(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk)
|
||||
ctx := context.Background()
|
||||
_, gaHash := testGA()
|
||||
call := mustRequest(t, s, 1, 2, gaHash)
|
||||
if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
if _, ok := s.Lookup(ctx, call.ID, call.AccessHash); !ok {
|
||||
t.Fatalf("tombstone should be visible before TTL")
|
||||
}
|
||||
clk.Advance(61 * time.Second)
|
||||
mustRequest(t, s, 3, 4, gaHash) // 触发 sweep
|
||||
if _, ok := s.Lookup(ctx, call.ID, call.AccessHash); ok {
|
||||
t.Fatalf("tombstone should be collected after TTL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallDurationOnlyWhenConfirmed(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk)
|
||||
ctx := context.Background()
|
||||
_, gaHash := testGA()
|
||||
call := mustRequest(t, s, 1, 2, gaHash)
|
||||
snap, _, err := s.DiscardCall(ctx, 2, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonBusy, 55)
|
||||
if err != nil || snap.Duration != 0 {
|
||||
t.Fatalf("unconfirmed discard duration = %d err=%v, want 0", snap.Duration, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallSignal(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk, func(c *Config) { c.SignalingRatePerSecond = 2 })
|
||||
ctx := context.Background()
|
||||
_, gaHash := testGA()
|
||||
call := mustRequest(t, s, 1, 2, gaHash)
|
||||
|
||||
// Accepted 前不可转发。
|
||||
if _, err := s.Signal(ctx, 1, call.ID, call.AccessHash, func(int64, domain.SessionRef) {}); !errors.Is(err, ErrPeerInvalid) {
|
||||
t.Fatalf("signal before accept err = %v, want ErrPeerInvalid", err)
|
||||
}
|
||||
if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
var forwarded []int64
|
||||
forward := func(peer int64, _ domain.SessionRef) { forwarded = append(forwarded, peer) }
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := s.Signal(ctx, 1, call.ID, call.AccessHash, forward); err != nil {
|
||||
t.Fatalf("signal %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// 限速 2/s:第三条被静默丢弃。
|
||||
if len(forwarded) != 2 || forwarded[0] != 2 || forwarded[1] != 2 {
|
||||
t.Fatalf("forwarded = %v, want [2 2]", forwarded)
|
||||
}
|
||||
clk.Advance(time.Second)
|
||||
if drop, err := s.Signal(ctx, 2, call.ID, call.AccessHash, forward); err != nil || drop {
|
||||
t.Fatalf("signal new window drop=%v err=%v", drop, err)
|
||||
}
|
||||
if forwarded[len(forwarded)-1] != 1 {
|
||||
t.Fatalf("callee→caller forward peer = %d, want 1", forwarded[len(forwarded)-1])
|
||||
}
|
||||
// 终态尾包静默吞掉。
|
||||
if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
if drop, err := s.Signal(ctx, 1, call.ID, call.AccessHash, forward); err != nil || !drop {
|
||||
t.Fatalf("signal after discard drop=%v err=%v, want drop", drop, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegotiateProtocol(t *testing.T) {
|
||||
base := func(min, max int, versions ...string) domain.PhoneCallProtocol {
|
||||
return domain.PhoneCallProtocol{UDPP2P: true, UDPReflector: true, MinLayer: min, MaxLayer: max, LibraryVersions: versions}
|
||||
}
|
||||
t.Run("layer intersection", func(t *testing.T) {
|
||||
out, err := negotiateProtocol(base(65, 92, "9.0.0"), base(70, 110, "9.0.0"))
|
||||
if err != nil || out.MinLayer != 70 || out.MaxLayer != 92 {
|
||||
t.Fatalf("negotiated = %+v err=%v", out, err)
|
||||
}
|
||||
})
|
||||
t.Run("layer disjoint", func(t *testing.T) {
|
||||
if _, err := negotiateProtocol(base(65, 70, "9.0.0"), base(80, 92, "9.0.0")); !errors.Is(err, ErrProtocolCompatLayerInvalid) {
|
||||
t.Fatalf("err = %v, want ErrProtocolCompatLayerInvalid", err)
|
||||
}
|
||||
})
|
||||
t.Run("best common version is semver max", func(t *testing.T) {
|
||||
out, err := negotiateProtocol(base(65, 92, "11.0.0", "9.0.0", "2.4.4"), base(65, 92, "2.4.4", "9.0.0"))
|
||||
if err != nil || len(out.LibraryVersions) != 1 || out.LibraryVersions[0] != "9.0.0" {
|
||||
t.Fatalf("versions = %v err=%v, want [9.0.0]", out.LibraryVersions, err)
|
||||
}
|
||||
})
|
||||
t.Run("preferred version beats semver max", func(t *testing.T) {
|
||||
// ⚠ "9.0.0" 优先于更高版本:DrKLO 视频 gate 是字符串字典序比较
|
||||
//("1x.0.0" < "2.7.7" 会判不支持视频),且 12/13 走 V3 SCTP 信令。
|
||||
out, err := negotiateProtocol(base(65, 92, "13.0.0", "10.0.0", "9.0.0"), base(65, 92, "9.0.0", "10.0.0", "13.0.0"))
|
||||
if err != nil || out.LibraryVersions[0] != "9.0.0" {
|
||||
t.Fatalf("versions = %v err=%v, want preferred [9.0.0]", out.LibraryVersions, err)
|
||||
}
|
||||
})
|
||||
t.Run("numeric compare fallback not lexicographic", func(t *testing.T) {
|
||||
// 交集无 preferred 版本时退化为语义化最高(数值比较,非字典序)。
|
||||
out, err := negotiateProtocol(base(65, 92, "10.0.0", "11.0.0"), base(65, 92, "11.0.0", "10.0.0"))
|
||||
if err != nil || out.LibraryVersions[0] != "11.0.0" {
|
||||
t.Fatalf("versions = %v err=%v, want [11.0.0]", out.LibraryVersions, err)
|
||||
}
|
||||
})
|
||||
t.Run("no common versions passes callee list through", func(t *testing.T) {
|
||||
// ⚠ P1-3:版本无交集绝不拒绝通话,透传被叫列表。
|
||||
out, err := negotiateProtocol(base(65, 92, "11.0.0"), base(65, 92, "2.4.4", "3.0.0"))
|
||||
if err != nil || len(out.LibraryVersions) != 2 || out.LibraryVersions[0] != "2.4.4" {
|
||||
t.Fatalf("versions = %v err=%v, want callee passthrough", out.LibraryVersions, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateProtocol(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
p domain.PhoneCallProtocol
|
||||
want error
|
||||
}{
|
||||
{"min over max", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 93, MaxLayer: 92, LibraryVersions: []string{"9.0.0"}}, ErrProtocolLayerInvalid},
|
||||
{"max below 65", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 60, MaxLayer: 64, LibraryVersions: []string{"9.0.0"}}, ErrProtocolCompatLayerInvalid},
|
||||
{"no transport flags", domain.PhoneCallProtocol{MinLayer: 65, MaxLayer: 92, LibraryVersions: []string{"9.0.0"}}, ErrProtocolFlagsInvalid},
|
||||
{"no versions", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 65, MaxLayer: 92}, ErrProtocolFlagsInvalid},
|
||||
{"ok", testProtocol(), nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if err := validateProtocol(tc.p); !errors.Is(err, tc.want) {
|
||||
t.Fatalf("%s: err = %v, want %v", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCallExpireDue(t *testing.T) {
|
||||
clk := newTestClock()
|
||||
s := newTestService(clk)
|
||||
ctx := context.Background()
|
||||
ga, gaHash := testGA()
|
||||
|
||||
// 三通通话:振铃中(→missed)、Accepted 悬挂(→disconnect)、Confirmed(不回收)。
|
||||
ringingCall := mustRequest(t, s, 1, 2, gaHash)
|
||||
acceptedCall, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{CalleeID: 4, RandomID: 1, GAHash: gaHash, Protocol: testProtocol()})
|
||||
if err != nil {
|
||||
t.Fatalf("request accepted call: %v", err)
|
||||
}
|
||||
if _, err := s.AcceptCall(ctx, 4, acceptedCall.ID, acceptedCall.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
confirmedCall, err := s.RequestCall(ctx, 5, domain.PhoneCallRequest{CalleeID: 6, RandomID: 2, GAHash: gaHash, Protocol: testProtocol()})
|
||||
if err != nil {
|
||||
t.Fatalf("request confirmed call: %v", err)
|
||||
}
|
||||
if _, err := s.AcceptCall(ctx, 6, confirmedCall.ID, confirmedCall.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil {
|
||||
t.Fatalf("accept confirmed: %v", err)
|
||||
}
|
||||
if _, _, err := s.ConfirmCall(ctx, 5, confirmedCall.ID, confirmedCall.AccessHash, ga, 1, testProtocol()); err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
|
||||
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
|
||||
t.Fatalf("nothing should expire yet, got %d", len(got))
|
||||
}
|
||||
clk.Advance(91 * time.Second)
|
||||
expired := s.ExpireDue(ctx, clk.Now())
|
||||
if len(expired) != 2 {
|
||||
t.Fatalf("expired = %d, want 2 (ringing+accepted)", len(expired))
|
||||
}
|
||||
reasons := map[int64]domain.PhoneCallDiscardReason{}
|
||||
for _, c := range expired {
|
||||
reasons[c.ID] = c.DiscardReason
|
||||
}
|
||||
if reasons[ringingCall.ID] != domain.PhoneCallDiscardReasonMissed {
|
||||
t.Fatalf("ringing call reason = %s, want missed", reasons[ringingCall.ID])
|
||||
}
|
||||
if reasons[acceptedCall.ID] != domain.PhoneCallDiscardReasonDisconnect {
|
||||
t.Fatalf("accepted call reason = %s, want disconnect", reasons[acceptedCall.ID])
|
||||
}
|
||||
// Confirmed 通话不受服务端时长限制。
|
||||
if snap, ok := s.Lookup(ctx, confirmedCall.ID, confirmedCall.AccessHash); !ok || snap.State != domain.PhoneCallStateConfirmed {
|
||||
t.Fatalf("confirmed call = %+v ok=%v, want untouched", snap, ok)
|
||||
}
|
||||
// 幂等:再跑一轮无新增。
|
||||
if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 {
|
||||
t.Fatalf("second ExpireDue = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue