Add call protocol, Rmux. Update AmneziaWG. Fixes and improvements

This commit is contained in:
Shtorm
2026-08-06 14:19:34 +03:00
parent 051e928b01
commit d6b9f693c4
89 changed files with 15671 additions and 132 deletions

View File

@@ -0,0 +1,341 @@
package telemost
import (
"encoding/json"
"fmt"
"io"
mathrand "math/rand"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"github.com/pion/interceptor"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/transport/call/common"
)
const (
APIBase = "https://cloud-api.yandex.ru/telemost_front/v2/telemost"
Origin = "https://telemost.yandex.ru"
)
var CapabilitiesOffer = map[string][]string{
"offerAnswerMode": {"SEPARATE"},
"initialSubscriberOffer": {"ON_HELLO"},
"slotsMode": {"FROM_CONTROLLER"},
"simulcastMode": {"DISABLED", "STATIC"},
"selfVadStatus": {"FROM_SERVER", "FROM_CLIENT"},
"dataChannelSharing": {"TO_RTP"},
"videoEncoderConfig": {"NO_CONFIG", "ONLY_INIT_CONFIG", "RUNTIME_CONFIG"},
"dataChannelVideoCodec": {"VP8", "UNIQUE_CODEC_FROM_TRACK_DESCRIPTION"},
"bandwidthLimitationReason": {"BANDWIDTH_REASON_DISABLED", "BANDWIDTH_REASON_ENABLED"},
"sdkDefaultDeviceManagement": {"SDK_DEFAULT_DEVICE_MANAGEMENT_DISABLED", "SDK_DEFAULT_DEVICE_MANAGEMENT_ENABLED"},
"joinOrderLayout": {"JOIN_ORDER_LAYOUT_DISABLED", "JOIN_ORDER_LAYOUT_ENABLED"},
"pinLayout": {"PIN_LAYOUT_DISABLED"},
"sendSelfViewVideoSlot": {"SEND_SELF_VIEW_VIDEO_SLOT_DISABLED", "SEND_SELF_VIEW_VIDEO_SLOT_ENABLED"},
"serverLayoutTransition": {"SERVER_LAYOUT_TRANSITION_DISABLED"},
"sdkPublisherOptimizeBitrate": {"SDK_PUBLISHER_OPTIMIZE_BITRATE_DISABLED", "SDK_PUBLISHER_OPTIMIZE_BITRATE_FULL", "SDK_PUBLISHER_OPTIMIZE_BITRATE_ONLY_SELF"},
"sdkNetworkLostDetection": {"SDK_NETWORK_LOST_DETECTION_DISABLED"},
"sdkNetworkPathMonitor": {"SDK_NETWORK_PATH_MONITOR_DISABLED"},
"publisherVp9": {"PUBLISH_VP9_DISABLED", "PUBLISH_VP9_ENABLED"},
"svcMode": {"SVC_MODE_DISABLED", "SVC_MODE_L3T3", "SVC_MODE_L3T3_KEY"},
"subscriberOfferAsyncAck": {"SUBSCRIBER_OFFER_ASYNC_ACK_DISABLED", "SUBSCRIBER_OFFER_ASYNC_ACK_ENABLED"},
"subscriberDtlsPassiveMode": {"SUBSCRIBER_DTLS_PASSIVE_MODE_DISABLED", "SUBSCRIBER_DTLS_PASSIVE_MODE_ENABLED"},
"androidBluetoothRoutingFix": {"ANDROID_BLUETOOTH_ROUTING_FIX_DISABLED"},
"fixedIceCandidatesPoolSize": {"FIXED_ICE_CANDIDATES_POOL_SIZE_DISABLED"},
"sdkAndroidTelecomIntegration": {"SDK_ANDROID_TELECOM_INTEGRATION_DISABLED"},
"setActiveCodecsMode": {"SET_ACTIVE_CODECS_MODE_DISABLED", "SET_ACTIVE_CODECS_MODE_VIDEO_ONLY"},
"publisherOpusDred": {"PUBLISHER_OPUS_DRED_DISABLED"},
"publisherOpusLowBitrate": {"PUBLISHER_OPUS_LOW_BITRATE_DISABLED"},
"sdkAndroidDestroySessionOnTaskRemoved": {"SDK_ANDROID_DESTROY_SESSION_ON_TASK_REMOVED_DISABLED"},
"svcModes": {"FALSE"},
"reportTelemetryModes": {"TRUE"},
"keepDefaultDevicesModes": {"FALSE"},
}
var StartupSlotSizes = [][][2]int{
{{0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}, {0, 0}},
{{464, 261}, {464, 261}, {464, 261}, {336, 189}, {272, 153}, {272, 153}, {272, 153}, {272, 153}, {224, 126}, {224, 126}, {224, 126}, {224, 126}},
{{464, 261}, {464, 261}, {464, 261}, {336, 189}, {272, 153}, {272, 153}, {272, 153}, {272, 153}, {224, 126}, {224, 126}, {224, 126}, {224, 126}},
{{672, 378}, {672, 378}, {464, 261}, {336, 189}, {320, 180}, {320, 180}, {320, 180}, {320, 180}, {272, 153}, {272, 153}, {224, 126}, {224, 126}},
}
type SlotBindEvent struct {
Slot int
ParticipantID string
Mid string
Reason string
}
type Client struct {
HTTP *http.Client
Cookie string
UserAgent string
AppVersion string
InstanceID string
}
func (c *Client) Do(method, path string, body interface{}) ([]byte, int, error) {
var bodyReader io.Reader
if body != nil {
data, _ := json.Marshal(body)
bodyReader = strings.NewReader(string(data))
}
req, err := http.NewRequest(method, APIBase+path, bodyReader)
if err != nil {
return nil, 0, err
}
ua := c.UserAgent
if ua == "" {
ua = common.UserAgent
}
instanceID := c.InstanceID
if instanceID == "" {
instanceID = uuid.New().String()
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Origin", Origin)
req.Header.Set("Referer", Origin+"/")
req.Header.Set("Client-Instance-Id", instanceID)
if c.Cookie != "" {
req.Header.Set("Cookie", c.Cookie)
}
if c.AppVersion != "" {
req.Header.Set("X-Telemost-Client-Version", c.AppVersion)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := c.HTTP
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
return data, resp.StatusCode, err
}
func (c *Client) TMRequest(method, path string) ([]byte, int, error) {
return c.Do(method, path, nil)
}
func (c *Client) RequestStates(joinURI, peerID string) error {
confURL := url.QueryEscape(joinURI)
body := map[string]interface{}{
"peers": []map[string]string{{"peer_id": peerID}},
"permissions": map[string]interface{}{},
"conference": map[string]interface{}{"version": -1},
}
r, status, err := c.Do("POST", "/conferences/"+confURL+"/request-states", body)
if err != nil {
return err
}
if status != 200 {
return fmt.Errorf("status %d: %s", status, string(r))
}
return nil
}
func NewAPI(settingEngine *webrtc.SettingEngine) (*webrtc.API, error) {
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
return nil, err
}
for _, uri := range []string{
"urn:ietf:params:rtp-hdrext:toffset",
"http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time",
"urn:3gpp:video-orientation",
"http://www.webrtc.org/experiments/rtp-hdrext/playout-delay",
"http://www.webrtc.org/experiments/rtp-hdrext/video-content-type",
"http://www.webrtc.org/experiments/rtp-hdrext/video-timing",
"http://www.webrtc.org/experiments/rtp-hdrext/color-space",
} {
if err := mediaEngine.RegisterHeaderExtension(
webrtc.RTPHeaderExtensionCapability{URI: uri},
webrtc.RTPCodecTypeVideo,
); err != nil {
return nil, fmt.Errorf("register header extension %s: %w", uri, err)
}
}
registry := &interceptor.Registry{}
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, registry); err != nil {
return nil, err
}
opts := []func(*webrtc.API){
webrtc.WithMediaEngine(mediaEngine),
webrtc.WithInterceptorRegistry(registry),
}
if settingEngine != nil {
opts = append(opts, webrtc.WithSettingEngine(*settingEngine))
}
return webrtc.NewAPI(opts...), nil
}
func NewPeerConnection(config webrtc.Configuration) (*webrtc.PeerConnection, error) {
api, err := NewAPI(nil)
if err != nil {
return nil, err
}
return api.NewPeerConnection(config)
}
func MungeSDPAddVideoContent(sdp string) string {
lines := strings.Split(sdp, "\r\n")
out := make([]string, 0, len(lines)+4)
inVideo := false
inserted := false
for _, line := range lines {
if strings.HasPrefix(line, "m=") {
if inVideo && !inserted {
out = append(out, "a=content:speaker,main")
inserted = true
}
inVideo = strings.HasPrefix(line, "m=video")
inserted = false
}
out = append(out, line)
if inVideo && !inserted && strings.HasPrefix(line, "a=mid:") {
out = append(out, "a=content:speaker,main")
inserted = true
}
}
return strings.Join(out, "\r\n")
}
func SlotsConfigBindings(v interface{}) []SlotBindEvent {
m, ok := v.(map[string]interface{})
if !ok {
return nil
}
slots, _ := m["slots"].([]interface{})
var out []SlotBindEvent
for idx, s := range slots {
sm, _ := s.(map[string]interface{})
if pv, _ := sm["participantVideoByMid"].(map[string]interface{}); pv != nil {
pid, _ := pv["participantId"].(string)
mid, _ := pv["mid"].(string)
reason, _ := pv["limitationReason"].(string)
out = append(out, SlotBindEvent{Slot: idx, ParticipantID: pid, Mid: mid, Reason: reason})
continue
}
if p, _ := sm["participant"].(map[string]interface{}); p != nil {
pid, _ := p["participantId"].(string)
out = append(out, SlotBindEvent{Slot: idx, ParticipantID: pid})
}
}
return out
}
func BriefJSON(v interface{}) string {
const max = 240
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("<json err: %v>", err)
}
if len(b) > max {
return string(b[:max]) + "...(+" + fmt.Sprintf("%d", len(b)-max) + "B)"
}
return string(b)
}
func SetSlotsMessage(key int) map[string]interface{} {
rnd := mathrand.New(mathrand.NewSource(time.Now().UnixNano()))
return slotsMessageWithSizes(key, StartupSlotSizes[len(StartupSlotSizes)-1], rnd)
}
func StartupSetSlotsMessage(i, key int) map[string]interface{} {
rnd := mathrand.New(mathrand.NewSource(time.Now().UnixNano() + int64(i)))
return slotsMessageWithSizes(key, StartupSlotSizes[i], rnd)
}
func SetSlotsOffsetMessage(offset int) map[string]interface{} {
return map[string]interface{}{
"uid": uuid.New().String(),
"setSlotsOffset": map[string]interface{}{"offset": offset},
}
}
func SdkCodecsInfoMessage() map[string]interface{} {
return map[string]interface{}{
"uid": uuid.New().String(),
"sdkCodecsInfo": map[string]interface{}{
"vp8": map[string]interface{}{
"supported": "CODEC_FEATURE_SUPPORTED",
"hwDecode": "CODEC_FEATURE_NOT_SUPPORTED",
"hwEncode": "CODEC_FEATURE_NOT_SUPPORTED",
"isoString": "vp8",
},
},
}
}
func UpdatePublisherTrackDescriptionMessage(pc *webrtc.PeerConnection, audioLabel, videoLabel string) map[string]interface{} {
descs := []map[string]interface{}{}
for _, tr := range pc.GetTransceivers() {
sender := tr.Sender()
if sender == nil || sender.Track() == nil {
continue
}
kind := strings.ToUpper(sender.Track().Kind().String())
mid := tr.Mid()
label := videoLabel
groupId := 2
if kind == "AUDIO" {
label = audioLabel
groupId = 1
}
descs = append(descs, map[string]interface{}{
"mid": mid,
"transceiverMid": mid,
"kind": kind,
"priority": 0,
"label": label,
"codecs": map[string]interface{}{},
"groupId": groupId,
"description": "",
})
}
return map[string]interface{}{
"uid": uuid.New().String(),
"updatePublisherTrackDescription": map[string]interface{}{
"publisherTrackDescriptions": descs,
},
}
}
func jitterSize(width int, rnd *mathrand.Rand) (int, int) {
if width == 0 {
return 0, 0
}
w := width + rnd.Intn(11) - 5
return w, w * 9 / 16
}
func slotsMessageWithSizes(key int, template [][2]int, rnd *mathrand.Rand) map[string]interface{} {
slots := make([]map[string]interface{}, len(template))
for i, wh := range template {
w, h := wh[0], wh[1]
if rnd != nil {
w, h = jitterSize(wh[0], rnd)
}
slots[i] = map[string]interface{}{"width": w, "height": h}
}
return map[string]interface{}{
"uid": uuid.New().String(),
"setSlots": map[string]interface{}{
"slots": slots,
"audioSlotsCount": 0,
"key": key,
"shutdownAllVideo": nil,
"withSelfView": true,
"selfViewVisibility": "ON_LOADING_THEN_SHOW",
"gridConfig": map[string]interface{}{},
},
}
}

