mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-08-09 20:07:17 +03:00
Add call protocol, Rmux. Update AmneziaWG. Fixes and improvements
This commit is contained in:
341
transport/call/telemost/api.go
Normal file
341
transport/call/telemost/api.go
Normal 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{}{},
|
||||
},
|
||||
}
|
||||
}
|
||||
72
transport/call/telemost/config.go
Normal file
72
transport/call/telemost/config.go
Normal 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
|
||||
}
|
||||
97
transport/call/telemost/connect.go
Normal file
97
transport/call/telemost/connect.go
Normal 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
|
||||
}
|
||||
848
transport/call/telemost/creator.go
Normal file
848
transport/call/telemost/creator.go
Normal 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 ""
|
||||
}
|
||||
933
transport/call/telemost/joiner.go
Normal file
933
transport/call/telemost/joiner.go
Normal 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), ¶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")
|
||||
}
|
||||
267
transport/call/telemost/relay.go
Normal file
267
transport/call/telemost/relay.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user