From 406a8805d88da7c8ab003cc8ce5694de6b05ad05 Mon Sep 17 00:00:00 2001 From: onysd Date: Mon, 20 Jul 2026 15:08:53 +0300 Subject: [PATCH] fix --- cmd/telesrv/main.go | 1 + internal/config/config.go | 5 ++ internal/rpc/phone_turn.go | 20 ++++++- internal/rpc/phone_turn_test.go | 98 +++++++++++++++++++++++++++++++++ internal/turnsrv/turnsrv.go | 24 ++++++-- 5 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 internal/rpc/phone_turn_test.go diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index fa23e834..147addec 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -644,6 +644,7 @@ func run(logger *zap.Logger) error { t, err := turnsrv.New(turnsrv.Config{ UDPPort: cfg.TURNUDPPort, AdvertiseIP: turnAdvertise, + ExtraIPs: cfg.TURNExtraIPs, SharedSecret: cfg.TURNSecret, RelayMinPort: cfg.TURNRelayMinPort, RelayMaxPort: cfg.TURNRelayMaxPort, diff --git a/internal/config/config.go b/internal/config/config.go index 546bd18f..f95d11ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -353,6 +353,10 @@ type Config struct { // TURNAdvertiseIP 是写进 phoneConnectionWebrtc 与 relay 分配的客户端可达 // 地址,默认回落 SFUAdvertiseIP → AdvertiseIP。 TURNAdvertiseIP string + // TURNExtraIPs 是额外下发的 TURN/STUN 候选 IP(逗号分隔)。当客户端与服务器 + // 同处一个局域网、AdvertiseIP 又是公网 IP 时,把 LAN IP(如 192.168.x.x)列进 + // 这里,可修复 LAN 端发起通话「Failed to connect」(NAT hairpin)。详见 turnsrv.Config.ExtraIPs。 + TURNExtraIPs []string // TURNSecret 是 TURN REST 凭据 HMAC 密钥;为空则进程级随机(单实例自洽, // 多实例/外部 coturn 必须显式配置同一值)。 TURNSecret string @@ -607,6 +611,7 @@ func Load() (Config, error) { TURNEnable: envBoolOr("TELESRV_TURN_ENABLE", true), TURNUDPPort: envIntOr("TELESRV_TURN_UDP_PORT", 12400), TURNAdvertiseIP: envOr("TELESRV_TURN_ADVERTISE_IP", ""), + TURNExtraIPs: envListOr("TELESRV_TURN_EXTRA_IPS", nil), TURNSecret: envOr("TELESRV_TURN_SECRET", ""), TURNRelayMinPort: envIntOr("TELESRV_TURN_RELAY_MIN_PORT", 12500), TURNRelayMaxPort: envIntOr("TELESRV_TURN_RELAY_MAX_PORT", 12999), diff --git a/internal/rpc/phone_turn.go b/internal/rpc/phone_turn.go index 617cbd47..4fd81f7e 100644 --- a/internal/rpc/phone_turn.go +++ b/internal/rpc/phone_turn.go @@ -50,8 +50,22 @@ func (r *Router) phoneCallConnections(callerID int64) []domain.PhoneCallConnecti // stun flag——单条目 stun+turn 在 Android 上只会产出 TURN server、丢失 STUN //(org_telegram_messenger_voip_Instance.cpp:848-884)。TDesktop 两种写法都认。 // TURN username 是 REST 格式 ":",天然避开 "reflector" 劫持禁区。 - return []domain.PhoneCallConnection{ - {ID: 1, IP: t.IP(), Port: t.Port(), Stun: true}, - {ID: 2, IP: t.IP(), Port: t.Port(), Username: username, Password: password, Turn: true}, + // + // 每个可达 IP(AdvertiseIP + ExtraIPs)都下发一对 STUN/TURN 候选,ICE 逐一 + // 尝试并选可达者:LAN 客户端走 LAN IP、外网客户端走公网 IP。id 必须全局唯一 + // 且从 1 递增(DrKLO 用 id 做 reflector 映射)。凭据与 IP 无关(TURN REST 只 + // 校验 HMAC),同一份 username/password 对所有 IP 有效。 + ips := append([]string{t.IP()}, t.ExtraIPs()...) + conns := make([]domain.PhoneCallConnection, 0, len(ips)*2) + id := int64(1) + for _, ip := range ips { + if ip == "" { + continue + } + conns = append(conns, domain.PhoneCallConnection{ID: id, IP: ip, Port: t.Port(), Stun: true}) + id++ + conns = append(conns, domain.PhoneCallConnection{ID: id, IP: ip, Port: t.Port(), Username: username, Password: password, Turn: true}) + id++ } + return conns } diff --git a/internal/rpc/phone_turn_test.go b/internal/rpc/phone_turn_test.go new file mode 100644 index 00000000..322e54a1 --- /dev/null +++ b/internal/rpc/phone_turn_test.go @@ -0,0 +1,98 @@ +package rpc + +import ( + "testing" + + "github.com/iamxvbaba/td/clock" + "go.uber.org/zap" +) + +// fakeTURN is a minimal turnsrv.Service for exercising phoneCallConnections. +type fakeTURN struct { + ip string + port int + extra []string + enabled bool + credUser string + credPass string +} + +func (f *fakeTURN) Enabled() bool { return f.enabled } +func (f *fakeTURN) Credentials(string) (string, string, error) { + return f.credUser, f.credPass, nil +} +func (f *fakeTURN) IP() string { return f.ip } +func (f *fakeTURN) Port() int { return f.port } +func (f *fakeTURN) ExtraIPs() []string { return f.extra } +func (f *fakeTURN) Close() error { return nil } + +func newTURNRouter(t *testing.T, turn *fakeTURN) *Router { + t.Helper() + return New(Config{}, Deps{TURN: turn}, zap.NewNop(), clock.System) +} + +func TestPhoneCallConnectionsDisabledTURNReturnsNil(t *testing.T) { + r := newTURNRouter(t, &fakeTURN{enabled: false}) + if conns := r.phoneCallConnections(1); conns != nil { + t.Fatalf("disabled TURN: want nil, got %+v", conns) + } +} + +func TestPhoneCallConnectionsSingleIP(t *testing.T) { + r := newTURNRouter(t, &fakeTURN{ + enabled: true, ip: "89.28.58.29", port: 12400, + credUser: "u", credPass: "p", + }) + conns := r.phoneCallConnections(1) + if len(conns) != 2 { + t.Fatalf("single IP: want 2 conns (stun+turn), got %d: %+v", len(conns), conns) + } + if !conns[0].Stun || conns[0].Turn || conns[0].ID != 1 || conns[0].IP != "89.28.58.29" { + t.Fatalf("conn[0] should be STUN id1 on public IP, got %+v", conns[0]) + } + if !conns[1].Turn || conns[1].Stun || conns[1].ID != 2 || conns[1].Username != "u" || conns[1].Password != "p" { + t.Fatalf("conn[1] should be TURN id2 with creds, got %+v", conns[1]) + } +} + +func TestPhoneCallConnectionsExtraIPsAddCandidatesWithUniqueIDs(t *testing.T) { + r := newTURNRouter(t, &fakeTURN{ + enabled: true, ip: "89.28.58.29", port: 12400, + extra: []string{"192.168.0.20", ""}, // empty entry must be skipped + credUser: "u", credPass: "p", + }) + conns := r.phoneCallConnections(1) + // public (stun+turn) + LAN (stun+turn) = 4; the empty extra IP is skipped. + if len(conns) != 4 { + t.Fatalf("want 4 conns, got %d: %+v", len(conns), conns) + } + seenIDs := map[int64]bool{} + for _, c := range conns { + if seenIDs[c.ID] { + t.Fatalf("duplicate connection id %d: %+v", c.ID, conns) + } + seenIDs[c.ID] = true + if c.IP == "" { + t.Fatalf("empty IP leaked into connections: %+v", conns) + } + } + // IDs must be a contiguous 1..4 run (DrKLO maps reflectors by id). + for id := int64(1); id <= 4; id++ { + if !seenIDs[id] { + t.Fatalf("missing contiguous id %d: %+v", id, conns) + } + } + // The LAN IP must appear as both a STUN and a TURN candidate. + var lanStun, lanTurn bool + for _, c := range conns { + if c.IP == "192.168.0.20" && c.Stun { + lanStun = true + } + if c.IP == "192.168.0.20" && c.Turn { + lanTurn = true + } + } + if !lanStun || !lanTurn { + t.Fatalf("LAN IP must yield both STUN and TURN candidates, got %+v", conns) + } +} diff --git a/internal/turnsrv/turnsrv.go b/internal/turnsrv/turnsrv.go index 956cf617..a06392d9 100644 --- a/internal/turnsrv/turnsrv.go +++ b/internal/turnsrv/turnsrv.go @@ -29,6 +29,14 @@ type Config struct { // AdvertiseIP 是写进 phoneConnectionWebrtc 与 relay 分配地址的客户端可达 // 地址。⚠ 127.0.0.1 时真机拿到的 relay candidate 不可达(媒体面静默失败)。 AdvertiseIP string + // ExtraIPs 是额外下发的 TURN/STUN 候选地址(除 AdvertiseIP 外)。典型用途: + // 当客户端与服务器同处一个局域网、而 AdvertiseIP 是公网 IP 时,路由器多半不 + // 支持 NAT 回环(hairpin),LAN 内的客户端无法经公网 IP 触达 TURN 控制通道 + //(现象:LAN 端发起的通话「Failed to connect」,外网端发起的却正常)。把 LAN + // IP(如 192.168.x.x)列进这里,ICE 会一并尝试,LAN 客户端走 LAN 候选、外网 + // 客户端走公网候选。relay 分配地址仍是 AdvertiseIP(LAN-LAN 通话用 host + // 候选直连、不经 relay,故公网 relay 地址不成问题)。 + ExtraIPs []string // Realm 是 TURN long-term credential 的 realm(任意稳定串即可)。 Realm string // SharedSecret 是 REST 凭据的 HMAC 密钥;为空则进程级随机生成 @@ -54,6 +62,8 @@ type Service interface { // IP/Port 返回客户端可达的服务地址。 IP() string Port() int + // ExtraIPs 返回除 IP() 外额外下发的候选地址(可空)。 + ExtraIPs() []string Close() error } @@ -66,9 +76,10 @@ func (disabled) Enabled() bool { return false } func (disabled) Credentials(string) (string, string, error) { return "", "", fmt.Errorf("turnsrv: disabled") } -func (disabled) IP() string { return "" } -func (disabled) Port() int { return 0 } -func (disabled) Close() error { return nil } +func (disabled) IP() string { return "" } +func (disabled) Port() int { return 0 } +func (disabled) ExtraIPs() []string { return nil } +func (disabled) Close() error { return nil } type pionTURN struct { cfg Config @@ -150,9 +161,10 @@ func (t *pionTURN) Credentials(user string) (string, string, error) { return username, password, nil } -func (t *pionTURN) IP() string { return t.cfg.AdvertiseIP } -func (t *pionTURN) Port() int { return t.cfg.UDPPort } -func (t *pionTURN) Close() error { return t.server.Close() } +func (t *pionTURN) IP() string { return t.cfg.AdvertiseIP } +func (t *pionTURN) Port() int { return t.cfg.UDPPort } +func (t *pionTURN) ExtraIPs() []string { return t.cfg.ExtraIPs } +func (t *pionTURN) Close() error { return t.server.Close() } func randomSecret() (string, error) { buf := make([]byte, 24)