View File

@@ -0,0 +1,72 @@
package telemost
import (
"encoding/json"
"fmt"
"regexp"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
type TMConfig struct {
AppVersion string
SDKVersion string
}
func FetchConfig(dialer N.Dialer, logger logger.ContextLogger) (TMConfig, error) {
var cfg TMConfig
page, err := common.HttpGet(dialer, "https://telemost.yandex.ru/")
if err != nil {
return cfg, fmt.Errorf("failed to fetch telemost.yandex.ru: %w", err)
}
stateRe := regexp.MustCompile(`<script[^>]*id="preloaded-state"[^>]*>([\s\S]*?)</script>`)
stateMatch := stateRe.FindSubmatch(page)
if stateMatch == nil {
return cfg, fmt.Errorf("preloaded-state not found in page")
}
var state struct {
Config struct {
AppVersion string `json:"appVersion"`
} `json:"config"`
AppVersion string `json:"appVersion"`
}
if err := json.Unmarshal(stateMatch[1], &state); err != nil {
return cfg, fmt.Errorf("failed to parse preloaded-state: %w", err)
}
cfg.AppVersion = state.Config.AppVersion
if cfg.AppVersion == "" {
cfg.AppVersion = state.AppVersion
}
if cfg.AppVersion == "" {
return cfg, fmt.Errorf("appVersion not found in preloaded-state")
}
logger.Debug(fmt.Sprintf("[config] appVersion=%s", cfg.AppVersion))
bundleRe := regexp.MustCompile(`https://telemost\.yastatic\.net/s3/telemost/_/main\.\w+\.[a-f0-9]+\.js`)
bundleURL := bundleRe.FindString(string(page))
if bundleURL == "" {
return cfg, fmt.Errorf("main bundle URL not found in page")
}
logger.Debug(fmt.Sprintf("[config] Found bundle: %s", bundleURL))
bundle, err := common.HttpGet(dialer, bundleURL)
if err != nil {
return cfg, fmt.Errorf("failed to fetch bundle: %w", err)
}
sdkVerPatterns := []*regexp.Regexp{
regexp.MustCompile(`goloom_sdk_version:"(\d+\.\d+\.\d+)"`),
regexp.MustCompile(`"@yandex-video-platform/goloom-sdk":"(\d+\.\d+\.\d+)"`),
regexp.MustCompile(`goloom-sdk\.(\d+\.\d+\.\d+)\.js`),
}
for _, re := range sdkVerPatterns {
if m := re.FindSubmatch(bundle); m != nil {
cfg.SDKVersion = string(m[1])
break
}
}
if cfg.SDKVersion == "" {
return cfg, fmt.Errorf("goloom SDK version not found in bundle")
}
logger.Debug(fmt.Sprintf("[config] app=%s sdk=%s", cfg.AppVersion, cfg.SDKVersion))
return cfg, nil
}

View File

@@ -0,0 +1,97 @@
package telemost
import (
"context"
"fmt"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
func ConnectCreator(ctx context.Context, cookieStr, joinLink string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
cfg, err := FetchConfig(dialer, logger)
if err != nil {
return nil, "", err
}
var connInfo *ConnInfo
if joinLink != "" {
connInfo, err = joinExistingConference(dialer, cookieStr, joinLink, cfg, logger)
} else {
connInfo, err = CreateAndJoinCall(dialer, cookieStr, cfg, logger)
}
if err != nil {
return nil, "", err
}
if readBuf <= 0 {
readBuf = 32768
}
bridge := &Bridge{
connInfo: connInfo,
config: cfg,
cookieStr: cookieStr,
peers: make(map[string]string),
readBuf: readBuf,
dialer: dialer,
logger: logger,
}
go bridge.Run()
deadline := time.Now().Add(60 * time.Second)
for bridge.activeBridge == nil {
if time.Now().After(deadline) {
return nil, "", fmt.Errorf("telemost: creator tunnel timed out")
}
select {
case <-ctx.Done():
return nil, "", ctx.Err()
case <-time.After(200 * time.Millisecond):
}
}
return bridge.activeBridge, connInfo.ConferenceURI, nil
}
func ConnectJoiner(ctx context.Context, joinLink, displayName string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
if displayName == "" {
displayName = "Joiner"
}
joiner := NewTelemostJoiner(
logger,
dialer,
dnsRouter,
nil,
common.AddTunnelTracks,
common.ReadTrack,
)
tunCh := make(chan tunnel.DataTunnel, 1)
joiner.OnConnected = func(tun tunnel.DataTunnel) {
select {
case tunCh <- tun:
default:
}
}
params := fmt.Sprintf(`{"joinLink":%q,"displayName":%q}`, joinLink, displayName)
go joiner.RunWithParams(params)
select {
case tun := <-tunCh:
return tun, nil
case <-ctx.Done():
joiner.Close()
return nil, ctx.Err()
}
}
func CreateConferenceForTest(dialer N.Dialer, cookieStr string) (string, error) {
nop := logger.NOP()
cfg, err := FetchConfig(dialer, nop)
if err != nil {
return "", err
}
connInfo, err := CreateAndJoinCall(dialer, cookieStr, cfg, nop)
if err != nil {
return "", err
}
return connInfo.ConferenceURI, nil
}

View File

@@ -0,0 +1,848 @@
package telemost
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
"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
)
var clientInstanceID = uuid.New().String()
type ConnInfo struct {
ConferenceURI string
RoomID string
PeerID string
Credentials string
MediaServerURL string
ServiceName string
ICEServers []webrtc.ICEServer
StateCheckIntervalS int
}
type Bridge struct {
mu sync.Mutex
ws *websocket.Conn
relay *SFURelay
connInfo *ConnInfo
config TMConfig
cookieStr string
pubSeq int
subSeq int
peers map[string]string
readBuf int
activeBridge *tunnel.RelayBridge
selfName string
dialer N.Dialer
logger logger.ContextLogger
setSlotsKey int
initBundleSent bool
pendingKicks map[string]chan struct{}
boundPeers map[string]bool
unboundPeers map[string]bool
}
func tmRequest(dialer N.Dialer, method, path string, body interface{}, cookieStr string, cfg TMConfig) ([]byte, int, error) {
c := Client{HTTP: common.HttpClient(dialer), Cookie: cookieStr, AppVersion: cfg.AppVersion, InstanceID: clientInstanceID}
return c.Do(method, path, body)
}
func parseICEServersJSON(raw json.RawMessage) []webrtc.ICEServer {
var rawIce []struct {
URLs []string `json:"urls"`
Username string `json:"username"`
Credential string `json:"credential"`
}
json.Unmarshal(raw, &rawIce)
var out []webrtc.ICEServer
for _, s := range rawIce {
ice := webrtc.ICEServer{URLs: s.URLs}
if s.Username != "" {
ice.Username = s.Username
ice.Credential = s.Credential
}
out = append(out, ice)
}
return out
}
func getConnection(dialer N.Dialer, cookieStr, confURL string, cfg TMConfig) (*ConnInfo, error) {
r, status, err := tmRequest(dialer, "GET",
"/conferences/"+confURL+"/connection?next_gen_media_platform_allowed=true&display_name=Headless&waiting_room_supported=true",
nil, cookieStr, cfg)
if err != nil {
return nil, fmt.Errorf("get connection: %w", err)
}
if status != 200 {
return nil, fmt.Errorf("get connection: status %d: %s", status, string(r))
}
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(r, &conn)
if conn.ClientConfig.MediaServerURL == "" {
return nil, fmt.Errorf("empty media_server_url: %s", string(r))
}
return &ConnInfo{
RoomID: conn.RoomID,
PeerID: conn.PeerID,
Credentials: conn.Credentials,
MediaServerURL: conn.ClientConfig.MediaServerURL,
ServiceName: conn.ClientConfig.ServiceName,
ICEServers: parseICEServersJSON(conn.ClientConfig.ICEServers),
StateCheckIntervalS: conn.ClientConfig.StateCheckIntervalSecs,
}, nil
}
func joinExistingConference(dialer N.Dialer, cookieStr, conferenceURI string, cfg TMConfig, logger logger.ContextLogger) (*ConnInfo, error) {
conferenceURI = strings.TrimSpace(conferenceURI)
if conferenceURI == "" {
return nil, fmt.Errorf("empty -tm-link")
}
logger.Info(fmt.Sprintf("[auth] Joining existing conference: %s", conferenceURI))
info, err := getConnection(dialer, cookieStr, url.QueryEscape(conferenceURI), cfg)
if err != nil {
return nil, err
}
info.ConferenceURI = conferenceURI
logger.Debug(fmt.Sprintf("[auth] peer_id=%s room_id=%s", info.PeerID, info.RoomID))
logger.Debug(fmt.Sprintf("[auth] media_server=%s", info.MediaServerURL))
return info, nil
}
func CreateAndJoinCall(dialer N.Dialer, cookieStr string, cfg TMConfig, logger logger.ContextLogger) (*ConnInfo, error) {
logger.Info("[auth] Creating conference...")
r, status, err := tmRequest(dialer, "POST", "/conferences?next_gen_media_platform_allowed=true",
struct{}{}, cookieStr, cfg)
if err != nil {
return nil, fmt.Errorf("create conference: %w", err)
}
if status != 200 && status != 201 {
return nil, fmt.Errorf("create conference: status %d: %s", status, string(r))
}
var conf struct {
URI string `json:"uri"`
}
json.Unmarshal(r, &conf)
if conf.URI == "" {
return nil, fmt.Errorf("empty conference URI: %s", string(r))
}
logger.Info(fmt.Sprintf("[auth] Conference: %s", conf.URI))
logger.Debug("[auth] Getting connection...")
info, err := getConnection(dialer, cookieStr, url.QueryEscape(conf.URI), cfg)
if err != nil {
return nil, err
}
info.ConferenceURI = conf.URI
logger.Debug(fmt.Sprintf("[auth] peer_id=%s room_id=%s", info.PeerID, info.RoomID))
logger.Debug(fmt.Sprintf("[auth] media_server=%s", info.MediaServerURL))
return info, nil
}
func (b *Bridge) wsSend(msg interface{}) {
b.mu.Lock()
defer b.mu.Unlock()
if b.ws == nil {
return
}
data, _ := json.Marshal(msg)
b.ws.WriteMessage(websocket.TextMessage, data)
}
func (b *Bridge) ack(uid string) {
b.wsSend(map[string]interface{}{
"uid": uid,
"ack": map[string]interface{}{
"status": map[string]interface{}{"code": "OK", "description": ""},
},
})
}
func (b *Bridge) sendHello() {
b.mu.Lock()
b.selfName = "Headless"
b.mu.Unlock()
b.wsSend(map[string]interface{}{
"uid": uuid.New().String(),
"hello": map[string]interface{}{
"participantMeta": map[string]interface{}{"name": "Headless", "role": "SPEAKER", "description": "", "sendAudio": false, "sendVideo": true},
"participantAttributes": map[string]interface{}{"name": "Headless", "role": "SPEAKER", "description": ""},
"sendAudio": false, "sendVideo": true, "sendSharing": false,
"participantId": b.connInfo.PeerID, "roomId": b.connInfo.RoomID,
"serviceName": b.connInfo.ServiceName, "credentials": b.connInfo.Credentials,
"capabilitiesOffer": CapabilitiesOffer,
"sdkInfo": map[string]interface{}{"implementation": "browser", "version": b.config.SDKVersion, "userAgent": common.UserAgent, "hwConcurrency": 8},
"sdkInitializationId": uuid.New().String(),
"disablePublisher": false, "disableSubscriber": false, "disableSubscriberAudio": false,
},
})
b.logger.Debug("[tm-ws] -> hello")
}
func (b *Bridge) sendPubOffer() {
offer, err := b.relay.CreatePubOffer()
if err != nil {
b.logger.Warn(fmt.Sprintf("[tm-ws] pub offer failed: %v", err))
return
}
audioMid, videoMid := parseMids(offer.SDP)
b.logger.Debug(fmt.Sprintf("[tm-ws] -> publisherSdpOffer pcSeq=%d", b.pubSeq))
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": ""})
}
b.wsSend(map[string]interface{}{
"uid": uuid.New().String(),
"publisherSdpOffer": map[string]interface{}{"pcSeq": b.pubSeq, "sdp": offer.SDP, "tracks": tracks},
})
}
func (b *Bridge) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) {
c := cand.ToJSON()
mid := ""
if c.SDPMid != nil {
mid = *c.SDPMid
}
var idx uint16
if c.SDPMLineIndex != nil {
idx = *c.SDPMLineIndex
}
b.wsSend(map[string]interface{}{
"uid": uuid.New().String(),
"webrtcIceCandidate": map[string]interface{}{
"candidate": c.Candidate, "sdpMid": mid,
"usernameFragment": extractUfrag(c.Candidate),
"sdpMlineIndex": idx, "target": target, "pcSeq": pcSeq,
},
})
}
func (b *Bridge) requestVideoSlots() {
b.setSlotsKey++
b.logger.Debug(fmt.Sprintf("[tm-ws] -> setSlots key=%d", b.setSlotsKey))
b.wsSend(SetSlotsMessage(b.setSlotsKey))
}
func (b *Bridge) forceReconnect(reason string) {
oldPeerID := b.connInfo.PeerID
b.logger.Info(fmt.Sprintf("[tm-ws] forcing reconnect: %s", reason))
if oldPeerID != "" {
b.logger.Debug(fmt.Sprintf("[tm-ws] kicking self pid=%s to leave call cleanly", oldPeerID))
if err := b.kickPeer(oldPeerID); err != nil {
b.logger.Warn(fmt.Sprintf("[tm-ws] self-kick failed: %v", err))
}
}
clientInstanceID = uuid.New().String()
b.logger.Debug(fmt.Sprintf("[tm-ws] new instance-id=%s", clientInstanceID))
b.mu.Lock()
ws := b.ws
b.mu.Unlock()
if ws != nil {
ws.Close()
}
}
func (b *Bridge) sendInitBundle() {
if b.initBundleSent {
return
}
b.initBundleSent = true
b.logger.Debug("[tm-ws] -> sdkCodecsInfo + updatePublisherTrackDescription")
b.wsSend(SdkCodecsInfoMessage())
b.wsSend(UpdatePublisherTrackDescriptionMessage(b.relay.pubPC, "Microphone", "MacBook Pro Camera (0000:0001)"))
b.sendStartupSlotsRamp()
}
func (b *Bridge) sendStartupSlotsRamp() {
for i := 0; i < 4; i++ {
b.setSlotsKey++
b.logger.Debug(fmt.Sprintf("[tm-ws] -> setSlots key=%d (startup %d/4)", b.setSlotsKey, i+1))
b.wsSend(StartupSetSlotsMessage(i, b.setSlotsKey))
}
}
func (b *Bridge) handleMessage(raw []byte) {
var msg map[string]interface{}
if err := json.Unmarshal(raw, &msg); err != nil {
return
}
uid, _ := msg["uid"].(string)
if sh, ok := msg["serverHello"]; ok {
b.logger.Debug("[tm-ws] <- serverHello")
if shMap, ok := sh.(map[string]interface{}); ok {
b.parseICEServers(shMap)
}
b.ack(uid)
b.logger.Debug("[tm-ws] -> setSlotsOffset")
b.wsSend(SetSlotsOffsetMessage(0))
b.initRelay()
return
}
if pa, ok := msg["publisherSdpAnswer"]; ok {
paMap, _ := pa.(map[string]interface{})
sdp, _ := paMap["sdp"].(string)
b.logger.Debug(fmt.Sprintf("[tm-ws] <- publisherSdpAnswer %d bytes", len(sdp)))
if err := b.relay.SetPubAnswer(sdp); err != nil {
b.logger.Warn(fmt.Sprintf("[tm-ws] error: %v", err))
return
}
b.sendInitBundle()
return
}
if so, ok := msg["subscriberSdpOffer"]; ok {
soMap, _ := so.(map[string]interface{})
sdp, _ := soMap["sdp"].(string)
pcSeq, _ := soMap["pcSeq"].(float64)
b.subSeq = int(pcSeq)
b.logger.Debug(fmt.Sprintf("[tm-ws] <- subscriberSdpOffer pcSeq=%d", b.subSeq))
b.ack(uid)
answer, err := b.relay.SetSubOffer(sdp)
if err != nil {
b.logger.Warn(fmt.Sprintf("[tm-ws] error: %v", err))
return
}
b.logger.Debug(fmt.Sprintf("[tm-ws] -> subscriberSdpAnswer pcSeq=%d", b.subSeq))
b.wsSend(map[string]interface{}{
"uid": uuid.New().String(),
"subscriberSdpAnswer": map[string]interface{}{"sdp": answer.SDP, "pcSeq": b.subSeq},
})
b.sendPubOffer()
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 target == "PUBLISHER" {
b.relay.AddPubICECandidate(cand)
} else {
b.relay.AddSubICECandidate(cand)
}
b.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)
b.logger.Warn(fmt.Sprintf("[tm-ws] <- ack error: %s %s", code, desc))
}
}
}
return
}
if ud, ok := msg["updateDescription"]; ok {
b.logger.Debug(fmt.Sprintf("[tm-ws] <- updateDescription %s", BriefJSON(ud)))
udMap, _ := ud.(map[string]interface{})
descs, _ := udMap["description"].([]interface{})
b.applyDescriptionSnapshot(descs)
b.ack(uid)
return
}
if ud, ok := msg["upsertDescription"]; ok {
udMap, _ := ud.(map[string]interface{})
descs, _ := udMap["description"].([]interface{})
for _, d := range descs {
dm, _ := d.(map[string]interface{})
b.applyDescriptionEntry(dm)
}
b.kickStaleSelves()
b.ack(uid)
return
}
if rd, ok := msg["removeDescription"]; ok {
rdMap, _ := rd.(map[string]interface{})
ids, _ := rdMap["descriptionId"].([]interface{})
for _, id := range ids {
pid, _ := id.(string)
b.mu.Lock()
name := b.peers[pid]
delete(b.peers, pid)
remaining := len(b.peers)
ch, hadPendingKick := b.pendingKicks[pid]
if hadPendingKick {
delete(b.pendingKicks, pid)
}
b.mu.Unlock()
b.logger.Info(fmt.Sprintf("[tm-ws] Participant left: %s (%s) total=%d", name, pid, remaining))
if hadPendingKick {
close(ch)
}
if remaining == 0 {
go b.pollAndAdmit()
}
}
b.ack(uid)
return
}
if n, ok := msg["notification"]; ok {
b.logger.Debug(fmt.Sprintf("[tm-ws] <- notification %s", BriefJSON(n)))
b.ack(uid)
go b.pollAndAdmit()
return
}
if pc, ok := msg["participantsChanged"]; ok {
b.logger.Debug(fmt.Sprintf("[tm-ws] <- participantsChanged %s", BriefJSON(pc)))
b.ack(uid)
go b.pollAndAdmit()
return
}
if sc, ok := msg["slotsConfig"]; ok {
b.logger.Debug(fmt.Sprintf("[tm-ws] <- 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 != "" {
b.logger.Debug(fmt.Sprintf("[bind] BOUND slot=%d pid=%s mid=%s", ev.Slot, pid, ev.Mid))
b.mu.Lock()
if b.boundPeers == nil {
b.boundPeers = make(map[string]bool)
}
b.boundPeers[fullPid] = true
delete(b.unboundPeers, fullPid)
b.mu.Unlock()
} else if fullPid != "" {
b.mu.Lock()
wasBound := b.boundPeers[fullPid]
if wasBound {
if b.unboundPeers == nil {
b.unboundPeers = make(map[string]bool)
}
b.unboundPeers[fullPid] = true
delete(b.boundPeers, fullPid)
}
b.mu.Unlock()
if wasBound {
b.logger.Debug(fmt.Sprintf("[bind] KILL slot=%d pid=%s reason=%s - rebinding", ev.Slot, pid, ev.Reason))
needRebind = true
} else {
b.logger.Debug(fmt.Sprintf("[bind] UNBOUND slot=%d pid=%s reason=%s mid=%q", ev.Slot, pid, ev.Reason, ev.Mid))
}
}
}
b.mu.Lock()
for boundPid := range b.boundPeers {
if !presentPids[boundPid] {
short := boundPid
if len(short) > 8 {
short = short[:8]
}
b.logger.Debug(fmt.Sprintf("[bind] VANISHED pid=%s - rebinding", short))
delete(b.boundPeers, boundPid)
needRebind = true
}
}
b.mu.Unlock()
if needRebind {
go b.forceReconnect("slot binding killed")
}
b.ack(uid)
return
}
for k, v := range msg {
if k == "uid" || k == "ack" {
continue
}
b.logger.Debug(fmt.Sprintf("[tm-ws] <- %s (unhandled) %s", k, BriefJSON(v)))
break
}
if uid != "" {
b.ack(uid)
}
}
func (b *Bridge) parseICEServers(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, 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)
}
b.connInfo.ICEServers = iceServers
b.logger.Debug(fmt.Sprintf("[tm-ws] %d ICE servers", len(iceServers)))
}
func (b *Bridge) requestStates() error {
c := Client{HTTP: common.HttpClient(b.dialer), Cookie: b.cookieStr, AppVersion: b.config.AppVersion, InstanceID: clientInstanceID}
return c.RequestStates(b.connInfo.ConferenceURI, b.connInfo.PeerID)
}
func (b *Bridge) applyDescriptionEntry(dm map[string]interface{}) {
pid, _ := dm["id"].(string)
if pid == "" {
return
}
name := ""
if meta, ok := dm["meta"].(map[string]interface{}); ok {
name, _ = meta["name"].(string)
}
if pid == b.connInfo.PeerID {
b.mu.Lock()
if name != "" {
b.selfName = name
}
b.mu.Unlock()
return
}
_, disconnected := dm["disconnectedAt"]
b.mu.Lock()
_, wasKnown := b.peers[pid]
if disconnected {
delete(b.peers, pid)
} else {
b.peers[pid] = name
}
total := len(b.peers)
b.mu.Unlock()
switch {
case disconnected && wasKnown:
b.logger.Info(fmt.Sprintf("[tm-ws] Participant left: %s (%s) total=%d", name, pid, total))
case disconnected:
b.logger.Debug(fmt.Sprintf("[tm-ws] Ghost participant: %s (%s) - kicking", name, pid))
go b.kickPeer(pid)
case !wasKnown:
b.logger.Info(fmt.Sprintf("[tm-ws] Participant joined: %s (%s) total=%d", name, pid, total))
}
}
func (b *Bridge) applyDescriptionSnapshot(descs []interface{}) {
b.mu.Lock()
b.peers = make(map[string]string)
b.mu.Unlock()
for _, d := range descs {
dm, _ := d.(map[string]interface{})
b.applyDescriptionEntry(dm)
}
b.kickStaleSelves()
}
func (b *Bridge) kickStaleSelves() {
b.mu.Lock()
selfName := b.selfName
stale := make([]string, 0)
if selfName != "" {
for pid, name := range b.peers {
if name == selfName {
stale = append(stale, pid)
}
}
}
b.mu.Unlock()
for _, pid := range stale {
b.logger.Debug(fmt.Sprintf("[tm-ws] Kicking stale self %s (name=%q)", pid, selfName))
b.kickPeer(pid)
b.mu.Lock()
delete(b.peers, pid)
b.mu.Unlock()
}
}
func (b *Bridge) kickPeer(peerID string) error {
confURL := url.QueryEscape(b.connInfo.ConferenceURI)
b.logger.Debug(fmt.Sprintf("[tm-ws] Kicking %s", peerID))
body, status, err := tmRequest(b.dialer, "POST", "/conferences/"+confURL+"/commands/kick?peer_id="+url.QueryEscape(peerID)+"&with_ban=false",
nil, b.cookieStr, b.config)
if err != nil {
return err
}
if status >= 400 {
return fmt.Errorf("kick %s status %d: %s", peerID, status, string(body))
}
return nil
}
func (b *Bridge) pollAndAdmit() {
confURL := url.QueryEscape(b.connInfo.ConferenceURI)
r, status, err := tmRequest(b.dialer, "GET", "/conferences/"+confURL+"/waiting-rooms/peers", nil, b.cookieStr, b.config)
if err != nil || status != 200 {
return
}
var resp struct {
Peers []struct {
PeerID string `json:"peer_id"`
State struct {
DisplayName string `json:"display_name"`
} `json:"state"`
} `json:"peers"`
}
json.Unmarshal(r, &resp)
if len(resp.Peers) == 0 {
return
}
b.mu.Lock()
if b.pendingKicks == nil {
b.pendingKicks = make(map[string]chan struct{})
}
toKick := make(map[string]string, len(b.peers))
waits := make(map[string]<-chan struct{}, len(b.peers))
for pid, name := range b.peers {
toKick[pid] = name
ch := make(chan struct{})
b.pendingKicks[pid] = ch
waits[pid] = ch
}
b.mu.Unlock()
for pid, name := range toKick {
b.logger.Debug(fmt.Sprintf("[tm-ws] Kicking %s (%s) for one-to-one", name, pid))
if err := b.kickPeer(pid); err != nil {
b.logger.Warn(fmt.Sprintf("[tm-ws] kick failed: %v", err))
b.mu.Lock()
delete(b.pendingKicks, pid)
b.mu.Unlock()
return
}
}
for pid, ch := range waits {
<-ch
b.logger.Debug(fmt.Sprintf("[tm-ws] kick confirmed for %s", pid))
}
p := resp.Peers[0]
b.logger.Debug(fmt.Sprintf("[tm-ws] Admitting %s (%s)", p.State.DisplayName, p.PeerID))
tmRequest(b.dialer, "PUT", "/conferences/"+confURL+"/commands/admit?peer_id="+url.QueryEscape(p.PeerID),
nil, b.cookieStr, b.config)
}
func (b *Bridge) initRelay() {
if b.relay != nil {
b.relay.Close()
}
b.pubSeq = 1
b.subSeq = 0
b.initBundleSent = false
relay := NewSFURelay(b.logger)
relay.readBufSize = b.readBuf
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(b.connInfo.ConferenceURI))
if err != nil {
b.logger.Fatal(fmt.Sprintf("[relay] obfuscator init failed: %v", err))
}
relay.SetObfuscator(obf)
b.logger.Debug(fmt.Sprintf("[relay] obfuscator localEpoch=0x%08x", obf.LocalEpoch()))
relay.OnPubReady = func() {
b.logger.Debug("[relay] pub PC connected")
}
relay.OnConnected = func(tun *tunnel.VP8DataTunnel) {
if b.activeBridge != nil {
b.activeBridge.Reset()
}
b.activeBridge = tunnel.NewRelayBridge(tun, "creator", common.VP8BufSize, b.dialer, b.logger)
b.logger.Debug("[relay] tunnel connected")
}
relay.OnPeerRestart = func() {
if b.activeBridge != nil {
b.logger.Info("[relay] new peer detected, resetting relay bridge")
b.activeBridge.Reset()
}
}
relay.OnPubICE = func(cand *webrtc.ICECandidate) {
if cand == nil {
return
}
b.sendICE(cand, "PUBLISHER", b.pubSeq)
}
relay.OnSubICE = func(cand *webrtc.ICECandidate) {
if cand == nil {
return
}
b.sendICE(cand, "SUBSCRIBER", b.subSeq)
}
if err := relay.Init(b.connInfo.ICEServers); err != nil {
b.logger.Fatal(fmt.Sprintf("[relay] init failed: %v", err))
}
b.relay = relay
}
func (b *Bridge) Run() {
wsHeader := http.Header{}
wsHeader.Set("User-Agent", common.UserAgent)
wsHeader.Set("Origin", tmOrigin)
wsDialer := websocket.Dialer{
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return b.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
}
for {
b.logger.Debug("[tm-ws] Connecting...")
ws, _, err := wsDialer.Dial(b.connInfo.MediaServerURL, wsHeader)
if err != nil {
b.logger.Warn(fmt.Sprintf("[tm-ws] Connect failed: %s, retrying in 5s...", common.MaskError(err)))
time.Sleep(5 * time.Second)
continue
}
b.mu.Lock()
b.ws = ws
b.mu.Unlock()
b.logger.Debug("[tm-ws] Connected")
b.sendHello()
go b.pollAndAdmit()
stopWaitingRoomPoll := make(chan struct{})
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-stopWaitingRoomPoll:
return
case <-ticker.C:
b.pollAndAdmit()
}
}
}()
stopPing := make(chan struct{})
go func() {
ticker := time.NewTicker(tmPingPeriod)
defer ticker.Stop()
for {
select {
case <-stopPing:
return
case <-ticker.C:
b.wsSend(map[string]interface{}{"uid": uuid.New().String(), "ping": map[string]interface{}{}})
}
}
}()
stopStateKeepalive := make(chan struct{})
go func() {
interval := b.connInfo.StateCheckIntervalS
if interval <= 0 {
interval = 30
}
if err := b.requestStates(); err != nil {
b.logger.Debug(fmt.Sprintf("[tm-state] 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 := b.requestStates(); err != nil {
b.logger.Debug(fmt.Sprintf("[tm-state] request-states: %v", err))
}
}
}
}()
for {
_, raw, err := ws.ReadMessage()
if err != nil {
b.logger.Debug(fmt.Sprintf("[tm-ws] Closed: %s", common.MaskError(err)))
break
}
b.handleMessage(raw)
}
close(stopPing)
close(stopStateKeepalive)
close(stopWaitingRoomPoll)
b.mu.Lock()
b.ws = nil
b.mu.Unlock()
b.logger.Debug("[tm-ws] Rejoining in 3s...")
time.Sleep(3 * time.Second)
newConn, err := getConnection(b.dialer, b.cookieStr, url.QueryEscape(b.connInfo.ConferenceURI), b.config)
if err != nil {
b.logger.Warn(fmt.Sprintf("[rejoin] Failed: %v, retrying in 5s...", err))
time.Sleep(5 * time.Second)
continue
}
b.connInfo.PeerID = newConn.PeerID
b.connInfo.Credentials = newConn.Credentials
b.connInfo.MediaServerURL = newConn.MediaServerURL
b.connInfo.ICEServers = newConn.ICEServers
b.connInfo.StateCheckIntervalS = newConn.StateCheckIntervalS
}
}
func parseMids(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 extractUfrag(candidate string) string {
parts := strings.Split(candidate, " ")
for i, p := range parts {
if p == "ufrag" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}

View File

@@ -0,0 +1,933 @@
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), &params); 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")
}

