package telemost import ( "context" "crypto/tls" "encoding/json" "fmt" "net" "net/http" "net/netip" "net/url" "strings" "sync" "sync/atomic" "time" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/pion/webrtc/v4" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" "github.com/sagernet/sing-box/transport/call/common" "github.com/sagernet/sing-box/transport/call/tunnel" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" ) const ( TmAPIBase = APIBase TmOrigin = Origin TmPingPeriod = 5 * time.Second telemostReconnectInitialDelay = time.Second telemostReconnectMaxDelay = 16 * time.Second ) type TelemostJoiner struct { logger logger.ContextLogger OnConnected func(tunnel.DataTunnel) OnRemoteCandidate func(target int, candidateOrSDP string) dialer N.Dialer dnsRouter adapter.DNSRouter PCConfig common.PeerConnectionConfigurer AddTracks common.AddTunnelTracksFunc ReadTrackFn common.ReadTrackFunc joinLink string displayName string ws *websocket.Conn wsMu sync.Mutex subPC *webrtc.PeerConnection subSeq int subRemoteSet bool subPending []webrtc.ICECandidateInit pubPC *webrtc.PeerConnection pubSeq int pubRemoteSet bool pubPending []webrtc.ICECandidateInit sampleTrack *webrtc.TrackLocalStaticSample vp8tunnel *tunnel.VP8DataTunnel obf *tunnel.TunnelObfuscator vp8FPS int vp8Batch int httpClient *http.Client instanceID string peerID string roomID string credentials string serviceName string mediaURL string iceServers []webrtc.ICEServer stateCheckIntervalS int closeMu sync.Mutex closed bool stopCh chan struct{} stopOnce sync.Once configAck tunnel.ConfigAckTracker reconnectAttempt atomic.Int32 setSlotsKey int initBundleSent bool boundPeers map[string]bool unboundPeers map[string]bool boundMu sync.Mutex } func NewTelemostJoiner(logger logger.ContextLogger, dialer N.Dialer, dnsRouter adapter.DNSRouter, pcConfig common.PeerConnectionConfigurer, addTracks common.AddTunnelTracksFunc, readTrackFn common.ReadTrackFunc) *TelemostJoiner { return &TelemostJoiner{ logger: logger, dialer: dialer, dnsRouter: dnsRouter, PCConfig: pcConfig, AddTracks: addTracks, ReadTrackFn: readTrackFn, instanceID: uuid.New().String(), stopCh: make(chan struct{}), httpClient: &http.Client{ Timeout: 15 * time.Second, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) }, }, }, } } func (j *TelemostJoiner) RunWithParams(jsonParams string) { var params struct { JoinLink string `json:"joinLink"` DisplayName string `json:"displayName"` VP8FPS int `json:"vp8Fps"` VP8Batch int `json:"vp8Batch"` } if err := json.Unmarshal([]byte(jsonParams), ¶ms); err != nil { j.logger.Error(fmt.Sprintf("telemost-joiner: failed to parse params: %v", err)) return } j.joinLink = params.JoinLink j.displayName = params.DisplayName if j.displayName == "" { j.displayName = "Joiner" } obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(params.JoinLink)) if err != nil { j.logger.Error(fmt.Sprintf("telemost-joiner: obfuscator init failed: %v", err)) return } j.obf = obf j.vp8FPS = params.VP8FPS j.vp8Batch = params.VP8Batch j.logger.Info(fmt.Sprintf("telemost-joiner: link=%s name=%s vp8Fps=%d vp8Batch=%d localEpoch=0x%08x", j.joinLink, j.displayName, params.VP8FPS, params.VP8Batch, obf.LocalEpoch())) j.logger.Info("telemost-joiner: connecting") if err := j.runOnce(); err != nil { j.logger.Error(fmt.Sprintf("telemost-joiner: %v", err)) return } for { if j.isClosed() { return } j.logger.Info("telemost-joiner: tunnel lost") j.resetSessionState() if !j.waitBeforeRetry(int(j.reconnectAttempt.Load())) { return } j.reconnectAttempt.Add(1) if j.isClosed() { return } j.logger.Info(fmt.Sprintf("telemost-joiner: reconnect attempt #%d", j.reconnectAttempt.Load())) if err := j.runOnce(); err != nil { j.logger.Warn(fmt.Sprintf("telemost-joiner: %v, will retry", err)) } } } func (j *TelemostJoiner) Close() { j.closeMu.Lock() j.closed = true j.closeMu.Unlock() j.stopOnce.Do(func() { close(j.stopCh) }) j.wsMu.Lock() ws := j.ws j.ws = nil j.wsMu.Unlock() common.CloseWS(ws) if j.vp8tunnel != nil { j.vp8tunnel.Stop() } if j.subPC != nil { j.subPC.Close() } if j.pubPC != nil { j.pubPC.Close() } } func TmParseMids(sdp string) (audioMid, videoMid string) { var media string for _, line := range strings.Split(sdp, "\r\n") { if strings.HasPrefix(line, "m=audio") { media = "audio" } else if strings.HasPrefix(line, "m=video") { media = "video" } if strings.HasPrefix(line, "a=mid:") { mid := strings.TrimPrefix(line, "a=mid:") if media == "audio" && audioMid == "" { audioMid = mid } else if media == "video" && videoMid == "" { videoMid = mid } } } return } func (j *TelemostJoiner) runOnce() error { if err := j.getConnection(); err != nil { return err } j.connectAndRun() return nil } func (j *TelemostJoiner) MarkConfigAcked() { j.configAck.Mark() } func (j *TelemostJoiner) waitBeforeRetry(attempt int) bool { delay := common.BackoffWithJitter(attempt, telemostReconnectInitialDelay, telemostReconnectMaxDelay) j.logger.Debug(fmt.Sprintf("telemost-joiner: waiting %s before reconnect", delay)) timer := time.NewTimer(delay) defer timer.Stop() select { case <-timer.C: return !j.isClosed() case <-j.stopCh: return false } } func (j *TelemostJoiner) resetSessionState() { j.wsMu.Lock() j.ws = nil j.wsMu.Unlock() j.subPC = nil j.subSeq = 0 j.subRemoteSet = false j.subPending = nil j.pubPC = nil j.pubSeq = 0 j.pubRemoteSet = false j.pubPending = nil j.sampleTrack = nil j.vp8tunnel = nil j.initBundleSent = false j.boundMu.Lock() j.boundPeers = nil j.unboundPeers = nil j.boundMu.Unlock() } func (j *TelemostJoiner) isClosed() bool { j.closeMu.Lock() defer j.closeMu.Unlock() return j.closed } func (j *TelemostJoiner) apiClient() *Client { return &Client{HTTP: j.httpClient, InstanceID: j.instanceID} } func (j *TelemostJoiner) getConnection() error { confURL := url.QueryEscape(j.joinLink) name := url.QueryEscape(j.displayName) if name == "" { name = "Joiner" } connPath := "/conferences/" + confURL + "/connection?next_gen_media_platform_allowed=true&display_name=" + name + "&waiting_room_supported=true" j.logger.Debug(fmt.Sprintf("telemost-joiner: getting connection for %s", j.joinLink)) responseBody, status, err := j.apiClient().TMRequest("GET", connPath) if err != nil { return fmt.Errorf("get connection: %w", err) } if status != 200 { return fmt.Errorf("get connection: status %d: %s", status, string(responseBody)) } var initial struct { ConnectionType string `json:"connection_type"` ClientConfig struct { CheckInterval int `json:"conference_check_access_interval_ms"` } `json:"client_configuration"` } json.Unmarshal(responseBody, &initial) if initial.ConnectionType == "WAITING_ROOM" { interval := initial.ClientConfig.CheckInterval if interval <= 0 { interval = 3000 } checkPath := "/conferences/" + confURL + "/waiting-rooms/check-access" j.logger.Info(fmt.Sprintf("telemost-joiner: in waiting room, polling check-access every %dms...", interval)) for { time.Sleep(time.Duration(interval) * time.Millisecond) checkBody, checkStatus, checkErr := j.apiClient().TMRequest("GET", checkPath) if checkErr != nil { return fmt.Errorf("waiting room check-access: %w", checkErr) } if checkStatus != 200 { return fmt.Errorf("waiting room check-access: status %d", checkStatus) } var check struct { Admitted bool `json:"admitted"` } json.Unmarshal(checkBody, &check) if check.Admitted { j.logger.Info("telemost-joiner: admitted!") break } } responseBody, status, err = j.apiClient().TMRequest("GET", connPath) if err != nil { return fmt.Errorf("post-admit connection: %w", err) } if status != 200 { return fmt.Errorf("post-admit connection: status %d: %s", status, string(responseBody)) } } var conn struct { PeerID string `json:"peer_id"` RoomID string `json:"room_id"` Credentials string `json:"credentials"` ClientConfig struct { MediaServerURL string `json:"media_server_url"` ServiceName string `json:"service_name"` ICEServers json.RawMessage `json:"ice_servers"` StateCheckIntervalSecs int `json:"state_check_interval_seconds"` } `json:"client_configuration"` } json.Unmarshal(responseBody, &conn) if conn.ClientConfig.MediaServerURL == "" { return fmt.Errorf("empty media_server_url: %s", string(responseBody)) } j.peerID = conn.PeerID j.roomID = conn.RoomID j.credentials = conn.Credentials j.mediaURL = conn.ClientConfig.MediaServerURL j.serviceName = conn.ClientConfig.ServiceName j.stateCheckIntervalS = conn.ClientConfig.StateCheckIntervalSecs var rawIce []struct { URLs []string `json:"urls"` Username string `json:"username"` Credential string `json:"credential"` } json.Unmarshal(conn.ClientConfig.ICEServers, &rawIce) for _, s := range rawIce { ice := webrtc.ICEServer{URLs: s.URLs} if s.Username != "" { ice.Username = s.Username ice.Credential = s.Credential } j.iceServers = append(j.iceServers, ice) } j.logger.Debug(fmt.Sprintf("telemost-joiner: peer_id=%s room_id=%s media_url=%s", j.peerID, j.roomID, j.mediaURL)) return nil } func (j *TelemostJoiner) wsSend(msg interface{}) { j.wsMu.Lock() defer j.wsMu.Unlock() if j.ws != nil { data, _ := json.Marshal(msg) j.logger.Debug(fmt.Sprintf("telemost-joiner: [DIAG] -> %s", string(data))) j.ws.WriteJSON(msg) } } func (j *TelemostJoiner) ack(uid string) { if uid == "" { return } j.wsSend(map[string]interface{}{ "uid": uid, "ack": map[string]interface{}{ "status": map[string]interface{}{"code": "OK", "description": ""}, }, }) } func (j *TelemostJoiner) sendHello() { j.wsSend(map[string]interface{}{ "uid": uuid.New().String(), "hello": map[string]interface{}{ "participantMeta": map[string]interface{}{"name": j.displayName, "role": "SPEAKER", "description": "", "sendAudio": false, "sendVideo": true}, "participantAttributes": map[string]interface{}{"name": j.displayName, "role": "SPEAKER", "description": ""}, "sendAudio": false, "sendVideo": true, "sendSharing": false, "participantId": j.peerID, "roomId": j.roomID, "serviceName": j.serviceName, "credentials": j.credentials, "capabilitiesOffer": CapabilitiesOffer, "sdkInfo": map[string]interface{}{"implementation": "browser", "version": "6.0.0", "userAgent": common.UserAgent, "hwConcurrency": 8}, "sdkInitializationId": uuid.New().String(), "disablePublisher": false, "disableSubscriber": false, "disableSubscriberAudio": false, }, }) j.logger.Debug("telemost-joiner: -> hello") } func (j *TelemostJoiner) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) { candidate := cand.ToJSON() j.wsSend(map[string]interface{}{ "uid": uuid.New().String(), "webrtcIceCandidate": map[string]interface{}{ "candidate": candidate.Candidate, "sdpMid": *candidate.SDPMid, "sdpMlineIndex": *candidate.SDPMLineIndex, "target": target, "pcSeq": pcSeq, }, }) } func (j *TelemostJoiner) initPC() { config := webrtc.Configuration{ICEServers: j.iceServers} settingEngine := webrtc.SettingEngine{} settingEngine.DetachDataChannels() if j.PCConfig != nil { j.PCConfig.ConfigureSettingEngine(&settingEngine) } api, err := NewAPI(&settingEngine) if err != nil { j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create webrtc API: %v", err)) return } subPC, err := api.NewPeerConnection(config) if err != nil { j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create sub PC: %v", err)) return } j.subPC = subPC subPC.OnICECandidate(func(cand *webrtc.ICECandidate) { if cand != nil { j.sendICE(cand, "SUBSCRIBER", j.subSeq) } }) subPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { j.logger.Debug(fmt.Sprintf("telemost-joiner: sub PC state: %s", state.String())) if state == webrtc.PeerConnectionStateFailed { j.logger.Error("telemost-joiner: ERROR: subscriber connection failed") } }) subPC.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { j.logger.Debug(fmt.Sprintf("telemost-joiner: sub remote track: %s", track.Codec().MimeType)) go j.ReadTrackFn(track, func(frame []byte) { if j.vp8tunnel != nil { j.vp8tunnel.HandleFrame(frame) } }, j.logger, "telemost-joiner") }) pubPC, err := api.NewPeerConnection(config) if err != nil { j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create pub PC: %v", err)) return } j.pubPC = pubPC j.pubSeq = 1 j.sampleTrack = j.AddTracks(pubPC, j.logger, "telemost-joiner [pub]") pubPC.OnICECandidate(func(cand *webrtc.ICECandidate) { if cand != nil { j.sendICE(cand, "PUBLISHER", j.pubSeq) } }) pubPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { j.logger.Debug(fmt.Sprintf("telemost-joiner: pub PC state: %s", state.String())) if state == webrtc.PeerConnectionStateConnected && j.vp8tunnel == nil { j.reconnectAttempt.Store(0) j.logger.Info("telemost-joiner: === VP8 TUNNEL CONNECTED ===") j.vp8tunnel = tunnel.NewVP8DataTunnel(j.sampleTrack, j.obf, j.logger) vp8tun := j.vp8tunnel vp8tun.Start(j.vp8FPS, j.vp8Batch) if !j.configAck.Acknowledged() { acked, cancel := j.configAck.Arm() go tunnel.SendVP8ConfigUntilAcked(acked, cancel, j.stopCh, vp8tun, vp8tun.FPS(), vp8tun.Batch(), 1, j.logger, "telemost-joiner") j.logger.Debug(fmt.Sprintf("telemost-joiner: pushed vp8 config to creator fps=%d batch=%d", vp8tun.FPS(), vp8tun.Batch())) } if j.OnConnected != nil { j.OnConnected(j.vp8tunnel) } } }) j.logger.Debug(fmt.Sprintf("telemost-joiner: sub+pub PCs created with %d ICE servers", len(j.iceServers))) } func (j *TelemostJoiner) sendPubOffer() { if j.pubPC == nil { return } offer, err := j.pubPC.CreateOffer(nil) if err != nil { j.logger.Warn(fmt.Sprintf("telemost-joiner: pub offer failed: %v", err)) return } if err := j.pubPC.SetLocalDescription(offer); err != nil { j.logger.Warn(fmt.Sprintf("telemost-joiner: set pub local desc: %v", err)) return } offer.SDP = MungeSDPAddVideoContent(offer.SDP) audioMid, videoMid := TmParseMids(offer.SDP) j.logger.Debug(fmt.Sprintf("telemost-joiner: -> publisherSdpOffer pcSeq=%d audioMid=%s videoMid=%s", j.pubSeq, audioMid, videoMid)) var tracks []map[string]interface{} if audioMid != "" { tracks = append(tracks, map[string]interface{}{"mid": audioMid, "transceiverMid": audioMid, "kind": "AUDIO", "priority": 0, "label": "", "codecs": map[string]interface{}{}, "groupId": 1, "description": ""}) } if videoMid != "" { tracks = append(tracks, map[string]interface{}{"mid": videoMid, "transceiverMid": videoMid, "kind": "VIDEO", "priority": 0, "label": "", "codecs": map[string]interface{}{}, "groupId": 2, "description": ""}) } j.wsSend(map[string]interface{}{ "uid": uuid.New().String(), "publisherSdpOffer": map[string]interface{}{"pcSeq": j.pubSeq, "sdp": offer.SDP, "tracks": tracks}, }) } func (j *TelemostJoiner) handlePubAnswer(sdp string) { if j.pubPC == nil { return } if j.OnRemoteCandidate != nil { j.OnRemoteCandidate(-1, sdp) } err := j.pubPC.SetRemoteDescription(webrtc.SessionDescription{ Type: webrtc.SDPTypeAnswer, SDP: sdp, }) if err != nil { j.logger.Warn(fmt.Sprintf("telemost-joiner: set pub remote desc: %v", err)) return } j.pubRemoteSet = true for _, candidate := range j.pubPending { j.pubPC.AddICECandidate(candidate) } j.pubPending = nil j.sendInitBundle() } func (j *TelemostJoiner) sendInitBundle() { if j.initBundleSent { return } j.initBundleSent = true j.logger.Debug("telemost-joiner: -> sdkCodecsInfo + updatePublisherTrackDescription") j.wsSend(SdkCodecsInfoMessage()) j.wsSend(UpdatePublisherTrackDescriptionMessage(j.pubPC, "Microphone", "MacBook Pro Camera (0000:0001)")) j.sendStartupSlotsRamp() } func (j *TelemostJoiner) requestVideoSlots() { j.setSlotsKey++ j.logger.Debug(fmt.Sprintf("telemost-joiner: -> setSlots key=%d", j.setSlotsKey)) j.wsSend(SetSlotsMessage(j.setSlotsKey)) } func (j *TelemostJoiner) forceReconnect(reason string) { j.reconnectAttempt.Store(0) oldPeerID := j.peerID j.logger.Info(fmt.Sprintf("telemost-joiner: forcing reconnect: %s", reason)) if oldPeerID != "" { j.logger.Debug(fmt.Sprintf("telemost-joiner: kicking self pid=%s to leave call cleanly", oldPeerID)) confURL := url.QueryEscape(j.joinLink) _, status, err := j.apiClient().TMRequest("POST", "/conferences/"+confURL+"/commands/kick?peer_id="+url.QueryEscape(oldPeerID)+"&with_ban=false") if err != nil || status >= 400 { j.logger.Warn(fmt.Sprintf("telemost-joiner: self-kick failed: status=%d err=%v", status, err)) } } j.instanceID = uuid.New().String() j.logger.Debug(fmt.Sprintf("telemost-joiner: new instance-id=%s", j.instanceID)) j.wsMu.Lock() ws := j.ws j.wsMu.Unlock() common.CloseWS(ws) } func (j *TelemostJoiner) sendStartupSlotsRamp() { for i := 0; i < 4; i++ { j.setSlotsKey++ j.logger.Debug(fmt.Sprintf("telemost-joiner: -> setSlots key=%d (startup %d/4)", j.setSlotsKey, i+1)) j.wsSend(StartupSetSlotsMessage(i, j.setSlotsKey)) } } func (j *TelemostJoiner) handleSubOffer(sdp string, pcSeq int) { j.subSeq = pcSeq if j.subPC == nil { j.logger.Warn("telemost-joiner: sub PC not ready for offer") return } if j.OnRemoteCandidate != nil { j.OnRemoteCandidate(-1, sdp) } err := j.subPC.SetRemoteDescription(webrtc.SessionDescription{ Type: webrtc.SDPTypeOffer, SDP: sdp, }) if err != nil { j.logger.Warn(fmt.Sprintf("telemost-joiner: set sub remote desc: %v", err)) return } j.subRemoteSet = true for _, candidate := range j.subPending { j.subPC.AddICECandidate(candidate) } j.subPending = nil answer, err := j.subPC.CreateAnswer(nil) if err != nil { j.logger.Warn(fmt.Sprintf("telemost-joiner: create sub answer: %v", err)) return } j.subPC.SetLocalDescription(answer) j.logger.Debug(fmt.Sprintf("telemost-joiner: -> subscriberSdpAnswer pcSeq=%d", pcSeq)) j.wsSend(map[string]interface{}{ "uid": uuid.New().String(), "subscriberSdpAnswer": map[string]interface{}{"sdp": answer.SDP, "pcSeq": pcSeq}, }) j.sendPubOffer() } func (j *TelemostJoiner) handleMessage(raw []byte) { var msg map[string]interface{} if err := json.Unmarshal(raw, &msg); err != nil { return } uid, _ := msg["uid"].(string) if _, ok := msg["serverHello"]; ok { j.logger.Debug("telemost-joiner: <- serverHello") if sh, ok := msg["serverHello"].(map[string]interface{}); ok { j.parseICEServersFromHello(sh) } j.ack(uid) j.initPC() return } if so, ok := msg["subscriberSdpOffer"]; ok { soMap, _ := so.(map[string]interface{}) sdp, _ := soMap["sdp"].(string) pcSeq, _ := soMap["pcSeq"].(float64) j.logger.Debug(fmt.Sprintf("telemost-joiner: <- subscriberSdpOffer pcSeq=%d len=%d", int(pcSeq), len(sdp))) j.ack(uid) j.handleSubOffer(sdp, int(pcSeq)) return } if pa, ok := msg["publisherSdpAnswer"]; ok { paMap, _ := pa.(map[string]interface{}) sdp, _ := paMap["sdp"].(string) j.logger.Debug(fmt.Sprintf("telemost-joiner: <- publisherSdpAnswer %d bytes", len(sdp))) j.handlePubAnswer(sdp) return } if ic, ok := msg["webrtcIceCandidate"]; ok { icMap, _ := ic.(map[string]interface{}) candidate, _ := icMap["candidate"].(string) sdpMid, _ := icMap["sdpMid"].(string) target, _ := icMap["target"].(string) sdpIdx, _ := icMap["sdpMlineIndex"].(float64) idx := uint16(sdpIdx) cand := webrtc.ICECandidateInit{Candidate: candidate, SDPMid: &sdpMid, SDPMLineIndex: &idx} if j.OnRemoteCandidate != nil { tgt := 1 if target == "SUBSCRIBER" { tgt = 0 } j.OnRemoteCandidate(tgt, candidate) } if target == "SUBSCRIBER" { if j.subRemoteSet { j.subPC.AddICECandidate(cand) } else { j.subPending = append(j.subPending, cand) } } else if target == "PUBLISHER" { if j.pubRemoteSet { j.pubPC.AddICECandidate(cand) } else { j.pubPending = append(j.pubPending, cand) } } j.ack(uid) return } if ackData, ok := msg["ack"]; ok { if ackMap, ok := ackData.(map[string]interface{}); ok { if status, ok := ackMap["status"].(map[string]interface{}); ok { if code, _ := status["code"].(string); code != "OK" { desc, _ := status["description"].(string) j.logger.Warn(fmt.Sprintf("telemost-joiner: ack error: %s %s", code, desc)) } } } return } if ud, ok := msg["upsertDescription"]; ok { udMap, _ := ud.(map[string]interface{}) if descs, ok := udMap["description"].([]interface{}); ok { for _, d := range descs { dm, _ := d.(map[string]interface{}) pid, _ := dm["id"].(string) if pid != "" && pid != j.peerID { participantName := "" if meta, ok := dm["meta"].(map[string]interface{}); ok { participantName, _ = meta["name"].(string) } j.logger.Debug(fmt.Sprintf("telemost-joiner: participant: %s (%s)", participantName, pid)) } } } j.ack(uid) return } if ud, ok := msg["updateDescription"]; ok { j.logger.Debug(fmt.Sprintf("telemost-joiner: <- updateDescription %s", BriefJSON(ud))) j.ack(uid) return } if _, ok := msg["removeDescription"]; ok { j.logger.Info("telemost-joiner: participant left") j.ack(uid) return } if sc, ok := msg["slotsConfig"]; ok { j.logger.Debug(fmt.Sprintf("telemost-joiner: <- slotsConfig %s", BriefJSON(sc))) needRebind := false presentPids := make(map[string]bool) for _, ev := range SlotsConfigBindings(sc) { fullPid := ev.ParticipantID if fullPid != "" { presentPids[fullPid] = true } pid := fullPid if len(pid) > 8 { pid = pid[:8] } if ev.Reason == "NO_LIMITATION" && ev.Mid != "" { j.logger.Debug(fmt.Sprintf("telemost-joiner: [bind] BOUND slot=%d pid=%s mid=%s", ev.Slot, pid, ev.Mid)) j.boundMu.Lock() if j.boundPeers == nil { j.boundPeers = make(map[string]bool) } j.boundPeers[fullPid] = true delete(j.unboundPeers, fullPid) j.boundMu.Unlock() } else if fullPid != "" { j.boundMu.Lock() wasBound := j.boundPeers[fullPid] if wasBound { if j.unboundPeers == nil { j.unboundPeers = make(map[string]bool) } j.unboundPeers[fullPid] = true delete(j.boundPeers, fullPid) } j.boundMu.Unlock() if wasBound { j.logger.Debug(fmt.Sprintf("telemost-joiner: [bind] KILL slot=%d pid=%s reason=%s - rebinding", ev.Slot, pid, ev.Reason)) needRebind = true } else { j.logger.Debug(fmt.Sprintf("telemost-joiner: [bind] UNBOUND slot=%d pid=%s reason=%s mid=%q", ev.Slot, pid, ev.Reason, ev.Mid)) } } } j.boundMu.Lock() for boundPid := range j.boundPeers { if !presentPids[boundPid] { short := boundPid if len(short) > 8 { short = short[:8] } j.logger.Debug(fmt.Sprintf("telemost-joiner: [bind] VANISHED pid=%s - rebinding", short)) delete(j.boundPeers, boundPid) needRebind = true } } j.boundMu.Unlock() if needRebind { go j.forceReconnect("slot binding killed") } j.ack(uid) return } for k, v := range msg { if k == "uid" || k == "ack" { continue } j.logger.Debug(fmt.Sprintf("telemost-joiner: <- %s (unhandled) %s", k, BriefJSON(v))) break } if uid != "" { j.ack(uid) } } func (j *TelemostJoiner) parseICEServersFromHello(sh map[string]interface{}) { rtcCfg, ok := sh["rtcConfiguration"].(map[string]interface{}) if !ok { return } servers, ok := rtcCfg["iceServers"].([]interface{}) if !ok { return } var iceServers []webrtc.ICEServer for _, s := range servers { sm, _ := s.(map[string]interface{}) var urls []string if u, ok := sm["urls"].([]interface{}); ok { for _, v := range u { if vs, ok := v.(string); ok { urls = append(urls, common.FixICEURL(vs)) } } } ice := webrtc.ICEServer{URLs: urls} if u, ok := sm["username"].(string); ok && u != "" { ice.Username = u ice.Credential, _ = sm["credential"].(string) } iceServers = append(iceServers, ice) } resolved := make(map[string]string) for i, s := range iceServers { for k, u := range s.URLs { host := common.ExtractICEHost(u) if host == "" || net.ParseIP(host) != nil { continue } _, ok := resolved[host] if !ok { rd, hasRD := j.dialer.(dialer.ResolveDialer) if j.dnsRouter == nil || !hasRD { continue } var err error var addrs []netip.Addr addrs, err = j.dnsRouter.Lookup(context.Background(), host, rd.QueryOptions()) if err != nil { j.logger.Warn(fmt.Sprintf("telemost-joiner: resolve ICE host %s failed: %s", common.MaskAddr(host), common.MaskError(err))) continue } resolved[host] = addrs[0].String() j.logger.Debug(fmt.Sprintf("telemost-joiner: resolved ICE host %s -> %s", host, addrs[0])) } iceServers[i].URLs[k] = strings.Replace(u, host, resolved[host], 1) } } j.iceServers = iceServers for i, s := range iceServers { j.logger.Debug(fmt.Sprintf("telemost-joiner: ICE server %d: urls=%v", i, s.URLs)) } j.logger.Debug(fmt.Sprintf("telemost-joiner: %d ICE servers from serverHello", len(iceServers))) } func (j *TelemostJoiner) connectAndRun() { parsed, err := url.Parse(j.mediaURL) if err != nil { j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: bad media URL: %s", common.MaskError(err))) return } hostname := parsed.Hostname() wsHeader := http.Header{} wsHeader.Set("User-Agent", common.UserAgent) wsHeader.Set("Origin", TmOrigin) j.logger.Debug(fmt.Sprintf("telemost-joiner: connecting to %s", j.mediaURL)) dialer := websocket.Dialer{ HandshakeTimeout: 10 * time.Second, WriteBufferSize: 65536, TLSClientConfig: &tls.Config{InsecureSkipVerify: true, ServerName: hostname}, NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return j.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) }, } ws, _, err := dialer.Dial(j.mediaURL, wsHeader) if err != nil { j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: ws connect: %s", common.MaskError(err))) return } j.wsMu.Lock() j.ws = ws j.wsMu.Unlock() j.logger.Debug("telemost-joiner: ws connected") j.sendHello() stopPing := make(chan struct{}) go func() { ticker := time.NewTicker(TmPingPeriod) defer ticker.Stop() for { select { case <-stopPing: return case <-ticker.C: j.wsSend(map[string]interface{}{"uid": uuid.New().String(), "ping": map[string]interface{}{}}) } } }() stopStateKeepalive := make(chan struct{}) go func() { interval := j.stateCheckIntervalS if interval <= 0 { interval = 30 } if err := j.apiClient().RequestStates(j.joinLink, j.peerID); err != nil { j.logger.Debug(fmt.Sprintf("telemost-joiner: initial request-states: %v", err)) } ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() for { select { case <-stopStateKeepalive: return case <-ticker.C: if err := j.apiClient().RequestStates(j.joinLink, j.peerID); err != nil { j.logger.Debug(fmt.Sprintf("telemost-joiner: request-states: %v", err)) } } } }() for { _, raw, err := ws.ReadMessage() if err != nil { j.logger.Debug(fmt.Sprintf("telemost-joiner: ws read error: %s", common.MaskError(err))) break } j.handleMessage(raw) } close(stopPing) close(stopStateKeepalive) if j.vp8tunnel != nil { j.vp8tunnel.Stop() } if j.subPC != nil { j.subPC.Close() } if j.pubPC != nil { j.pubPC.Close() } j.logger.Info("telemost-joiner: disconnected") }