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

58
internal/app/phone/dh.go Normal file
View 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
}

View 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 &registry{
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
}

View 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
}

View 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))
}
}