View File

@@ -0,0 +1,267 @@
package telemost
import (
"fmt"
"sync"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing/common/logger"
)
type SFURelay struct {
pubPC *webrtc.PeerConnection
subPC *webrtc.PeerConnection
pubRemoteSet bool
subRemoteSet bool
pubPending []webrtc.ICECandidateInit
subPending []webrtc.ICECandidateInit
mu sync.Mutex
logger logger.ContextLogger
sampleTrack *webrtc.TrackLocalStaticSample
tun *tunnel.VP8DataTunnel
obf *tunnel.TunnelObfuscator
OnConnected func(*tunnel.VP8DataTunnel)
OnPubReady func()
OnPeerRestart func()
OnPubICE func(*webrtc.ICECandidate)
OnSubICE func(*webrtc.ICECandidate)
readBufSize int
}
func (r *SFURelay) SetObfuscator(o *tunnel.TunnelObfuscator) { r.obf = o }
func NewSFURelay(logger logger.ContextLogger) *SFURelay {
return &SFURelay{logger: logger}
}
func (r *SFURelay) Init(iceServers []webrtc.ICEServer) error {
config := webrtc.Configuration{ICEServers: iceServers}
pubPC, err := NewPeerConnection(config)
if err != nil {
return err
}
r.pubPC = pubPC
sampleTrack, _ := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8},
"video", "tunnel-video",
)
r.sampleTrack = sampleTrack
audioTrack, _ := webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
"audio", "tunnel-audio",
)
pubPC.AddTransceiverFromTrack(audioTrack, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
pubPC.AddTransceiverFromTrack(r.sampleTrack, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly})
pubPC.OnICECandidate(func(cand *webrtc.ICECandidate) {
if cand == nil || r.OnPubICE == nil {
return
}
r.OnPubICE(cand)
})
pubPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
r.logger.Debug(fmt.Sprintf("[pub] connection state: %s", state.String()))
if state == webrtc.PeerConnectionStateConnected {
if r.tun == nil {
r.logger.Debug("[relay] starting VP8 publish tunnel on pub PC connected")
r.tun = tunnel.NewVP8DataTunnel(r.sampleTrack, r.obf, r.logger)
r.tun.Start(0, 0)
if r.OnConnected != nil {
r.OnConnected(r.tun)
}
}
if r.OnPubReady != nil {
r.OnPubReady()
}
}
})
subPC, err := NewPeerConnection(config)
if err != nil {
pubPC.Close()
return err
}
r.subPC = subPC
subPC.OnICECandidate(func(cand *webrtc.ICECandidate) {
if cand == nil || r.OnSubICE == nil {
return
}
r.OnSubICE(cand)
})
subPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
r.logger.Debug(fmt.Sprintf("[sub] connection state: %s", state.String()))
})
subPC.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
r.logger.Debug(fmt.Sprintf("[sub] remote track: %s", track.Codec().MimeType))
go r.readTrack(track)
})
r.logger.Debug(fmt.Sprintf("[relay] pub+sub PCs created (%d ICE servers)", len(iceServers)))
return nil
}
func (r *SFURelay) CreatePubOffer() (webrtc.SessionDescription, error) {
offer, err := r.pubPC.CreateOffer(nil)
if err != nil {
return offer, err
}
if err := r.pubPC.SetLocalDescription(offer); err != nil {
return offer, err
}
offer.SDP = MungeSDPAddVideoContent(offer.SDP)
return offer, nil
}
func (r *SFURelay) SetPubAnswer(sdp string) error {
r.mu.Lock()
defer r.mu.Unlock()
err := r.pubPC.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeAnswer, SDP: sdp,
})
if err != nil {
return err
}
r.pubRemoteSet = true
for _, cand := range r.pubPending {
r.pubPC.AddICECandidate(cand)
}
r.pubPending = nil
return nil
}
func (r *SFURelay) SetSubOffer(sdp string) (webrtc.SessionDescription, error) {
r.mu.Lock()
defer r.mu.Unlock()
err := r.subPC.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer, SDP: sdp,
})
if err != nil {
return webrtc.SessionDescription{}, err
}
r.subRemoteSet = true
for _, cand := range r.subPending {
r.subPC.AddICECandidate(cand)
}
r.subPending = nil
answer, err := r.subPC.CreateAnswer(nil)
if err != nil {
return answer, err
}
r.subPC.SetLocalDescription(answer)
return answer, nil
}
func (r *SFURelay) AddPubICECandidate(cand webrtc.ICECandidateInit) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.pubRemoteSet {
r.pubPending = append(r.pubPending, cand)
return
}
r.pubPC.AddICECandidate(cand)
}
func (r *SFURelay) AddSubICECandidate(cand webrtc.ICECandidateInit) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.subRemoteSet {
r.subPending = append(r.subPending, cand)
return
}
r.subPC.AddICECandidate(cand)
}
func (r *SFURelay) Close() {
if r.tun != nil {
r.tun.Stop()
r.tun = nil
}
if r.pubPC != nil {
r.pubPC.Close()
r.pubPC = nil
}
if r.subPC != nil {
r.subPC.Close()
r.subPC = nil
}
}
func (r *SFURelay) readTrack(track *webrtc.TrackRemote) {
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
buf := make([]byte, common.UDPBufSize)
for {
if _, _, err := track.Read(buf); err != nil {
return
}
}
}
var vp8Pkt codecs.VP8Packet
var pkt rtp.Packet
var frameBuf []byte
var lastSeq uint16
var haveLastSeq bool
frameValid := false
var recvCount int
bufSz := r.readBufSize
if bufSz <= 0 {
bufSz = common.RTPBufSize
}
buf := make([]byte, bufSz)
for {
n, _, err := track.Read(buf)
if err != nil {
return
}
if pkt.Unmarshal(buf[:n]) != nil {
continue
}
if haveLastSeq && pkt.SequenceNumber != lastSeq+1 {
frameValid = false
frameBuf = frameBuf[:0]
}
lastSeq = pkt.SequenceNumber
haveLastSeq = true
vp8Payload, err := vp8Pkt.Unmarshal(pkt.Payload)
if err != nil {
frameValid = false
frameBuf = frameBuf[:0]
continue
}
if vp8Pkt.S == 1 {
frameBuf = frameBuf[:0]
frameValid = true
}
if !frameValid {
continue
}
frameBuf = append(frameBuf, vp8Payload...)
if !pkt.Marker {
continue
}
recvCount++
if recvCount <= 3 || recvCount%200 == 0 {
r.logger.Debug(fmt.Sprintf("[video] recv vp8 frame #%d %d bytes", recvCount, len(frameBuf)))
}
res := r.obf.Decode(frameBuf)
frameBuf = frameBuf[:0]
frameValid = false
if !res.HasFrame || res.SelfEcho {
continue
}
if res.PeerRestart {
r.logger.Info(fmt.Sprintf("[video] peer restart detected, new epoch=0x%08x", res.PeerEpoch))
if r.OnPeerRestart != nil {
r.OnPeerRestart()
}
}
if res.Keepalive || len(res.Payload) == 0 {
continue
}
if r.tun != nil && r.tun.OnData != nil {
r.tun.OnData(res.Payload)
}
}
}