call: Update vk, telemost, dion and wbstream implementations

This commit is contained in:
Shtorm
2026-09-20 21:57:51 +03:00
parent 197cf99b73
commit 311d9b51eb
61 changed files with 5121 additions and 1363 deletions
+6 -7
View File
@@ -25,12 +25,12 @@ require (
github.com/gofrs/uuid/v5 v5.5.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/huandu/go-sqlbuilder v1.39.1
github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91
github.com/jackc/pgx/v5 v5.8.0
github.com/jsimonetti/rtnetlink v1.4.1
github.com/keybase/go-keychain v0.0.1
github.com/kulikov0/headless-client v0.1.0
github.com/libdns/acmedns v0.5.0
github.com/libdns/alidns v1.0.6
github.com/libdns/cloudflare v0.2.2
@@ -46,9 +46,7 @@ require (
github.com/pion/datachannel v1.6.0
github.com/pion/interceptor v0.1.44
github.com/pion/rtp v1.10.1
github.com/pion/webrtc/v4 v4.2.9
github.com/pkg/sftp v1.13.10
github.com/quic-go/quic-go v0.60.0
github.com/sagernet/asc-go v0.0.0-20260827085112-8f8226245b0d
github.com/sagernet/bbolt v0.0.0-20260823094646-e24805439c9c
github.com/sagernet/cors v1.2.1
@@ -124,19 +122,20 @@ require (
github.com/panjf2000/ants/v2 v2.12.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pion/dtls/v3 v3.1.5 // indirect
github.com/pion/ice/v4 v4.2.1 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/sctp v1.9.2 // indirect
github.com/pion/sctp v1.9.4 // indirect
github.com/pion/sdp/v3 v3.0.18 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pion/transport/v4 v4.1.0 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/redis/go-redis/v9 v9.8.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b // indirect
@@ -159,7 +158,7 @@ require (
github.com/ameshkov/dnscrypt/v2 v2.4.0
github.com/ameshkov/dnsstamps v1.0.3 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
+12 -16
View File
@@ -34,8 +34,8 @@ github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1O
github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A=
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
@@ -210,8 +210,6 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
@@ -264,6 +262,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kulikov0/headless-client v0.1.0 h1:kA8oQqjQBtL2NQ3fzEQt1SIHi+nfjC0qMZlXx2hijl8=
github.com/kulikov0/headless-client v0.1.0/go.mod h1:5tWHLmlj28tuq2+pNkr8qXcC7AG/ONqbSvy6jhizJno=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -336,8 +336,6 @@ github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
github.com/pion/ice/v4 v4.2.1 h1:XPRYXaLiFq3LFDG7a7bMrmr3mFr27G/gtXN3v/TVfxY=
github.com/pion/ice/v4 v4.2.1/go.mod h1:2quLV1S5v1tAx3VvAJaH//KGitRXvo4RKlX6D3tnN+c=
github.com/pion/interceptor v0.1.44 h1:sNlZwM8dWXU9JQAkJh8xrarC0Etn8Oolcniukmuy0/I=
github.com/pion/interceptor v0.1.44/go.mod h1:4atVlBkcgXuUP+ykQF0qOCGU2j7pQzX2ofvPRFsY5RY=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
@@ -350,8 +348,8 @@ github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.1 h1:xP1prZcCTUuhO2c83XtxyOHJteISg6o8iPsE2acaMtA=
github.com/pion/rtp v1.10.1/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo=
github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=
github.com/pion/sctp v1.9.4 h1:cMxEu0F5tbP4qH07bKf1Zjf4rUih9LIo0qQt424e258=
github.com/pion/sctp v1.9.4/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
@@ -360,12 +358,10 @@ github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE=
github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY=
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -382,6 +378,8 @@ github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -505,8 +503,6 @@ github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.2.0 h1:aOd9Vy2LGSwgMM+4805AgLB
github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.2.0/go.mod h1:Ek4yz5OK6stwhLKgLsRRYDI+FA+ZWvRJiWLjsi/vMM4=
github.com/shtorm-7/mtg-multi v1.11.0-extended-1.0.0 h1:iBLll4ZZG8ULQcHWs6gGslZWtBN72Zo1zjySzMVHF7g=
github.com/shtorm-7/mtg-multi v1.11.0-extended-1.0.0/go.mod h1:3rvdhwdPABkwKBdvgMt3VwMn9uSq8hpoHRezZ5jRJU0=
github.com/shtorm-7/rmux v1.0.0 h1:oNXshcZVUEDU9u5ylqh3VBFQwus5LFH5yZD9KMTkSSw=
github.com/shtorm-7/rmux v1.0.0/go.mod h1:6tVwUpgjMcCTi3iami7le2/rqhfR6aFPhwuyFzGNQcw=
github.com/shtorm-7/rmux v1.0.1 h1:ES3/MEQIXG1FR/Lb70M1YFP8eHTPQ99OjPLkutGbLF4=
github.com/shtorm-7/rmux v1.0.1/go.mod h1:6tVwUpgjMcCTi3iami7le2/rqhfR6aFPhwuyFzGNQcw=
github.com/shtorm-7/sing v0.9.0-beta.4-extended-1.2.1 h1:wpPAKAhHdipRcA4wWtHF16JIrObOTCg9a1O7uowAumg=
@@ -517,8 +513,6 @@ github.com/shtorm-7/sing-vmess v0.2.8-extended-1.0.0 h1:OjvqZOhYSi9eIJGYC0iPDPqv
github.com/shtorm-7/sing-vmess v0.2.8-extended-1.0.0/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
github.com/shtorm-7/tailscale v1.102.1-sing-box-1.14-mod.4-extended-1.0.3 h1:AG4leaIK/VByNXDCPtxtBjLc1orPAtBBNcfw+rebPno=
github.com/shtorm-7/tailscale v1.102.1-sing-box-1.14-mod.4-extended-1.0.3/go.mod h1:+1R9TnmCDbxb7h7ZOBKxi5UI6aWf+polOO/fc0WB/4o=
github.com/shtorm-7/wireguard-go v0.0.5-extended-1.6.0 h1:cLCyRR5mVNuhR6iNkcDHItt0SZ6O1GYWfP86XmVmjUo=
github.com/shtorm-7/wireguard-go v0.0.5-extended-1.6.0/go.mod h1:1VZmkU7yPAZVE0XKufUqqAhEY1RnWUcyZLmry85y+7A=
github.com/shtorm-7/wireguard-go v0.0.5-extended-1.6.1 h1:lLd8c5SNbm01idBlaSc18Urn4i4/ME/Umu8BeQLJ/io=
github.com/shtorm-7/wireguard-go v0.0.5-extended-1.6.1/go.mod h1:1VZmkU7yPAZVE0XKufUqqAhEY1RnWUcyZLmry85y+7A=
github.com/shtorm-7/workerpool v0.5.0 h1:NPZuNgyH0EUm4aQsTL09xR1iV+7GCFw6jX9Z4aAVp2s=
@@ -585,6 +579,8 @@ github.com/xtaci/kcp-go/v5 v5.6.72 h1:FLaQPalgpufJYQRk0OK+gErEhXGLUPjv6FSRPrFR8L
github.com/xtaci/kcp-go/v5 v5.6.72/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM=
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM=
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+665
View File
@@ -0,0 +1,665 @@
package bitrix
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
"github.com/google/uuid"
headless "github.com/kulikov0/headless-client"
)
const (
slbBase = "https://slb.bitrix24.tech/v2/join"
signalSDK = "js"
signalVersion = "1.6.7"
signalProtocol = "8"
clientVersion = "1.0.0"
clientPlatform = "web"
callTypeInstant = 2
roomTypeSmall = 1
)
var (
reConferenceID = regexp.MustCompile(`conferenceId:\s*'([^']+)'`)
reChatID = regexp.MustCompile(`chatId:\s*'([^']+)'`)
reAlias = regexp.MustCompile(`alias:\s*'([^']+)'`)
reSessid = regexp.MustCompile(`bitrix_sessid["']?\s*[:=]\s*["']([a-f0-9]{8,})`)
reUserID = regexp.MustCompile(`"USER_ID"\s*:\s*"?(\d+)`)
)
type Client struct {
HTTP *http.Client
Logger logger.ContextLogger
portal string
sessid string
userAgent string
instanceID string
email string
password string
chatID string
conferenceID string
selfUserID string
}
type JoinResult struct {
RoomData string `json:"roomData"`
RoomID string `json:"roomId"`
MediaServerURL string `json:"mediaServerUrl"`
}
type conferenceParams struct {
ConferenceID string
ChatID string
Alias string
}
type callInfo struct {
UUID string
ChatID string
CallToken string
UserToken string
Alias string
GuestLink string
}
type PullConfigResult struct {
WebSocket string
ChannelID string
Hostname string
Revision int
}
type extraHeader struct {
Name string
Value string
}
type slbRequest struct {
UserToken string `json:"userToken"`
IsOneToOne bool `json:"isOneToOne"`
ClientVersion string `json:"clientVersion"`
ClientPlatform string `json:"clientPlatform"`
CallType int `json:"callType"`
RoomType int `json:"roomType"`
InstanceID string `json:"instanceId"`
CallToken string `json:"callToken"`
Provider string `json:"provider"`
IsVideo bool `json:"isVideo"`
CallUUID string `json:"callUuid"`
}
func NewClient(portal, userAgent string, dialer N.Dialer, log logger.ContextLogger) (*Client, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
if log == nil {
log = logger.NOP()
}
httpClient := common.HttpClient(dialer)
httpClient.Jar = jar
return &Client{
HTTP: httpClient,
Logger: log,
portal: strings.TrimRight(portal, "/"),
userAgent: userAgent,
instanceID: uuid.New().String(),
}, nil
}
func SignalURL(res JoinResult) string {
q := url.Values{}
q.Set("auto_subscribe", "1")
q.Set("sdk", signalSDK)
q.Set("version", signalVersion)
q.Set("protocol", signalProtocol)
q.Set("roomData", res.RoomData)
q.Set("clientVersion", clientVersion)
q.Set("clientPlatform", clientPlatform)
return res.MediaServerURL + "?" + q.Encode()
}
func (c *Client) JoinAsGuest(alias, displayName string) (JoinResult, error) {
var res JoinResult
conf, err := c.fetchConference(alias)
if err != nil {
return res, err
}
c.chatID = conf.ChatID
c.conferenceID = conf.ConferenceID
userToken, err := c.registerGuest(conf, displayName)
if err != nil {
return res, err
}
info, err := c.tryJoinCall(conf.ChatID)
if err != nil {
return res, err
}
info.UserToken = userToken
return c.slbJoin(info, false)
}
func (c *Client) JoinAsHost(alias string) (JoinResult, error) {
var res JoinResult
conf, err := c.fetchConference(alias)
if err != nil {
return res, err
}
c.Logger.Debug(fmt.Sprintf("[host] conference chatId=%s conferenceId=%s", conf.ChatID, conf.ConferenceID))
c.chatID = conf.ChatID
callToken, userToken, err := c.getCallToken(conf.ChatID)
if err != nil {
return res, err
}
info := callInfo{
UUID: uuid.New().String(),
ChatID: conf.ChatID,
CallToken: callToken,
UserToken: userToken,
}
c.Logger.Debug(fmt.Sprintf("[host] creating media room callUUID=%s callToken=%dB userToken=%dB", info.UUID, len(info.CallToken), len(info.UserToken)))
return c.slbJoin(info, true)
}
func (c *Client) CreateAndJoin() (JoinResult, string, error) {
var res JoinResult
info, err := c.createRoom()
if err != nil {
return res, "", err
}
c.chatID = info.ChatID
guestLink := info.GuestLink
if guestLink == "" {
guestLink, err = c.getGuestLink(info.ChatID)
if err != nil {
return res, "", err
}
}
if info.CallToken == "" {
callToken, userToken, err := c.getCallToken(info.ChatID)
if err != nil {
return res, "", err
}
info.CallToken = callToken
if info.UserToken == "" {
info.UserToken = userToken
}
}
res, err = c.slbJoin(info, true)
if err != nil {
return res, "", err
}
return res, guestLink, nil
}
func (c *Client) resetJar() error {
jar, err := cookiejar.New(nil)
if err != nil {
return err
}
c.HTTP.Jar = jar
c.sessid = ""
return nil
}
func secFetchSiteFor(endpoint, origin string) string {
endpointURL, err := url.Parse(endpoint)
if err != nil {
return "cross-site"
}
originURL, err := url.Parse(origin)
if err != nil {
return "cross-site"
}
if endpointURL.Host == originURL.Host {
return "same-origin"
}
return "cross-site"
}
func (c *Client) do(method, endpoint, contentType string, body io.Reader, extraHeaders ...extraHeader) ([]byte, int, error) {
req, err := http.NewRequest(method, endpoint, body)
if err != nil {
return nil, 0, err
}
req.Header = headless.ChromeWindows.Headers(headless.DestEmpty)
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
req.Header.Set("Accept", "application/json, text/plain, */*")
if c.portal != "" {
req.Header.Set("Origin", c.portal)
req.Header.Set("Referer", c.portal+"/")
req.Header.Set("Sec-Fetch-Site", secFetchSiteFor(endpoint, c.portal))
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
for _, h := range extraHeaders {
req.Header.Set(h.Name, h.Value)
}
resp, err := c.HTTP.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) restForm(action string, form url.Values) ([]byte, int, error) {
endpoint := c.portal + "/rest/" + action + ".json"
return c.do("POST", endpoint, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
}
func (c *Client) restFormAuthed(action string, form url.Values) ([]byte, int, error) {
endpoint := c.portal + "/rest/" + action + ".json"
var extraHeaders []extraHeader
if c.sessid != "" {
form.Set("sessid", c.sessid)
endpoint += "?sessid=" + url.QueryEscape(c.sessid)
extraHeaders = append(extraHeaders, extraHeader{Name: "X-Bitrix-Csrf-Token", Value: c.sessid})
}
return c.do("POST", endpoint, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()), extraHeaders...)
}
func (c *Client) ajaxAction(action string, form url.Values) ([]byte, int, error) {
endpoint := c.portal + "/bitrix/services/main/ajax.php?action=" + action
var extraHeaders []extraHeader
if c.sessid != "" {
endpoint += "&sessid=" + url.QueryEscape(c.sessid)
form.Set("sessid", c.sessid)
extraHeaders = append(extraHeaders, extraHeader{Name: "X-Bitrix-Csrf-Token", Value: c.sessid})
}
return c.do("POST", endpoint, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()), extraHeaders...)
}
func (c *Client) fetchConference(alias string) (conferenceParams, error) {
var p conferenceParams
body, status, err := c.do("GET", c.portal+"/video/"+alias, "", nil)
if err != nil {
return p, err
}
if status != 200 {
return p, fmt.Errorf("fetch conference page: status %d", status)
}
page := string(body)
if m := reConferenceID.FindStringSubmatch(page); m != nil {
p.ConferenceID = m[1]
}
if m := reChatID.FindStringSubmatch(page); m != nil {
p.ChatID = m[1]
}
if m := reAlias.FindStringSubmatch(page); m != nil {
p.Alias = m[1]
} else {
p.Alias = alias
}
if m := reSessid.FindStringSubmatch(page); m != nil {
c.sessid = m[1]
}
if p.ConferenceID == "" || p.ChatID == "" {
return p, fmt.Errorf("conference params not found in page (conferenceId=%q chatId=%q)", p.ConferenceID, p.ChatID)
}
return p, nil
}
func (c *Client) registerGuest(p conferenceParams, displayName string) (userToken string, err error) {
form := url.Values{}
form.Set("call_auth_id", "guest")
form.Set("videoconf_id", p.ConferenceID)
form.Set("call_chat_id", p.ChatID)
form.Set("alias", p.Alias)
form.Set("user_hash", "")
if displayName != "" {
form.Set("name", displayName)
}
body, status, err := c.restForm("call.user.register", form)
if err != nil {
return "", err
}
var out struct {
Result struct {
UserToken string `json:"userToken"`
} `json:"result"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", fmt.Errorf("register guest: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
if out.Result.UserToken == "" {
return "", fmt.Errorf("register guest: no userToken (status %d, err %s %s)", status, out.Error, out.ErrorDescription)
}
return out.Result.UserToken, nil
}
func (c *Client) tryJoinCall(chatID string) (callInfo, error) {
var info callInfo
form := url.Values{}
form.Set("entityType", "chat")
form.Set("entityId", "chat"+chatID)
form.Set("provider", "Bitrix")
form.Set("callType", fmt.Sprintf("%d", callTypeInstant))
body, status, err := c.withRelogin(func() ([]byte, int, error) {
return c.ajaxAction("call.Call.tryJoinCall", form)
})
if err != nil {
return info, err
}
var out struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"`
Errors []struct {
Message string `json:"message"`
Code any `json:"code"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &out); err != nil {
return info, fmt.Errorf("tryJoinCall: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
var data struct {
Success bool `json:"success"`
Call struct {
UUID string `json:"UUID"`
ChatID json.Number `json:"CHAT_ID"`
} `json:"call"`
CallToken string `json:"callToken"`
}
if err := json.Unmarshal(out.Data, &data); err != nil && out.Status == "success" {
return info, fmt.Errorf("tryJoinCall: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
if out.Status == "success" && !data.Success {
return info, fmt.Errorf("tryJoinCall: no active call at chat%s, organizer not in the room", chatID)
}
if out.Status != "success" || data.Call.UUID == "" {
return info, fmt.Errorf("tryJoinCall failed (status %d, %s, errors %+v, body %s)", status, out.Status, out.Errors, common.BodySnippet(body))
}
info.UUID = data.Call.UUID
info.ChatID = data.Call.ChatID.String()
info.CallToken = data.CallToken
return info, nil
}
func (c *Client) slbJoin(info callInfo, mustCreate bool) (JoinResult, error) {
var res JoinResult
reqBody := slbRequest{
UserToken: info.UserToken,
IsOneToOne: false,
ClientVersion: clientVersion,
ClientPlatform: clientPlatform,
CallType: callTypeInstant,
RoomType: roomTypeSmall,
InstanceID: c.instanceID,
CallToken: info.CallToken,
Provider: "Bitrix",
IsVideo: true,
CallUUID: info.UUID,
}
payload, _ := json.Marshal(reqBody)
endpoint := slbBase + "?mustCreate=" + strconv.FormatBool(mustCreate)
body, status, err := c.do("POST", endpoint, "text/plain;charset=UTF-8", strings.NewReader(string(payload)))
if err != nil {
return res, err
}
var out struct {
Result JoinResult `json:"result"`
Error json.RawMessage `json:"error"`
}
if err := json.Unmarshal(body, &out); err != nil {
return res, fmt.Errorf("slb join: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
if out.Result.MediaServerURL == "" || out.Result.RoomData == "" {
return res, fmt.Errorf("slb join: missing roomData/mediaServerUrl (status %d, err %s)", status, string(out.Error))
}
return out.Result, nil
}
func (c *Client) currentUserID() (string, error) {
body, status, err := c.do("GET", c.portal+"/online/", "", nil)
if err != nil {
return "", err
}
if m := reUserID.FindSubmatch(body); m != nil && string(m[1]) != "0" {
return string(m[1]), nil
}
return "", fmt.Errorf("current user id not found (status %d)", status)
}
func (c *Client) createRoom() (callInfo, error) {
var info callInfo
uid, err := c.currentUserID()
if err != nil {
return info, fmt.Errorf("createRoom: %w", err)
}
form := url.Values{}
form.Set("fields[entityType]", "VIDEOCONF")
form.Set("fields[title]", "")
form.Set("fields[memberEntities][0][0]", "user")
form.Set("fields[memberEntities][0][1]", uid)
form.Set("fields[ownerId]", uid)
form.Set("fields[description]", "")
form.Set("fields[manageUsersAdd]", "member")
form.Set("fields[manageUsersDelete]", "manager")
form.Set("fields[manageUi]", "member")
form.Set("fields[manageMessages]", "member")
form.Set("fields[conferencePassword]", "")
body, status, err := c.withRelogin(func() ([]byte, int, error) {
return c.ajaxAction("im.v2.Chat.add", form)
})
if err != nil {
return info, err
}
var out struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"`
Errors []struct {
Code any `json:"code"`
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &out); err != nil {
return info, fmt.Errorf("createRoom: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
var data struct {
ChatID json.Number `json:"chatId"`
Alias string `json:"alias"`
Link string `json:"link"`
}
if err := json.Unmarshal(out.Data, &data); err == nil {
info.ChatID = data.ChatID.String()
info.Alias = data.Alias
info.GuestLink = data.Link
}
if info.ChatID == "" {
errMsg := ""
if len(out.Errors) > 0 {
errMsg = fmt.Sprintf("%v %s", out.Errors[0].Code, out.Errors[0].Message)
}
return info, fmt.Errorf("createRoom: no chatId (status %d, status=%q err=%q, body %s)", status, out.Status, errMsg, common.BodySnippet(body))
}
c.Logger.Debug(fmt.Sprintf("[create] videoconf chatId=%s alias=%s", info.ChatID, info.Alias))
return info, nil
}
func (c *Client) KickUser(userID string) error {
if c.chatID == "" {
return fmt.Errorf("kick: no active chatId")
}
if userID == "" {
return fmt.Errorf("kick: empty userId")
}
form := url.Values{}
form.Set("chatId", c.chatID)
form.Set("userId", userID)
body, status, err := c.withRelogin(func() ([]byte, int, error) {
return c.ajaxAction("im.v2.Chat.deleteUser", form)
})
if err != nil {
return err
}
var out struct {
Status string `json:"status"`
Errors []struct {
Code any `json:"code"`
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &out); err != nil {
return fmt.Errorf("kick: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
if len(out.Errors) > 0 {
return fmt.Errorf("kick: chatId=%s userId=%s rejected (status %d, %v %s)", c.chatID, userID, status, out.Errors[0].Code, out.Errors[0].Message)
}
c.Logger.Debug(fmt.Sprintf("[kick] removed userId=%s from chatId=%s", userID, c.chatID))
return nil
}
func (c *Client) SelfUserID() (string, error) {
if c.selfUserID != "" {
return c.selfUserID, nil
}
id, err := c.currentUserID()
if err != nil {
return "", err
}
c.selfUserID = id
return id, nil
}
func (c *Client) callHash() string {
if c.HTTP == nil || c.HTTP.Jar == nil {
return ""
}
u, err := url.Parse(c.portal)
if err != nil {
return ""
}
for _, ck := range c.HTTP.Jar.Cookies(u) {
if ck.Name == "BITRIX_CALL_HASH" {
return ck.Value
}
}
return ""
}
func (c *Client) PullConfig() (PullConfigResult, error) {
var pc PullConfigResult
form := url.Values{}
form.Set("CACHE", "N")
var body []byte
var status int
var err error
if hash := c.callHash(); hash != "" {
form.Set("call_auth_id", hash)
form.Set("videoconf_id", c.conferenceID)
form.Set("call_chat_id", c.chatID)
body, status, err = c.restForm("pull.config.get", form)
} else {
body, status, err = c.withRelogin(func() ([]byte, int, error) {
return c.restFormAuthed("pull.config.get", form)
})
}
if err != nil {
return pc, err
}
var out struct {
Result struct {
Server struct {
WebSocket string `json:"websocket"`
} `json:"server"`
API struct {
RevisionWeb int `json:"revision_web"`
} `json:"api"`
Channels struct {
Shared struct {
ID string `json:"id"`
} `json:"shared"`
Private struct {
ID string `json:"id"`
} `json:"private"`
} `json:"channels"`
} `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &out); err != nil {
return pc, fmt.Errorf("pull config: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
if out.Result.Server.WebSocket == "" || out.Result.Channels.Private.ID == "" {
return pc, fmt.Errorf("pull config: missing websocket/channel (status %d, body %s)", status, common.BodySnippet(body))
}
pc.WebSocket = out.Result.Server.WebSocket
pc.ChannelID = out.Result.Channels.Private.ID
if out.Result.Channels.Shared.ID != "" {
pc.ChannelID += "/" + out.Result.Channels.Shared.ID
}
pc.Revision = out.Result.API.RevisionWeb
pc.Hostname = c.portal
if u, perr := url.Parse(c.portal); perr == nil && u.Host != "" {
pc.Hostname = u.Host
}
return pc, nil
}
func (c *Client) getGuestLink(chatID string) (string, error) {
form := url.Values{}
form.Set("chatId", chatID)
body, status, err := c.withRelogin(func() ([]byte, int, error) {
return c.ajaxAction("call.Call.getGuestLink", form)
})
if err != nil {
return "", err
}
var out struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", fmt.Errorf("getGuestLink: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
var data struct {
GuestLink string `json:"guestLink"`
}
if err := json.Unmarshal(out.Data, &data); err != nil || data.GuestLink == "" {
return "", fmt.Errorf("getGuestLink: empty (status %d, body %s)", status, common.BodySnippet(body))
}
return data.GuestLink, nil
}
func (c *Client) getCallToken(chatID string) (callToken, userToken string, err error) {
form := url.Values{}
form.Set("chatId", chatID)
body, status, err := c.withRelogin(func() ([]byte, int, error) {
return c.ajaxAction("call.Call.getCallToken", form)
})
if err != nil {
return "", "", err
}
var out struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", "", fmt.Errorf("getCallToken: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
var data struct {
CallToken string `json:"callToken"`
UserToken string `json:"userToken"`
}
if err := json.Unmarshal(out.Data, &data); err != nil || data.CallToken == "" {
return "", "", fmt.Errorf("getCallToken: empty (status %d, body %s)", status, common.BodySnippet(body))
}
return data.CallToken, data.UserToken, nil
}
+238
View File
@@ -0,0 +1,238 @@
package bitrix
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/sagernet/sing-box/transport/call/common"
headless "github.com/kulikov0/headless-client"
)
const authNetBase = "https://auth2.bitrix24.net"
var (
reFlowCfg = regexp.MustCompile(`b24network\.security\.flowtoken"[^>]*>(\{[^<]*\})</script>`)
reJunk = regexp.MustCompile(`[!#$%@~]`)
reJSRedirect = regexp.MustCompile(`window\.location\.href\s*=\s*['"]([^'"]+)['"]`)
)
func (c *Client) SetCredentials(email, password string) {
c.email = strings.TrimSpace(email)
c.password = password
}
func (c *Client) loginWithPassword(email, password string) error {
if email == "" || password == "" {
return fmt.Errorf("login: no email/password")
}
if err := c.resetJar(); err != nil {
return err
}
flowToken, sessid, currentURI, err := c.bootstrapLogin()
if err != nil {
return err
}
c.Logger.Debug(fmt.Sprintf("[auth] bootstrap: flowToken=%dB sessid=%v authURL=%v", len(flowToken), sessid != "", strings.Contains(currentURI, "auth2.bitrix24.net")))
if err := c.authCheckLogin(flowToken, email, sessid, currentURI); err != nil {
return err
}
c.Logger.Debug("[auth] checkLogin OK")
if err := c.authCheck(flowToken, email, password, sessid); err != nil {
return err
}
c.Logger.Debug("[auth] check OK")
c.email = email
c.password = password
return c.completeOAuth()
}
func (c *Client) bootstrapLogin() (flowToken, sessid, currentURI string, err error) {
req, err := http.NewRequest("GET", c.portal+"/", nil)
if err != nil {
return "", "", "", err
}
req.Header = headless.ChromeWindows.Headers(headless.DestDocument)
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
page := string(body)
currentURI = resp.Request.URL.String()
if !strings.Contains(currentURI, "auth2.bitrix24.net") {
return "", "", "", fmt.Errorf("portal did not redirect to auth2 (landed on %s)", currentURI)
}
flowToken = computeFlowToken(page)
if m := reSessid.FindStringSubmatch(page); m != nil {
sessid = m[1]
}
return flowToken, sessid, currentURI, nil
}
func computeFlowToken(page string) string {
m := reFlowCfg.FindStringSubmatch(page)
if m == nil {
return ""
}
var cfg struct {
FTV string `json:"ftv"`
}
if err := json.Unmarshal([]byte(m[1]), &cfg); err != nil {
return ""
}
return reJunk.ReplaceAllString(cfg.FTV, "")
}
func (c *Client) authCheckLogin(flowToken, login, sessid, currentURI string) error {
form := url.Values{}
form.Set("flow-token-unique-id", flowToken)
form.Set("login", login)
form.Set("currentUri", currentURI)
form.Set("checkSocserv", "Y")
body, status, err := c.authDo("b24network.authorize.checkLogin", form, sessid)
if err != nil {
return err
}
var out struct {
Status string `json:"status"`
Data []struct {
HasAccess bool `json:"HAS_ACCESS"`
Captcha any `json:"CAPTCHA"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &out); err != nil {
return fmt.Errorf("checkLogin: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
if out.Status != "success" || len(out.Data) == 0 {
return fmt.Errorf("login rejected: checkLogin (status %d, errors %+v, body %s)", status, out.Errors, common.BodySnippet(body))
}
if out.Data[0].Captcha != nil {
return fmt.Errorf("login rejected: captcha required at login step")
}
if !out.Data[0].HasAccess {
return fmt.Errorf("login rejected: account has no access")
}
return nil
}
func (c *Client) authCheck(flowToken, login, password, sessid string) error {
form := url.Values{}
form.Set("flow-token-unique-id", flowToken)
form.Set("login", login)
form.Set("password", password)
form.Set("remember", "Y")
body, status, err := c.authDo("b24network.authorize.check", form, sessid)
if err != nil {
return err
}
var out struct {
Status string `json:"status"`
Errors []struct {
Message string `json:"message"`
Code string `json:"code"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &out); err != nil {
return fmt.Errorf("check: %w (status %d, body %s)", err, status, common.BodySnippet(body))
}
if out.Status != "success" || len(out.Errors) > 0 {
return fmt.Errorf("login rejected: password check (status %d, errors %+v, body %s)", status, out.Errors, common.BodySnippet(body))
}
return nil
}
func (c *Client) authDo(action string, form url.Values, sessid string) ([]byte, int, error) {
endpoint := authNetBase + "/bitrix/services/main/ajax.php?action=" + action
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, 0, err
}
req.Header = headless.ChromeWindows.Headers(headless.DestEmpty)
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("Origin", authNetBase)
req.Header.Set("Referer", authNetBase+"/authorization/")
req.Header.Set("Sec-Fetch-Site", secFetchSiteFor(endpoint, authNetBase))
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if sessid != "" {
req.Header.Set("X-Bitrix-Csrf-Token", sessid)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return body, resp.StatusCode, err
}
func (c *Client) getPage(target string) (body, final string, err error) {
req, err := http.NewRequest("GET", target, nil)
if err != nil {
return "", "", err
}
req.Header = headless.ChromeWindows.Headers(headless.DestDocument)
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return string(raw), resp.Request.URL.String(), nil
}
func (c *Client) completeOAuth() error {
target := c.portal + "/"
for hop := range 8 {
body, final, err := c.getPage(target)
if err != nil {
return err
}
c.Logger.Debug(fmt.Sprintf("[auth] oauth hop %d: final=%s len=%d", hop, final, len(body)))
if next := reJSRedirect.FindStringSubmatch(body); next != nil {
target = next[1]
continue
}
if strings.HasPrefix(final, c.portal) {
if m := reSessid.FindStringSubmatch(body); m != nil {
c.sessid = m[1]
return nil
}
}
target = c.portal + "/"
body, final, err = c.getPage(target)
if err != nil {
return err
}
if !strings.HasPrefix(final, c.portal) {
return fmt.Errorf("oauth handoff landed outside the portal, final=%s", final)
}
if m := reSessid.FindStringSubmatch(body); m != nil {
c.sessid = m[1]
return nil
}
c.sessid = ""
c.Logger.Debug("[auth] no csrf token on the portal page, it will be taken from the first response")
return nil
}
return fmt.Errorf("oauth handoff did not converge")
}
+65
View File
@@ -0,0 +1,65 @@
package bitrix
import (
"context"
"encoding/json"
"fmt"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing-box/transport/call/tunnel/rtc"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
func ConnectJoiner(ctx context.Context, joinLink, displayName, mode string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, error) {
if displayName == "" {
displayName = "Joiner"
}
if mode == "" {
mode = TunnelModeVideo
}
if readBuf <= 0 {
readBuf = 32768
}
params := struct {
JoinLink string `json:"joinLink"`
DisplayName string `json:"displayName"`
TunnelMode string `json:"tunnelMode"`
}{
JoinLink: joinLink,
DisplayName: displayName,
TunnelMode: mode,
}
paramsJSON, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("bitrix: encode params: %w", err)
}
joiner := NewBitrixJoiner(logger, nil, dialer)
tunCh := make(chan tunnel.DataTunnel, 1)
joiner.OnConnected = func(tun tunnel.DataTunnel) {
select {
case tunCh <- tun:
default:
}
}
go joiner.RunWithParams(string(paramsJSON))
select {
case tun := <-tunCh:
rb := tunnel.NewRelayBridge(tun, "joiner", bridgeReadBufFor(tun, readBuf), dialer, logger)
rb.SetOnConfigAck(joiner.MarkConfigAcked)
rb.MarkReady()
return rb, nil
case <-ctx.Done():
joiner.Close()
return nil, ctx.Err()
}
}
func bridgeReadBufFor(tun tunnel.DataTunnel, readBuf int) int {
switch tun.(type) {
case *rtc.DCTunnel, *rtc.MultiTrackKCPTunnel:
return readBuf
}
return common.VP8BufSize
}
+348
View File
@@ -0,0 +1,348 @@
package bitrix
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"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"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/webrtc"
)
const (
bitrixReconnectInitialDelay = time.Second
bitrixReconnectMaxDelay = 16 * time.Second
)
type BitrixJoiner struct {
logger logger.ContextLogger
OnConnected func(tunnel.DataTunnel)
OnRemoteCandidate func(target int, candidateOrSDP string)
PCConfig common.PeerConnectionConfigurer
Dialer N.Dialer
joinLink string
displayName string
portal string
alias string
tunnelMode string
vp8FPS int
vp8Batch int
reliable bool
dualTrack bool
sessMu sync.Mutex
sig *Signal
ms *MediaSession
pull *PullClient
closeMu sync.Mutex
closed bool
stopCh chan struct{}
stopOnce sync.Once
reconnectAttempt atomic.Int32
}
func NewBitrixJoiner(logger logger.ContextLogger, pcConfig common.PeerConnectionConfigurer, dialer N.Dialer) *BitrixJoiner {
return &BitrixJoiner{
logger: logger,
PCConfig: pcConfig,
Dialer: dialer,
stopCh: make(chan struct{}),
}
}
func (j *BitrixJoiner) MarkConfigAcked() {
j.sessMu.Lock()
ms := j.ms
j.sessMu.Unlock()
if ms != nil {
ms.MarkConfigAcked()
}
}
func (j *BitrixJoiner) RunWithParams(jsonParams string) {
var params struct {
JoinLink string `json:"joinLink"`
DisplayName string `json:"displayName"`
TunnelMode string `json:"tunnelMode"`
VP8FPS int `json:"vp8Fps"`
VP8Batch int `json:"vp8Batch"`
Reliable bool `json:"reliable"`
DualTrack bool `json:"dualTrack"`
}
if err := json.Unmarshal([]byte(jsonParams), &params); err != nil {
j.logger.Error(fmt.Sprintf("bitrix-joiner: failed to parse params: %v", err))
return
}
j.joinLink = params.JoinLink
j.displayName = params.DisplayName
if j.displayName == "" {
j.displayName = "Joiner"
}
if strings.EqualFold(params.TunnelMode, "dc") {
j.tunnelMode = TunnelModeDC
} else {
j.tunnelMode = TunnelModeVideo
}
j.vp8FPS = params.VP8FPS
if j.vp8FPS <= 0 {
j.vp8FPS = 24
}
j.vp8Batch = params.VP8Batch
if j.vp8Batch <= 0 {
j.vp8Batch = 30
}
j.reliable = params.Reliable
j.dualTrack = params.DualTrack
portal, alias, err := parseBitrixJoinLink(j.joinLink)
if err != nil {
j.logger.Error(fmt.Sprintf("bitrix-joiner: %v", err))
return
}
j.portal = portal
j.alias = alias
j.logger.Debug(fmt.Sprintf("bitrix-joiner: portal=%s alias=%s mode=%s vp8Fps=%d vp8Batch=%d reliable=%v dualTrack=%v",
j.portal, j.alias, j.tunnelMode, j.vp8FPS, j.vp8Batch, j.reliable, j.dualTrack))
if err := j.runOnce(); err != nil {
j.logger.Error(fmt.Sprintf("bitrix-joiner: %v", err))
return
}
for {
if j.isClosed() {
return
}
j.logger.Info("bitrix-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("bitrix-joiner: reconnect attempt #%d", j.reconnectAttempt.Load()))
if err := j.runOnce(); err != nil {
j.logger.Warn(fmt.Sprintf("bitrix-joiner: %v, will retry", err))
}
}
}
func (j *BitrixJoiner) Close() {
j.closeMu.Lock()
j.closed = true
j.closeMu.Unlock()
j.stopOnce.Do(func() { close(j.stopCh) })
j.resetSessionState()
}
func (j *BitrixJoiner) runOnce() error {
userAgent := headless.ChromeWindows.UserAgent()
c, err := NewClient(j.portal, userAgent, j.Dialer, j.logger)
if err != nil {
return fmt.Errorf("new client: %w", err)
}
c.HTTP.Transport = j.makeTransport()
res, err := c.JoinAsGuest(j.alias, j.displayName)
if err != nil {
return fmt.Errorf("join as guest: %w", err)
}
j.logger.Debug(fmt.Sprintf("bitrix-joiner: roomId=%s mediaServer=%s", res.RoomID, res.MediaServerURL))
var configureSettingEngine func(*webrtc.SettingEngine)
if j.PCConfig != nil {
configureSettingEngine = j.PCConfig.ConfigureSettingEngine
}
var once sync.Once
connected := make(chan struct{})
sig, err := ConnectSignal(SignalConfig{
SignalURL: SignalURL(res),
Origin: j.portal,
UserAgent: userAgent,
Logger: j.logger,
ConfigureSettingEngine: configureSettingEngine,
NetDialContext: j.makeDialContext(),
OnConnected: func() {
once.Do(func() { close(connected) })
},
OnRemoteCandidate: j.OnRemoteCandidate,
})
if err != nil {
return fmt.Errorf("signal connect: %w", err)
}
ms, err := NewMediaSession(MediaParams{
Signal: sig,
Alias: j.alias,
Mode: j.tunnelMode,
FPS: j.vp8FPS,
Batch: j.vp8Batch,
Reliable: j.reliable,
DualTrack: j.dualTrack,
Logger: j.logger,
})
if err != nil {
sig.Close()
return fmt.Errorf("media session: %w", err)
}
ms.OnConnected = func(tun tunnel.DataTunnel) {
j.reconnectAttempt.Store(0)
j.logger.Info(fmt.Sprintf("bitrix-joiner: === TUNNEL CONNECTED === %T", tun))
if j.OnConnected != nil {
j.OnConnected(tun)
}
}
j.setSession(sig, ms)
done := make(chan struct{})
go func() {
if err := sig.Run(); err != nil {
j.logger.Debug(fmt.Sprintf("bitrix-joiner: signal run ended: %s", common.MaskError(err)))
}
close(done)
}()
select {
case <-connected:
case <-done:
return fmt.Errorf("signal closed before media connect")
case <-j.stopCh:
sig.Close()
return nil
}
j.startKickWatch(c, sig, userAgent)
if err := ms.Start(); err != nil {
sig.Close()
return fmt.Errorf("media start: %w", err)
}
select {
case <-done:
case <-j.stopCh:
sig.Close()
}
return nil
}
func (j *BitrixJoiner) makeDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) {
if j.Dialer == nil {
return nil
}
return func(ctx context.Context, network, addr string) (net.Conn, error) {
return j.Dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
}
}
func (j *BitrixJoiner) makeTransport() http.RoundTripper {
return headless.ChromeWindows.Transport(headless.TLSOptions{
DialContext: j.makeDialContext(),
})
}
func (j *BitrixJoiner) setSession(sig *Signal, ms *MediaSession) {
j.sessMu.Lock()
j.sig = sig
j.ms = ms
j.sessMu.Unlock()
}
func (j *BitrixJoiner) startKickWatch(c *Client, sig *Signal, userAgent string) {
selfID := sig.LocalUserID()
if selfID == "" {
j.logger.Debug("bitrix-joiner: self userId unknown, kick-detect disabled")
return
}
pc, err := c.PullConfig()
if err != nil {
j.logger.Debug(fmt.Sprintf("bitrix-joiner: pull config failed, kick-detect disabled: %s", common.MaskError(err)))
return
}
pull := NewPullClient(pc, userAgent, j.portal, j.logger)
pull.SetOnUserLeave(func(uid string) {
if uid != selfID {
return
}
j.logger.Info(fmt.Sprintf("bitrix-joiner: kicked from conference (userId=%s), shutting down", uid))
go j.Close()
})
j.sessMu.Lock()
j.pull = pull
j.sessMu.Unlock()
go func() {
if err := pull.Run(); err != nil {
j.logger.Debug(fmt.Sprintf("bitrix-joiner: subws2 pull ended: %s", common.MaskError(err)))
}
}()
}
func (j *BitrixJoiner) resetSessionState() {
j.sessMu.Lock()
sig := j.sig
ms := j.ms
pull := j.pull
j.sig = nil
j.ms = nil
j.pull = nil
j.sessMu.Unlock()
if pull != nil {
pull.Close()
}
if ms != nil {
ms.Stop()
}
if sig != nil {
sig.Close()
}
}
func (j *BitrixJoiner) waitBeforeRetry(attempt int) bool {
delay := common.BackoffWithJitter(attempt, bitrixReconnectInitialDelay, bitrixReconnectMaxDelay)
j.logger.Debug(fmt.Sprintf("bitrix-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 *BitrixJoiner) isClosed() bool {
j.closeMu.Lock()
defer j.closeMu.Unlock()
return j.closed
}
func parseBitrixJoinLink(joinLink string) (portal, alias string, err error) {
u, err := url.Parse(joinLink)
if err != nil {
return "", "", fmt.Errorf("bad join link: %w", err)
}
portal = u.Scheme + "://" + u.Host
alias = strings.TrimPrefix(u.Path, "/video/")
if alias == "" || portal == "://" {
return "", "", fmt.Errorf("link missing portal or /video/CODE: %s", joinLink)
}
return portal, alias, nil
}
+40
View File
@@ -0,0 +1,40 @@
package bitrix
import "sync"
type KickManager struct {
kick func(userID string) error
selfID string
logFn func(string, ...any)
mu sync.Mutex
active string
}
func NewKickManager(kick func(userID string) error, selfID string, logFn func(string, ...any)) *KickManager {
return &KickManager{kick: kick, selfID: selfID, logFn: logFn}
}
func (m *KickManager) OnUserJoined(userID string) {
if userID == "" || userID == m.selfID {
return
}
m.mu.Lock()
old := m.active
if old == userID {
m.mu.Unlock()
return
}
m.active = userID
m.mu.Unlock()
if old == "" {
m.logFn("[bx] active call guest userId=%s", userID)
return
}
if err := m.kick(old); err != nil {
m.logFn("[bx] kick failed userId=%s: %v", old, err)
return
}
m.logFn("[bx] kicked previous guest userId=%s, new active=%s", old, userID)
}
+500
View File
@@ -0,0 +1,500 @@
package bitrix
import (
"fmt"
"sync"
"time"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing-box/transport/call/livekit"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing-box/transport/call/tunnel/rtc"
"github.com/sagernet/sing/common/logger"
"github.com/kulikov0/headless-client/webrtc"
"github.com/pion/rtp/codecs"
)
const (
TunnelModeAuto = ""
TunnelModeVideo = "video"
TunnelModeDC = "dc"
)
type MediaParams struct {
Signal *Signal
Alias string
Mode string
FPS int
Batch int
Reliable bool
DualTrack bool
ReadBuf int
Logger logger.ContextLogger
}
type MediaSession struct {
p MediaParams
obf *tunnel.TunnelObfuscator
mu sync.Mutex
sendTracks []*webrtc.TrackLocalStaticSample
transceivers []*webrtc.RTPTransceiver
vp8tun *rtc.MultiTrackTunnel
kcptun *rtc.MultiTrackKCPTunnel
dctun *rtc.DCTunnel
subReliableDC *webrtc.DataChannel
pubDCHooked bool
dcStarted bool
tunFired bool
configAcked chan struct{}
configAckedOnce sync.Once
stopCh chan struct{}
OnConnected func(tunnel.DataTunnel)
OnPeerRestart func()
}
func NewMediaSession(p MediaParams) (*MediaSession, error) {
if p.Logger == nil {
p.Logger = logger.NOP()
}
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(p.Alias))
if err != nil {
return nil, err
}
s := &MediaSession{
p: p,
obf: obf,
configAcked: make(chan struct{}),
stopCh: make(chan struct{}),
}
count := 1
if p.DualTrack {
count = 2
}
tracks := make([]*webrtc.TrackLocalStaticSample, 0, count)
subs := make([]*rtc.VP8DataTunnel, 0, count)
for i := 0; i < count; i++ {
track, err := s.newVP8Track()
if err != nil {
return nil, err
}
tracks = append(tracks, track)
subs = append(subs, rtc.NewVP8DataTunnelWithQueue(track, obf, p.Logger, rtc.KCPCarrierQueueDepth))
}
s.sendTracks = tracks
s.vp8tun = rtc.NewMultiTrackTunnel(subs)
s.vp8tun.SetOnPeerRestart(func() {
s.p.Logger.Debug("[bx] peer epoch changed, re-arming auto-detect")
s.rearmAutoDetect()
if s.OnPeerRestart != nil {
s.OnPeerRestart()
}
})
p.Signal.SetOnTrack(func(remote *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
if remote.Codec().MimeType == webrtc.MimeTypeVP8 {
go s.readVP8Track(remote)
} else {
go rtc.DrainTrack(remote)
}
})
p.Signal.SetOnDataChannel(func(dc *webrtc.DataChannel) {
if dc.Label() != "_reliable" {
return
}
s.mu.Lock()
s.subReliableDC = dc
s.mu.Unlock()
dc.OnOpen(func() {
s.p.Logger.Debug("[bx] sub _reliable DC open")
s.maybeStartDCTunnel()
})
})
return s, nil
}
func (s *MediaSession) newVP8Track() (*webrtc.TrackLocalStaticSample, error) {
return webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000})
}
func (s *MediaSession) MarkConfigAcked() {
s.configAckedOnce.Do(func() { close(s.configAcked) })
}
func (s *MediaSession) Stop() {
select {
case <-s.stopCh:
default:
close(s.stopCh)
}
s.mu.Lock()
vp8 := s.vp8tun
kcptun := s.kcptun
s.mu.Unlock()
if kcptun != nil {
kcptun.Stop()
}
if vp8 != nil {
vp8.Stop()
}
}
func (s *MediaSession) Start() error {
if err := s.p.Signal.WaitJoined(30 * time.Second); err != nil {
return err
}
s.mu.Lock()
tracks := s.sendTracks
s.mu.Unlock()
transceivers := make([]*webrtc.RTPTransceiver, 0, len(tracks))
for i, t := range tracks {
source := livekit.TrackSourceCamera
if i > 0 {
source = livekit.TrackSourceScreenShare
}
trx, err := s.p.Signal.AddPublisherTrack(t, source)
if err != nil {
return err
}
transceivers = append(transceivers, trx)
go rtc.DrainSenderRTCP(trx.Sender())
}
if err := s.p.Signal.Renegotiate(); err != nil {
return err
}
s.mu.Lock()
s.transceivers = transceivers
s.mu.Unlock()
s.vp8tun.Start(s.p.FPS, s.p.Batch)
s.p.Logger.Debug(fmt.Sprintf("[bx] vp8 tunnel started fps=%d batch=%d tracks=%d", s.p.FPS, s.p.Batch, len(tracks)))
s.hookPubDC()
s.startVP8Role()
return nil
}
func (s *MediaSession) hookPubDC() {
dc := s.p.Signal.PubReliableDC()
if dc == nil {
return
}
s.mu.Lock()
if s.pubDCHooked {
s.mu.Unlock()
return
}
s.pubDCHooked = true
s.mu.Unlock()
dc.OnOpen(func() {
s.p.Logger.Debug("[bx] pub _reliable DC open")
s.maybeStartDCTunnel()
})
if dc.ReadyState() == webrtc.DataChannelStateOpen {
s.maybeStartDCTunnel()
}
}
func (s *MediaSession) startVP8Role() {
tun := s.currentVP8Tun()
var active tunnel.DataTunnel = tun
if s.p.Mode == TunnelModeVideo && s.p.Reliable {
active = s.wrapReliable(tun)
}
switch s.p.Mode {
case TunnelModeVideo:
go s.configPingPong(active, tun.SubTunnelCount())
s.fireOnConnected(active)
case TunnelModeAuto:
tun.SetOnData(func(payload []byte) { s.activate(tun, payload) })
}
}
func (s *MediaSession) maybeStartDCTunnel() {
if s.p.Mode == TunnelModeVideo {
return
}
s.mu.Lock()
subDC := s.subReliableDC
s.mu.Unlock()
pubDC := s.p.Signal.PubReliableDC()
if pubDC == nil || subDC == nil {
return
}
if pubDC.ReadyState() != webrtc.DataChannelStateOpen || subDC.ReadyState() != webrtc.DataChannelStateOpen {
return
}
s.mu.Lock()
if s.dcStarted {
s.mu.Unlock()
return
}
s.dcStarted = true
s.mu.Unlock()
subRaw, err := subDC.Detach()
if err != nil {
s.p.Logger.Debug(fmt.Sprintf("[bx] detach sub _reliable: %v", err))
return
}
pubRaw, err := pubDC.Detach()
if err != nil {
s.p.Logger.Debug(fmt.Sprintf("[bx] detach pub _reliable: %v", err))
return
}
readWrapped := livekit.NewDataPacketWrapper(subRaw, livekit.DataPacketKindReliable)
writeWrapped := livekit.NewDataPacketWrapper(pubRaw, livekit.DataPacketKindReliable)
readBuf := s.p.ReadBuf
if readBuf == 0 {
readBuf = common.DCBufSize
}
dctun := rtc.NewChunkedDCTunnelFromRaw(readWrapped, writeWrapped, s.obf, readBuf, s.p.Logger)
s.mu.Lock()
s.dctun = dctun
s.mu.Unlock()
s.p.Logger.Debug("[bx] dc tunnel ready pub+sub _reliable")
switch s.p.Mode {
case TunnelModeDC:
s.fireOnConnected(dctun)
case TunnelModeAuto:
dctun.SetOnData(func(payload []byte) { s.activate(dctun, payload) })
}
}
func (s *MediaSession) configPingPong(tun tunnel.DataTunnel, trackCount int) {
tunnel.SendVP8ConfigUntilAcked(s.configAcked, nil, s.stopCh, tun,
s.p.FPS, s.p.Batch, trackCount, s.p.Logger, "[bx]")
}
func (s *MediaSession) fireOnConnected(tun tunnel.DataTunnel) {
s.mu.Lock()
if s.tunFired {
s.mu.Unlock()
return
}
s.tunFired = true
s.mu.Unlock()
if s.OnConnected != nil {
s.OnConnected(tun)
}
}
func (s *MediaSession) activate(tun tunnel.DataTunnel, payload []byte) {
s.mu.Lock()
if s.tunFired {
s.mu.Unlock()
return
}
s.tunFired = true
s.mu.Unlock()
delivered := tun
useKCP := false
if _, ok := tun.(*rtc.MultiTrackTunnel); ok && !tunnel.LooksLikeRelayFrame(payload) {
delivered = s.wrapReliable(tun)
useKCP = true
}
s.p.Logger.Debug(fmt.Sprintf("[bx] auto-detected active tunnel: %T", delivered))
if s.OnConnected != nil {
s.OnConnected(delivered)
}
switch v := tun.(type) {
case *rtc.DCTunnel:
if fwd := v.OnData(); fwd != nil {
fwd(payload)
}
case *rtc.MultiTrackTunnel:
if useKCP {
if k, ok := delivered.(*rtc.MultiTrackKCPTunnel); ok {
k.InjectSegment(payload)
}
} else {
v.DeliverData(payload)
}
}
}
func (s *MediaSession) wrapReliable(tun tunnel.DataTunnel) tunnel.DataTunnel {
mt, ok := tun.(*rtc.MultiTrackTunnel)
if !ok {
return tun
}
wrapped := rtc.NewMultiTrackKCPTunnel(mt, s.p.Logger)
s.mu.Lock()
s.kcptun = wrapped
s.mu.Unlock()
s.p.Logger.Debug("[bx] per-track kcp reliability active over video tunnel")
return wrapped
}
func (s *MediaSession) currentVP8Tun() *rtc.MultiTrackTunnel {
s.mu.Lock()
defer s.mu.Unlock()
return s.vp8tun
}
func (s *MediaSession) rearmAutoDetect() {
if s.p.Mode != TunnelModeAuto {
return
}
s.mu.Lock()
s.tunFired = false
orphanKCP := s.kcptun
s.kcptun = nil
vp8 := s.vp8tun
dc := s.dctun
s.mu.Unlock()
if orphanKCP != nil {
orphanKCP.StopLayer()
}
if vp8 != nil {
vp8.SetOnData(func(payload []byte) { s.activate(vp8, payload) })
}
if dc != nil {
dc.SetOnData(func(payload []byte) { s.activate(dc, payload) })
}
}
func (s *MediaSession) AdaptTrackCount(peerCount int) {
if peerCount < 1 {
return
}
s.mu.Lock()
current := len(s.sendTracks)
s.mu.Unlock()
if peerCount == current {
s.p.Logger.Debug(fmt.Sprintf("[bx] adapt-track-count: peer=%d current=%d, no change", peerCount, current))
return
}
if peerCount > current {
s.p.Logger.Debug(fmt.Sprintf("[bx] adapt-track-count: scaling publisher tracks %d -> %d", current, peerCount))
for i := current; i < peerCount; i++ {
if !s.addPublisherTrack(i) {
return
}
}
} else {
s.p.Logger.Debug(fmt.Sprintf("[bx] adapt-track-count: shrinking publisher tracks %d -> %d", current, peerCount))
for i := current; i > peerCount; i-- {
if !s.removePublisherTrack() {
return
}
}
}
if err := s.p.Signal.Renegotiate(); err != nil {
s.p.Logger.Debug(fmt.Sprintf("[bx] adapt-track-count: renegotiate: %v", err))
return
}
s.p.Logger.Debug("[bx] adapt-track-count: renegotiation offer sent")
}
func (s *MediaSession) addPublisherTrack(slot int) bool {
source := livekit.TrackSourceScreenShare
if slot == 0 {
source = livekit.TrackSourceCamera
}
track, err := s.newVP8Track()
if err != nil {
s.p.Logger.Debug(fmt.Sprintf("[bx] adapt-track-count: new track slot=%d: %v", slot, err))
return false
}
trx, err := s.p.Signal.AddPublisherTrack(track, source)
if err != nil {
s.p.Logger.Debug(fmt.Sprintf("[bx] adapt-track-count: add track slot=%d: %v", slot, err))
return false
}
go rtc.DrainSenderRTCP(trx.Sender())
s.mu.Lock()
s.sendTracks = append(s.sendTracks, track)
s.transceivers = append(s.transceivers, trx)
vp8 := s.vp8tun
kcptun := s.kcptun
s.mu.Unlock()
if vp8 != nil {
newSub := rtc.NewVP8DataTunnelWithQueue(track, s.obf, s.p.Logger, rtc.KCPCarrierQueueDepth)
vp8.AddSubTunnel(newSub)
if kcptun != nil {
kcptun.AddSession(newSub)
}
}
return true
}
func (s *MediaSession) removePublisherTrack() bool {
s.mu.Lock()
if len(s.transceivers) <= 1 || len(s.sendTracks) <= 1 {
s.mu.Unlock()
s.p.Logger.Debug("[bx] adapt-track-count: refusing to remove cam slot")
return false
}
last := len(s.transceivers) - 1
trx := s.transceivers[last]
s.transceivers = s.transceivers[:last]
s.sendTracks = s.sendTracks[:last]
vp8 := s.vp8tun
kcptun := s.kcptun
s.mu.Unlock()
if kcptun != nil {
kcptun.RemoveLastSession()
}
if vp8 != nil {
vp8.RemoveLastSubTunnel()
}
if err := trx.Stop(); err != nil {
s.p.Logger.Debug(fmt.Sprintf("[bx] adapt-track-count: stop transceiver: %v", err))
return false
}
return true
}
func (s *MediaSession) readVP8Track(track *webrtc.TrackRemote) {
var vp8Pkt codecs.VP8Packet
var frameBuf []byte
var lastSeq uint16
var haveLastSeq bool
frameValid := false
for {
pkt, _, err := track.ReadRTP()
if err != nil {
return
}
if pkt == 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
}
if tun := s.currentVP8Tun(); tun != nil {
tun.HandleFrame(frameBuf)
}
frameBuf = frameBuf[:0]
frameValid = false
}
}
+221
View File
@@ -0,0 +1,221 @@
package bitrix
import (
"encoding/binary"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing/common/logger"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/websocket"
)
const (
pullWireVarint = 0
pullWireFixed64 = 1
pullWireBytes = 2
pullWireFixed32 = 5
pullMaxDepth = 8
)
type PullClient struct {
cfg PullConfigResult
ua string
origin string
logger logger.ContextLogger
mu sync.Mutex
ws *websocket.Conn
closed bool
onUsersAnswered func([]string)
onUserLeave func(string)
}
func NewPullClient(cfg PullConfigResult, ua, origin string, log logger.ContextLogger) *PullClient {
if log == nil {
log = logger.NOP()
}
return &PullClient{cfg: cfg, ua: ua, origin: origin, logger: log}
}
func (p *PullClient) SetOnUsersAnswered(fn func([]string)) { p.onUsersAnswered = fn }
func (p *PullClient) SetOnUserLeave(fn func(string)) { p.onUserLeave = fn }
func (p *PullClient) dialURL() string {
q := url.Values{}
q.Set("CHANNEL_ID", p.cfg.ChannelID)
q.Set("binaryMode", "true")
if p.cfg.Hostname != "" {
q.Set("hostname", p.cfg.Hostname)
}
if p.cfg.Revision > 0 {
q.Set("revision", fmt.Sprintf("%d", p.cfg.Revision))
}
sep := "?"
if strings.Contains(p.cfg.WebSocket, "?") {
sep = "&"
}
return p.cfg.WebSocket + sep + q.Encode()
}
func (p *PullClient) connect() error {
headers := http.Header{}
if p.ua != "" {
headers.Set("User-Agent", p.ua)
}
if p.origin != "" {
headers.Set("Origin", p.origin)
}
dialer := headless.ChromeWindows.WebSocketDialer(headless.TLSOptions{})
conn, resp, err := dialer.Dial(p.dialURL(), headers)
if err != nil {
if resp != nil {
return fmt.Errorf("subws2 dial: %w, status %d", err, resp.StatusCode)
}
return fmt.Errorf("subws2 dial: %w", err)
}
p.mu.Lock()
p.ws = conn
p.mu.Unlock()
p.logger.Debug("[bx] subws2 connected")
return nil
}
func (p *PullClient) Run() error {
if err := p.connect(); err != nil {
return err
}
for {
p.mu.Lock()
ws := p.ws
closed := p.closed
p.mu.Unlock()
if closed || ws == nil {
return nil
}
_, data, err := ws.ReadMessage()
if err != nil {
p.mu.Lock()
closed = p.closed
p.mu.Unlock()
if closed {
return nil
}
return fmt.Errorf("subws2 read: %w", err)
}
p.handleFrame(data)
}
}
func (p *PullClient) Close() {
p.mu.Lock()
p.closed = true
ws := p.ws
p.ws = nil
p.mu.Unlock()
if ws != nil {
common.CloseWS(ws)
}
}
func (p *PullClient) handleFrame(frame []byte) {
for _, body := range extractPullBodies(frame) {
var msg struct {
ModuleID string `json:"module_id"`
Command string `json:"command"`
Params struct {
Senders []struct {
SenderID json.Number `json:"senderId"`
} `json:"senders"`
UserID json.Number `json:"userId"`
} `json:"params"`
}
if err := json.Unmarshal(body, &msg); err != nil {
continue
}
switch {
case msg.ModuleID == "call" && strings.Contains(msg.Command, "usersAnswered"):
var ids []string
for _, s := range msg.Params.Senders {
if id := s.SenderID.String(); id != "" {
ids = append(ids, id)
}
}
if len(ids) > 0 && p.onUsersAnswered != nil {
p.onUsersAnswered(ids)
}
case msg.ModuleID == "im" && msg.Command == "chatUserLeave":
if uid := msg.Params.UserID.String(); uid != "" && p.onUserLeave != nil {
p.onUserLeave(uid)
}
}
}
}
func extractPullBodies(buf []byte) [][]byte {
return collectPullBodies(buf, nil, 0)
}
func collectPullBodies(buf []byte, out [][]byte, depth int) [][]byte {
if depth > pullMaxDepth {
return out
}
for len(buf) > 0 {
value, rest, ok := nextPullField(buf)
if !ok {
return out
}
buf = rest
switch {
case len(value) == 0:
case value[0] == '{':
out = append(out, value)
default:
out = collectPullBodies(value, out, depth+1)
}
}
return out
}
func nextPullField(buf []byte) (value, rest []byte, ok bool) {
tag, n := binary.Uvarint(buf)
if n <= 0 {
return nil, nil, false
}
buf = buf[n:]
switch tag & 7 {
case pullWireVarint:
_, m := binary.Uvarint(buf)
if m <= 0 {
return nil, nil, false
}
return nil, buf[m:], true
case pullWireFixed64:
return skipPullFixed(buf, 8)
case pullWireFixed32:
return skipPullFixed(buf, 4)
case pullWireBytes:
l, m := binary.Uvarint(buf)
if m <= 0 || l > uint64(len(buf)-m) {
return nil, nil, false
}
end := m + int(l)
return buf[m:end], buf[end:], true
}
return nil, nil, false
}
func skipPullFixed(buf []byte, size int) (value, rest []byte, ok bool) {
if len(buf) < size {
return nil, nil, false
}
return nil, buf[size:], true
}
+127
View File
@@ -0,0 +1,127 @@
package bitrix
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
func (c *Client) LoadCookieString(cookieStr string) error {
cookieStr = strings.TrimSpace(cookieStr)
if cookieStr == "" {
return fmt.Errorf("empty cookie string")
}
u, err := url.Parse(c.portal)
if err != nil {
return fmt.Errorf("parse portal %s: %w", c.portal, err)
}
var cookies []*http.Cookie
for piece := range strings.SplitSeq(cookieStr, ";") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue
}
eq := strings.IndexByte(piece, '=')
if eq <= 0 {
continue
}
cookies = append(cookies, &http.Cookie{Name: piece[:eq], Value: piece[eq+1:], Path: "/"})
}
c.HTTP.Jar.SetCookies(u, cookies)
return nil
}
func (c *Client) EnsureLogin() error {
if c.checkSession() {
c.Logger.Debug("[auth] session valid, reusing saved cookies")
return nil
}
if c.email == "" || c.password == "" {
return fmt.Errorf("ensureLogin: no email/password")
}
c.Logger.Debug("[auth] session dead, re-logging in")
return c.loginWithPassword(c.email, c.password)
}
func (c *Client) checkSession() bool {
body, final, err := c.getPage(c.portal + "/")
if err != nil {
return false
}
if strings.Contains(final, "auth2.bitrix24.net") {
return false
}
if m := reSessid.FindStringSubmatch(body); m != nil {
c.sessid = m[1]
return true
}
return false
}
func (c *Client) withRelogin(do func() ([]byte, int, error)) ([]byte, int, error) {
body, status, err := do()
if err != nil {
return body, status, err
}
if token := csrfTokenFromBody(body); token != "" && token != c.sessid {
c.Logger.Debug("[auth] stale csrf token, retrying with the one from the response")
c.sessid = token
body, status, err = do()
if err != nil {
return body, status, err
}
}
if !looksUnauth(body, status) {
return body, status, err
}
if c.email == "" || c.password == "" {
return body, status, err
}
c.Logger.Debug(fmt.Sprintf("[auth] unauthenticated response (status %d), re-logging in and retrying", status))
if lerr := c.loginWithPassword(c.email, c.password); lerr != nil {
return body, status, fmt.Errorf("relogin: %w", lerr)
}
return do()
}
func csrfTokenFromBody(body []byte) string {
var out struct {
Errors []struct {
Code any `json:"code"`
CustomData json.RawMessage `json:"customData"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &out); err != nil {
return ""
}
for _, e := range out.Errors {
if e.Code != "invalid_csrf" {
continue
}
var customData struct {
CSRF string `json:"csrf"`
}
if err := json.Unmarshal(e.CustomData, &customData); err != nil {
continue
}
return customData.CSRF
}
return ""
}
func looksUnauth(body []byte, status int) bool {
if status == 401 || status == 403 {
return true
}
s := strings.TrimSpace(string(body))
if s == "" || s[0] != '{' {
return true
}
low := strings.ToLower(s)
return strings.Contains(low, "not_authorized") ||
strings.Contains(low, "unauthorized") ||
strings.Contains(low, "invalid_token") ||
strings.Contains(low, "user_not_authorized")
}
+228
View File
@@ -0,0 +1,228 @@
package bitrix
import (
"context"
"fmt"
"net"
"sync"
"time"
"github.com/sagernet/sing-box/transport/call/livekit"
"github.com/sagernet/sing/common/logger"
"github.com/kulikov0/headless-client/webrtc"
)
type SignalConfig struct {
SignalURL string
Origin string
UserAgent string
Logger logger.ContextLogger
ConfigureSettingEngine func(*webrtc.SettingEngine)
NetDialContext func(ctx context.Context, network, addr string) (net.Conn, error)
OnConnected func()
OnDataChannel func(*webrtc.DataChannel)
OnTrack func(*webrtc.TrackRemote, *webrtc.RTPReceiver)
OnRemoteCandidate func(target int, candidateOrSDP string)
}
type Signal struct {
lk *livekit.Client
logger logger.ContextLogger
mu sync.Mutex
onTrack func(*webrtc.TrackRemote, *webrtc.RTPReceiver)
onDC func(*webrtc.DataChannel)
pendingTracks []remoteTrack
pendingDCs []*webrtc.DataChannel
pubReliable *webrtc.DataChannel
dataChannelsUp bool
}
type remoteTrack struct {
track *webrtc.TrackRemote
receiver *webrtc.RTPReceiver
}
func ConnectSignal(cfg SignalConfig) (*Signal, error) {
log := cfg.Logger
if log == nil {
log = logger.NOP()
}
s := &Signal{
logger: log,
onTrack: cfg.OnTrack,
onDC: cfg.OnDataChannel,
}
lk, err := livekit.NewClient(livekit.Config{
ServerURL: cfg.SignalURL,
Origin: cfg.Origin,
UserAgent: cfg.UserAgent,
Codec: livekit.JSONCodec{},
Logger: log,
ConfigureSettingEngine: cfg.ConfigureSettingEngine,
NetDialContext: cfg.NetDialContext,
})
if err != nil {
return nil, err
}
s.lk = lk
s.lk.OnTrack = s.dispatchTrack
s.lk.OnDataChannel = s.dispatchDataChannel
s.lk.OnSubConnected = cfg.OnConnected
if cfg.OnRemoteCandidate != nil {
s.lk.OnRemoteCandidate = func(target int, ic webrtc.ICECandidateInit) {
cfg.OnRemoteCandidate(target, ic.Candidate)
}
s.lk.OnRemoteSDP = func(target int, _, sdp string) {
cfg.OnRemoteCandidate(-1, sdp)
}
}
if err := s.lk.Connect(); err != nil {
return nil, err
}
go s.lk.PingLoop()
return s, nil
}
func (s *Signal) Run() error { return s.lk.ReadLoop() }
func (s *Signal) Close() { s.lk.Close() }
func (s *Signal) SetOnTrack(fn func(*webrtc.TrackRemote, *webrtc.RTPReceiver)) {
s.mu.Lock()
s.onTrack = fn
pending := s.pendingTracks
s.pendingTracks = nil
s.mu.Unlock()
if fn == nil {
return
}
for _, t := range pending {
fn(t.track, t.receiver)
}
}
func (s *Signal) SetOnDataChannel(fn func(*webrtc.DataChannel)) {
s.mu.Lock()
s.onDC = fn
pending := s.pendingDCs
s.pendingDCs = nil
s.mu.Unlock()
if fn == nil {
return
}
for _, dc := range pending {
fn(dc)
}
}
func (s *Signal) LocalUserID() string { return s.lk.Join().LocalUserID }
func (s *Signal) PubReliableDC() *webrtc.DataChannel {
s.mu.Lock()
defer s.mu.Unlock()
return s.pubReliable
}
func (s *Signal) PubPC() *webrtc.PeerConnection { return s.lk.PubPC() }
func (s *Signal) Joined() <-chan struct{} { return s.lk.Joined() }
func (s *Signal) WaitJoined(timeout time.Duration) error {
select {
case <-s.lk.Joined():
return nil
case <-time.After(timeout):
return fmt.Errorf("join did not arrive")
}
}
func (s *Signal) AddPublisherTrack(track *webrtc.TrackLocalStaticSample, source int) (*webrtc.RTPTransceiver, error) {
pubPC := s.lk.PubPC()
if pubPC == nil {
return nil, fmt.Errorf("pub pc not ready")
}
if err := s.ensureDataChannels(pubPC); err != nil {
return nil, err
}
transceiver, err := pubPC.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionSendonly,
})
if err != nil {
return nil, err
}
if err := s.lk.SendAddTrack(track.ID(), "tunnel", livekit.TrackTypeVideo, source, 1280, 720); err != nil {
return nil, err
}
s.logger.Debug(fmt.Sprintf("[bx] published vp8 track cid=%s source=%d", track.ID(), source))
return transceiver, nil
}
func (s *Signal) Renegotiate() error {
pubPC := s.lk.PubPC()
if pubPC == nil {
return fmt.Errorf("pub pc not ready")
}
offer, err := pubPC.CreateOffer(nil)
if err != nil {
return err
}
if err := pubPC.SetLocalDescription(offer); err != nil {
return err
}
return s.lk.SendOffer(offer.SDP)
}
func (s *Signal) dispatchTrack(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
s.mu.Lock()
fn := s.onTrack
if fn == nil {
s.pendingTracks = append(s.pendingTracks, remoteTrack{track: track, receiver: receiver})
}
s.mu.Unlock()
if fn != nil {
fn(track, receiver)
}
}
func (s *Signal) dispatchDataChannel(dc *webrtc.DataChannel) {
s.mu.Lock()
fn := s.onDC
if fn == nil {
s.pendingDCs = append(s.pendingDCs, dc)
}
s.mu.Unlock()
if fn != nil {
fn(dc)
}
}
func (s *Signal) ensureDataChannels(pubPC *webrtc.PeerConnection) error {
s.mu.Lock()
if s.dataChannelsUp {
s.mu.Unlock()
return nil
}
s.dataChannelsUp = true
s.mu.Unlock()
ordered := true
reliable, err := pubPC.CreateDataChannel("_reliable", &webrtc.DataChannelInit{Ordered: &ordered})
if err != nil {
return fmt.Errorf("create _reliable: %w", err)
}
unordered := false
var zero uint16
lossy, err := pubPC.CreateDataChannel("_lossy", &webrtc.DataChannelInit{Ordered: &unordered, MaxRetransmits: &zero})
if err != nil {
return fmt.Errorf("create _lossy: %w", err)
}
reliable.OnOpen(func() { s.logger.Debug("[bx] pub dc _reliable open") })
lossy.OnOpen(func() { s.logger.Debug("[bx] pub dc _lossy open") })
s.mu.Lock()
s.pubReliable = reliable
s.mu.Unlock()
return nil
}
+6 -3
View File
@@ -1,8 +1,9 @@
package common
import (
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing/common/logger"
"github.com/kulikov0/headless-client/webrtc"
)
type ResolveFunc func(hostname string) (string, error)
@@ -11,5 +12,7 @@ type PeerConnectionConfigurer interface {
ConfigureSettingEngine(settingEngine *webrtc.SettingEngine)
}
type AddTunnelTracksFunc func(pc *webrtc.PeerConnection, logger logger.ContextLogger, prefix string) *webrtc.TrackLocalStaticSample
type ReadTrackFunc func(track *webrtc.TrackRemote, handler func([]byte), logger logger.ContextLogger, prefix string)
type (
AddTunnelTracksFunc func(pc *webrtc.PeerConnection, logger logger.ContextLogger, prefix string) *webrtc.TrackLocalStaticSample
ReadTrackFunc func(track *webrtc.TrackRemote, handler func([]byte), logger logger.ContextLogger, prefix string)
)
+208
View File
@@ -0,0 +1,208 @@
package common
import (
"fmt"
"math/rand/v2"
"strings"
)
type DeviceProfile struct {
UserAgent string
Platform string
BrowserType string
BrowserVersion string
DeviceBrand string
DeviceModel string
DeviceType string
OS string
OSVersion string
ScreenWidth int
ScreenHeight int
}
type deviceTemplate struct {
os string
osVersionPool []string
deviceBrandPool []string
deviceModelPool []string
browsers []browserTemplate
}
type browserTemplate struct {
browserType string
versionPool []string
userAgentFn func(osVersion, browserVersion string) string
}
var windowsDeviceBrands = []string{"Dell", "Lenovo", "HP", "Asus", "Acer", "MSI"}
var commonScreens = [][2]int{
{1280, 720},
{1366, 768},
{1440, 900},
{1536, 864},
{1600, 900},
{1680, 1050},
{1728, 1117},
{1920, 1080},
{2048, 1152},
{2560, 1440},
{2880, 1800},
{3840, 2160},
}
var deviceTemplates = []deviceTemplate{
{
os: "Mac OS",
osVersionPool: []string{"10.15.7", "11.7.10", "12.7.6", "13.6.9", "14.6.1", "15.1.0"},
deviceBrandPool: []string{"Apple"},
deviceModelPool: []string{"Macintosh"},
browsers: []browserTemplate{
{
browserType: "Chrome",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
macOSVersionForUA(osVersion), browserVersion)
},
},
{
browserType: "Safari",
versionPool: []string{"17.6", "18.0", "18.1", "18.2"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/%s Safari/605.1.15",
macOSVersionForUA(osVersion), browserVersion)
},
},
{
browserType: "Firefox",
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s; rv:%s) Gecko/20100101 Firefox/%s",
macOSVersionForUA(osVersion), browserVersion, browserVersion)
},
},
},
},
{
os: "Windows",
osVersionPool: []string{"10", "11"},
deviceBrandPool: []string{"Dell", "Lenovo", "HP", "Asus", "Acer", "MSI"},
deviceModelPool: []string{"PC"},
browsers: []browserTemplate{
{
browserType: "Chrome",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
browserVersion)
},
},
{
browserType: "Edge",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36 Edg/%s",
browserVersion, browserVersion)
},
},
{
browserType: "Firefox",
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:%s) Gecko/20100101 Firefox/%s",
browserVersion, browserVersion)
},
},
},
},
{
os: "Linux",
osVersionPool: []string{"x86_64", "x86_64 GNU"},
deviceBrandPool: []string{"Dell", "Lenovo", "HP", "System76", "Framework"},
deviceModelPool: []string{"PC"},
browsers: []browserTemplate{
{
browserType: "Chrome",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
browserVersion)
},
},
{
browserType: "Firefox",
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64; rv:%s) Gecko/20100101 Firefox/%s",
browserVersion, browserVersion)
},
},
},
},
}
func macOSVersionForUA(osVersion string) string {
switch osVersion {
case "10.15.7":
return "10_15_7"
case "11.7.10":
return "10_15_7"
case "12.7.6":
return "10_15_7"
case "13.6.9":
return "10_15_7"
case "14.6.1":
return "10_15_7"
case "15.1.0":
return "10_15_7"
}
return "10_15_7"
}
func RandomDeviceProfile() DeviceProfile {
tmpl := deviceTemplates[rand.IntN(len(deviceTemplates))]
browser := tmpl.browsers[rand.IntN(len(tmpl.browsers))]
osVersion := tmpl.osVersionPool[rand.IntN(len(tmpl.osVersionPool))]
browserVersion := browser.versionPool[rand.IntN(len(browser.versionPool))]
screen := commonScreens[rand.IntN(len(commonScreens))]
return DeviceProfile{
UserAgent: browser.userAgentFn(osVersion, browserVersion),
Platform: "web",
BrowserType: browser.browserType,
BrowserVersion: browserVersion,
DeviceBrand: tmpl.deviceBrandPool[rand.IntN(len(tmpl.deviceBrandPool))],
DeviceModel: tmpl.deviceModelPool[rand.IntN(len(tmpl.deviceModelPool))],
DeviceType: "pc",
OS: tmpl.os,
OSVersion: osVersion,
ScreenWidth: screen[0],
ScreenHeight: screen[1],
}
}
func chromeVersionFromUserAgent(userAgent string) string {
_, rest, found := strings.Cut(userAgent, "Chrome/")
if !found {
return ""
}
version, _, _ := strings.Cut(rest, " ")
return version
}
func ChromeWindowsDeviceProfile(userAgent string) DeviceProfile {
screen := commonScreens[rand.IntN(len(commonScreens))]
return DeviceProfile{
UserAgent: userAgent,
Platform: "web",
BrowserType: "Chrome",
BrowserVersion: chromeVersionFromUserAgent(userAgent),
DeviceBrand: windowsDeviceBrands[rand.IntN(len(windowsDeviceBrands))],
DeviceModel: "PC",
DeviceType: "pc",
OS: "Windows",
OSVersion: "10",
ScreenWidth: screen[0],
ScreenHeight: screen[1],
}
}
+50 -7
View File
@@ -12,10 +12,21 @@ import (
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
headless "github.com/kulikov0/headless-client"
)
const UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
const bodySnippetLimit = 300
func BodySnippet(body []byte) string {
if len(body) > bodySnippetLimit {
return string(body[:bodySnippetLimit]) + "..."
}
return string(body)
}
func LoadCookies(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -35,13 +46,45 @@ func LoadCookies(path string) (string, error) {
return strings.Join(parts, "; "), nil
}
func UpdateCookieFile(path string, updates map[string]string) error {
if len(updates) == 0 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var raw []map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
seen := make(map[string]bool, len(updates))
for _, c := range raw {
name, _ := c["name"].(string)
if v, ok := updates[name]; ok {
c["value"] = v
seen[name] = true
}
}
for name, v := range updates {
if !seen[name] {
raw = append(raw, map[string]any{"name": name, "value": v})
}
}
out, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, out, 0o600)
}
func HttpClient(dialer N.Dialer) *http.Client {
return &http.Client{
Transport: &http.Transport{
Transport: headless.ChromeWindows.Transport(headless.TLSOptions{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
},
}),
}
}
@@ -57,7 +100,7 @@ func HttpGet(dialer N.Dialer, endpoint string) ([]byte, error) {
}
func CookieValue(cookieHeader, name string) string {
for _, part := range strings.Split(cookieHeader, ";") {
for part := range strings.SplitSeq(cookieHeader, ";") {
part = strings.TrimSpace(part)
eq := strings.IndexByte(part, '=')
if eq != -1 && part[:eq] == name {
@@ -73,13 +116,13 @@ func FilterCookies(cookieHeader string, allow []string) string {
allowed[n] = struct{}{}
}
var out []string
for _, part := range strings.Split(cookieHeader, ";") {
for part := range strings.SplitSeq(cookieHeader, ";") {
trimmed := strings.TrimSpace(part)
eq := strings.IndexByte(trimmed, '=')
if eq == -1 {
before, _, ok := strings.Cut(trimmed, "=")
if !ok {
continue
}
if _, ok := allowed[trimmed[:eq]]; ok {
if _, ok := allowed[before]; ok {
out = append(out, trimmed)
}
}
+45 -7
View File
@@ -1,20 +1,27 @@
package common
import (
"context"
"fmt"
"net"
"strings"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
func FixICEURL(iceURL string) string {
idx := strings.Index(iceURL, ":")
if idx < 0 {
before, after, ok := strings.Cut(iceURL, ":")
if !ok {
return iceURL
}
scheme := iceURL[:idx]
scheme := before
if scheme != "turn" && scheme != "stun" && scheme != "turns" && scheme != "stuns" {
return iceURL
}
rest := iceURL[idx+1:]
rest := after
if strings.HasPrefix(rest, "[") {
return iceURL
}
@@ -41,11 +48,11 @@ func FixICEURL(iceURL string) string {
}
func ExtractICEHost(iceURL string) string {
idx := strings.Index(iceURL, ":")
if idx < 0 {
_, after, ok := strings.Cut(iceURL, ":")
if !ok {
return ""
}
rest := iceURL[idx+1:]
rest := after
params := strings.Index(rest, "?")
if params >= 0 {
rest = rest[:params]
@@ -56,3 +63,34 @@ func ExtractICEHost(iceURL string) string {
}
return host
}
func ResolveICEHosts(urls []string, dnsRouter adapter.DNSRouter, d N.Dialer, logger logger.ContextLogger, logPrefix string) []string {
out := make([]string, len(urls))
copy(out, urls)
if dnsRouter == nil {
return out
}
resolved := make(map[string]string)
for i, iceURL := range out {
fixed := FixICEURL(iceURL)
host := ExtractICEHost(fixed)
if host == "" || net.ParseIP(host) != nil {
out[i] = fixed
continue
}
ip, ok := resolved[host]
if !ok {
addrs, err := dnsRouter.Lookup(context.Background(), host, d.(dialer.ResolveDialer).QueryOptions())
if err != nil {
logger.Warn(fmt.Sprintf("%s: resolve ICE host %s failed: %s", logPrefix, MaskAddr(host), MaskError(err)))
out[i] = fixed
continue
}
ip = addrs[0].String()
resolved[host] = ip
logger.Debug(fmt.Sprintf("%s: resolved ICE host %s -> %s", logPrefix, host, ip))
}
out[i] = strings.Replace(fixed, host, ip, 1)
}
return out
}
+16 -4
View File
@@ -3,29 +3,41 @@ package common
import (
"fmt"
"github.com/sagernet/sing/common/logger"
"github.com/kulikov0/headless-client/webrtc"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing/common/logger"
)
func AddTunnelTracks(pc *webrtc.PeerConnection, logger logger.ContextLogger, prefix string) *webrtc.TrackLocalStaticSample {
sampleTrack, _ := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8},
"video", "tunnel-video",
)
audioTrack, _ := webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
"audio", "tunnel-audio",
)
audioSender, audioErr := pc.AddTrack(audioTrack)
videoSender, videoErr := pc.AddTrack(sampleTrack)
logger.Debug(fmt.Sprintf("%s: AddTrack audio: sender=%v err=%v", prefix, audioSender != nil, audioErr))
logger.Debug(fmt.Sprintf("%s: AddTrack video: sender=%v err=%v", prefix, videoSender != nil, videoErr))
logger.Debug(fmt.Sprintf("%s: senders count: %d", prefix, len(pc.GetSenders())))
go DrainSenderRTCP(videoSender)
return sampleTrack
}
func DrainSenderRTCP(sender *webrtc.RTPSender) {
if sender == nil {
return
}
buf := make([]byte, 1500)
for {
if _, _, err := sender.Read(buf); err != nil {
return
}
}
}
func ReadTrack(track *webrtc.TrackRemote, handler func([]byte), logger logger.ContextLogger, prefix string) {
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
buf := make([]byte, UDPBufSize)
+1 -1
View File
@@ -3,7 +3,7 @@ package common
import (
"time"
"github.com/gorilla/websocket"
"github.com/kulikov0/headless-client/websocket"
)
func CloseWS(ws *websocket.Conn) {
+16 -14
View File
@@ -6,6 +6,7 @@ import (
"net"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/transport/call/bitrix"
"github.com/sagernet/sing-box/transport/call/dion"
"github.com/sagernet/sing-box/transport/call/telemost"
"github.com/sagernet/sing-box/transport/call/tunnel"
@@ -62,12 +63,10 @@ func Connect(ctx context.Context, cfg Config) (*Bridge, error) {
log.Notice(fmt.Sprintf("call[telemost]: join_link=%s", joinLink))
return &Bridge{relay: relay}, nil
case RoleJoiner:
tun, err := telemost.ConnectJoiner(ctx, cfg.JoinLink, "", readBuf, cfg.Dialer, cfg.DNSRouter, log)
relay, err := telemost.ConnectJoiner(ctx, cfg.JoinLink, "", readBuf, cfg.Dialer, cfg.DNSRouter, log)
if err != nil {
return nil, err
}
relay := tunnel.NewRelayBridge(tun, "joiner", readBuf, cfg.Dialer, log)
relay.MarkReady()
return &Bridge{relay: relay}, nil
}
case "wbstream":
@@ -80,48 +79,51 @@ func Connect(ctx context.Context, cfg Config) (*Bridge, error) {
log.Notice(fmt.Sprintf("call[wbstream]: join_link=%s", joinLink))
return &Bridge{relay: relay}, nil
case RoleJoiner:
tun, err := wbstream.ConnectJoiner(ctx, cfg.JoinLink, "", cfg.Mode, readBuf, cfg.Dialer, cfg.DNSRouter, log)
relay, err := wbstream.ConnectJoiner(ctx, cfg.JoinLink, "", cfg.Mode, readBuf, cfg.Dialer, cfg.DNSRouter, log)
if err != nil {
return nil, err
}
relay := tunnel.NewRelayBridge(tun, "joiner", readBuf, cfg.Dialer, log)
relay.MarkReady()
return &Bridge{relay: relay}, nil
}
case "vk":
switch cfg.Role {
case RoleCreator:
relay, joinLink, err := vk.ConnectCreator(ctx, cookieStr, cfg.JoinLink, readBuf, cfg.Dialer, log)
relay, joinLink, err := vk.ConnectCreator(ctx, cookieStr, cfg.JoinLink, readBuf, cfg.Dialer, cfg.DNSRouter, log)
if err != nil {
return nil, err
}
log.Notice(fmt.Sprintf("call[vk]: join_link=%s", joinLink))
return &Bridge{relay: relay}, nil
case RoleJoiner:
tun, err := vk.ConnectJoiner(ctx, cfg.JoinLink, "", readBuf, cfg.Dialer, cfg.DNSRouter, log)
relay, err := vk.ConnectJoiner(ctx, cfg.JoinLink, "", readBuf, cfg.Dialer, cfg.DNSRouter, log)
if err != nil {
return nil, err
}
return &Bridge{relay: relay}, nil
}
case "bitrix":
switch cfg.Role {
case RoleJoiner:
relay, err := bitrix.ConnectJoiner(ctx, cfg.JoinLink, "", cfg.Mode, readBuf, cfg.Dialer, log)
if err != nil {
return nil, err
}
relay := tunnel.NewRelayBridge(tun, "joiner", readBuf, cfg.Dialer, log)
relay.MarkReady()
return &Bridge{relay: relay}, nil
}
case "dion":
switch cfg.Role {
case RoleCreator:
relay, joinLink, err := dion.ConnectCreator(ctx, cookieStr, cfg.JoinLink, cfg.Email, cfg.Password, readBuf, cfg.Dialer, log)
relay, joinLink, err := dion.ConnectCreator(ctx, cookieStr, cfg.JoinLink, cfg.Email, cfg.Password, readBuf, cfg.Dialer, cfg.DNSRouter, log)
if err != nil {
return nil, err
}
log.Notice(fmt.Sprintf("call[dion]: join_link=%s", joinLink))
return &Bridge{relay: relay}, nil
case RoleJoiner:
tun, err := dion.ConnectJoiner(ctx, cfg.JoinLink, "", readBuf, cfg.Dialer, log)
relay, err := dion.ConnectJoiner(ctx, cfg.JoinLink, "", readBuf, cfg.Dialer, cfg.DNSRouter, log)
if err != nil {
return nil, err
}
relay := tunnel.NewRelayBridge(tun, "joiner", readBuf, cfg.Dialer, log)
relay.MarkReady()
return &Bridge{relay: relay}, nil
}
}
+11 -4
View File
@@ -13,9 +13,11 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/sagernet/sing-box/transport/call/common"
N "github.com/sagernet/sing/common/network"
"github.com/google/uuid"
headless "github.com/kulikov0/headless-client"
)
var ErrSessionExpired = errors.New("dion: session expired, re-login required")
@@ -94,7 +96,7 @@ type WSSConnectResponse struct {
type Session struct {
HTTPClient *http.Client
Device DeviceProfile
Device common.DeviceProfile
AccessToken string
AccessTokenExp time.Time
UserID string
@@ -119,7 +121,7 @@ func NewSession(dialer N.Dialer) (*Session, error) {
}
httpClient := common.HttpClient(dialer)
httpClient.Jar = jar
return &Session{HTTPClient: httpClient, Device: RandomDeviceProfile()}, nil
return &Session{HTTPClient: httpClient, Device: common.ChromeWindowsDeviceProfile(headless.ChromeWindows.UserAgent())}, nil
}
func (s *Session) RegisterGuest() (*GuestAuthResponse, error) {
@@ -554,8 +556,13 @@ func (s *Session) setBaseHeaders(req *http.Request, accessToken string) {
req.Header.Set("Referer", Origin+"/")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en")
for name, values := range headless.ChromeWindows.Headers(headless.DestEmpty) {
if strings.HasPrefix(name, "Sec-Fetch-") {
req.Header[name] = values
}
}
req.Header.Set("X-Request-Id", uuid.New().String())
for name, value := range s.Device.Headers() {
for name, value := range deviceHeaders(s.Device) {
req.Header.Set(name, value)
}
if accessToken != "" {
+29 -23
View File
@@ -6,14 +6,17 @@ import (
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/transport/call/headlessapi"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing-box/transport/call/tunnel/rtc"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/google/uuid"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/webrtc"
"github.com/pion/rtp/codecs"
)
const (
@@ -45,9 +48,9 @@ type CallConfig struct {
RecvMid string
Role Role
SettingEngine *webrtc.SettingEngine
Dialer N.Dialer
DNSRouter adapter.DNSRouter
ConfigureSettingEngine func(*webrtc.SettingEngine)
Dialer N.Dialer
DNSRouter adapter.DNSRouter
}
type PeerEntry struct {
@@ -63,7 +66,7 @@ type Call struct {
signaling *SignalingClient
peer *PionPeer
sendTrack *webrtc.TrackLocalStaticSample
vp8tun *tunnel.VP8DataTunnel
vp8tun *rtc.VP8DataTunnel
mySessionID string
peersMu sync.Mutex
@@ -77,6 +80,7 @@ type Call struct {
OnConnected func(tunnel.DataTunnel)
OnPeerRestart func()
OnKicked func()
OnRemoteSDP func(sdp string)
done chan struct{}
@@ -153,6 +157,13 @@ func (c *Call) Start() error {
default:
}
}
signaling.OnKicked = func() {
c.cfg.Logger.Debug("[call] server kicked us, tearing down")
if c.OnKicked != nil {
c.OnKicked()
}
c.Close()
}
signaling.OnSpeakerJoined = c.handleSpeakerJoined
signaling.OnSpeakerDisconnected = c.handleSpeakerDisconnected
signaling.OnSpeakerCamStateChanged = c.handleSpeakerCamStateChanged
@@ -177,7 +188,13 @@ func (c *Call) Start() error {
}
c.cfg.Logger.Debug(fmt.Sprintf("[call] you_joined ice_servers=%d", len(youJoined.IceServers)))
pionAPI := NewPionAPI(c.cfg.SettingEngine)
pionAPI, err := headlessapi.WebRTCAPI(headlessapi.Options{
Profile: headless.ChromeWindows,
Configure: c.cfg.ConfigureSettingEngine,
})
if err != nil {
return fmt.Errorf("build webrtc api: %w", err)
}
iceServers := ResolveICEServerHosts(youJoined.IceServers, c.cfg.DNSRouter, c.cfg.Dialer, c.cfg.Logger)
peer, err := BuildPionPeer(pionAPI, iceServers)
if err != nil {
@@ -186,14 +203,11 @@ func (c *Call) Start() error {
c.peer = peer
sendMidIndex := sendVideoMidIndex
trackLabel := "dion-tunnel-" + sessionID
if c.cfg.Role == RoleCreator {
sendMidIndex = sendScreenShareMidIndex
trackLabel = "dion-tunnel-screen-" + sessionID
}
track, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
"video", trackLabel,
)
if err != nil {
return fmt.Errorf("NewTrackLocalStaticSample: %w", err)
@@ -209,13 +223,14 @@ func (c *Call) Start() error {
if err := sender.ReplaceTrack(track); err != nil {
return fmt.Errorf("ReplaceTrack: %w", err)
}
go rtc.DrainSenderRTCP(sender)
c.cfg.Logger.Debug(fmt.Sprintf("[call] role=%s attached send track to mid=%d", c.cfg.Role, sendMidIndex))
peer.PC.OnTrack(func(remoteTrack *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
c.cfg.Logger.Debug(fmt.Sprintf("[call] OnTrack id=%q kind=%s codec=%s ssrc=%d",
remoteTrack.ID(), remoteTrack.Kind().String(), remoteTrack.Codec().MimeType, remoteTrack.SSRC()))
if remoteTrack.Codec().MimeType != webrtc.MimeTypeVP8 {
go drainTrack(remoteTrack)
go rtc.DrainTrack(remoteTrack)
return
}
go c.readVP8Track(remoteTrack)
@@ -338,7 +353,7 @@ func (c *Call) Start() error {
if c.cfg.Role == RoleCreator {
fps, batch = creatorVP8FPS, creatorVP8Batch
}
c.vp8tun = tunnel.NewVP8DataTunnel(c.sendTrack, c.cfg.Obfuscator, c.cfg.Logger)
c.vp8tun = rtc.NewVP8DataTunnel(c.sendTrack, c.cfg.Obfuscator, c.cfg.Logger)
c.vp8tun.Start(fps, batch)
c.fireOnConnected(c.vp8tun)
@@ -721,12 +736,3 @@ func (c *Call) buildVideoInStats() []ClientStatVideoIn {
}
return out
}
func drainTrack(track *webrtc.TrackRemote) {
buf := make([]byte, 1500)
for {
if _, _, err := track.Read(buf); err != nil {
return
}
}
}
+17 -31
View File
@@ -5,13 +5,15 @@ import (
"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-box/transport/call/tunnel/rtc"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
func ConnectCreator(ctx context.Context, cookieStr, roomID, email, password string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
func ConnectCreator(ctx context.Context, cookieStr, roomID, email, password string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
auth, err := NewSession(dialer)
if err != nil {
return nil, "", fmt.Errorf("dion: new session: %w", err)
@@ -53,6 +55,7 @@ func ConnectCreator(ctx context.Context, cookieStr, roomID, email, password stri
DisplayName: "Creator",
Logger: logger,
Dialer: dialer,
DNSRouter: dnsRouter,
Role: RoleCreator,
})
call.OnConnected = func(tun tunnel.DataTunnel) {
@@ -60,7 +63,7 @@ func ConnectCreator(ctx context.Context, cookieStr, roomID, email, password stri
activeRelay.Reset()
}
bridgeReadBuf := common.VP8BufSize
if _, ok := tun.(*tunnel.DCTunnel); ok {
if _, ok := tun.(*rtc.DCTunnel); ok {
bridgeReadBuf = readBuf
}
activeRelay = tunnel.NewRelayBridge(tun, "creator", bridgeReadBuf, dialer, logger)
@@ -92,48 +95,31 @@ func ConnectCreator(ctx context.Context, cookieStr, roomID, email, password stri
}
}
func ConnectJoiner(ctx context.Context, roomID, displayName string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
func ConnectJoiner(ctx context.Context, roomID, displayName string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (*tunnel.RelayBridge, error) {
if displayName == "" {
displayName = "Joiner"
}
slug := ParseRoom(roomID)
if slug == "" {
return nil, fmt.Errorf("dion: missing room")
if readBuf <= 0 {
readBuf = 32768
}
auth, event, err := JoinAsGuest(dialer, slug, displayName)
if err != nil {
return nil, fmt.Errorf("dion: join as guest: %w", err)
}
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(event.Slug))
if err != nil {
return nil, fmt.Errorf("dion: obfuscator init: %w", err)
}
call := NewCall(CallConfig{
Auth: auth,
Event: event,
Obfuscator: obf,
DisplayName: displayName,
Logger: logger,
Dialer: dialer,
Role: RoleJoiner,
})
joiner := NewDionJoiner(logger, dialer, dnsRouter)
tunCh := make(chan tunnel.DataTunnel, 1)
call.OnConnected = func(tun tunnel.DataTunnel) {
joiner.OnConnected = func(tun tunnel.DataTunnel) {
select {
case tunCh <- tun:
default:
}
}
go func() {
if err := call.Start(); err != nil {
logger.Error(fmt.Sprintf("dion: call start failed: %v", err))
}
}()
params := fmt.Sprintf(`{"roomId":%q,"displayName":%q}`, roomID, displayName)
go joiner.RunWithParams(params)
select {
case tun := <-tunCh:
return tun, nil
rb := tunnel.NewRelayBridge(tun, "joiner", readBuf, dialer, logger)
rb.SetOnConfigAck(joiner.MarkConfigAcked)
rb.MarkReady()
return rb, nil
case <-ctx.Done():
call.Close()
joiner.Close()
return nil, ctx.Err()
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ func (s *Session) LoadCookieString(cookieStr string) error {
return fmt.Errorf("empty cookie string")
}
var entries []CookieEntry
for _, piece := range strings.Split(cookieStr, ";") {
for piece := range strings.SplitSeq(cookieStr, ";") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue
+3 -166
View File
@@ -2,156 +2,11 @@ package dion
import (
"fmt"
"math/rand/v2"
"github.com/sagernet/sing-box/transport/call/common"
)
type DeviceProfile struct {
UserAgent string
Platform string
BrowserType string
BrowserVersion string
DeviceBrand string
DeviceModel string
DeviceType string
OS string
OSVersion string
ScreenWidth int
ScreenHeight int
}
type deviceTemplate struct {
os string
osVersionPool []string
deviceBrandPool []string
deviceModelPool []string
browsers []browserTemplate
}
type browserTemplate struct {
browserType string
versionPool []string
userAgentFn func(osVersion, browserVersion string) string
}
var commonScreens = [][2]int{
{1280, 720}, {1366, 768}, {1440, 900}, {1536, 864},
{1600, 900}, {1680, 1050}, {1728, 1117}, {1920, 1080},
{2048, 1152}, {2560, 1440}, {2880, 1800}, {3840, 2160},
}
var deviceTemplates = []deviceTemplate{
{
os: "Mac OS",
osVersionPool: []string{"10.15.7", "11.7.10", "12.7.6", "13.6.9", "14.6.1", "15.1.0"},
deviceBrandPool: []string{"Apple"},
deviceModelPool: []string{"Macintosh"},
browsers: []browserTemplate{
{
browserType: "Chrome",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
macOSVersionForUA(osVersion), browserVersion)
},
},
{
browserType: "Safari",
versionPool: []string{"17.6", "18.0", "18.1", "18.2"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/%s Safari/605.1.15",
macOSVersionForUA(osVersion), browserVersion)
},
},
{
browserType: "Firefox",
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X %s; rv:%s) Gecko/20100101 Firefox/%s",
macOSVersionForUA(osVersion), browserVersion, browserVersion)
},
},
},
},
{
os: "Windows",
osVersionPool: []string{"10", "11"},
deviceBrandPool: []string{"Dell", "Lenovo", "HP", "Asus", "Acer", "MSI"},
deviceModelPool: []string{"PC"},
browsers: []browserTemplate{
{
browserType: "Chrome",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
browserVersion)
},
},
{
browserType: "Edge",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36 Edg/%s",
browserVersion, browserVersion)
},
},
{
browserType: "Firefox",
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:%s) Gecko/20100101 Firefox/%s",
browserVersion, browserVersion)
},
},
},
},
{
os: "Linux",
osVersionPool: []string{"x86_64", "x86_64 GNU"},
deviceBrandPool: []string{"Dell", "Lenovo", "HP", "System76", "Framework"},
deviceModelPool: []string{"PC"},
browsers: []browserTemplate{
{
browserType: "Chrome",
versionPool: []string{"141.0.0.0", "143.0.0.0", "145.0.0.0", "147.0.0.0", "149.0.0.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36",
browserVersion)
},
},
{
browserType: "Firefox",
versionPool: []string{"128.0", "131.0", "133.0", "135.0"},
userAgentFn: func(osVersion, browserVersion string) string {
return fmt.Sprintf("Mozilla/5.0 (X11; Linux x86_64; rv:%s) Gecko/20100101 Firefox/%s",
browserVersion, browserVersion)
},
},
},
},
}
func RandomDeviceProfile() DeviceProfile {
tmpl := deviceTemplates[rand.IntN(len(deviceTemplates))]
browser := tmpl.browsers[rand.IntN(len(tmpl.browsers))]
osVersion := tmpl.osVersionPool[rand.IntN(len(tmpl.osVersionPool))]
browserVersion := browser.versionPool[rand.IntN(len(browser.versionPool))]
screen := commonScreens[rand.IntN(len(commonScreens))]
return DeviceProfile{
UserAgent: browser.userAgentFn(osVersion, browserVersion),
Platform: "web",
BrowserType: browser.browserType,
BrowserVersion: browserVersion,
DeviceBrand: tmpl.deviceBrandPool[rand.IntN(len(tmpl.deviceBrandPool))],
DeviceModel: tmpl.deviceModelPool[rand.IntN(len(tmpl.deviceModelPool))],
DeviceType: "pc",
OS: tmpl.os,
OSVersion: osVersion,
ScreenWidth: screen[0],
ScreenHeight: screen[1],
}
}
func (p DeviceProfile) Headers() map[string]string {
func deviceHeaders(p common.DeviceProfile) map[string]string {
return map[string]string{
"d-platform": p.Platform,
"d-browser-type": p.BrowserType,
@@ -165,21 +20,3 @@ func (p DeviceProfile) Headers() map[string]string {
"d-screen-width": fmt.Sprintf("%d", p.ScreenWidth),
}
}
func macOSVersionForUA(osVersion string) string {
switch osVersion {
case "10.15.7":
return "10_15_7"
case "11.7.10":
return "10_15_7"
case "12.7.6":
return "10_15_7"
case "13.6.9":
return "10_15_7"
case "14.6.1":
return "10_15_7"
case "15.1.0":
return "10_15_7"
}
return "10_15_7"
}
+184
View File
@@ -0,0 +1,184 @@
package dion
import (
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"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"
)
const (
dionReconnectInitialDelay = time.Second
dionReconnectMaxDelay = 16 * time.Second
)
type DionJoiner struct {
logger logger.ContextLogger
dialer N.Dialer
dnsRouter adapter.DNSRouter
OnConnected func(tunnel.DataTunnel)
roomID string
displayName string
mu sync.Mutex
call *Call
closed bool
stopCh chan struct{}
stopOnce sync.Once
configAck tunnel.ConfigAckTracker
reconnectAttempt atomic.Int32
}
func NewDionJoiner(logger logger.ContextLogger, dialer N.Dialer, dnsRouter adapter.DNSRouter) *DionJoiner {
return &DionJoiner{
logger: logger,
dialer: dialer,
dnsRouter: dnsRouter,
stopCh: make(chan struct{}),
}
}
func (j *DionJoiner) RunWithParams(jsonParams string) {
var params struct {
RoomID string `json:"roomId"`
DisplayName string `json:"displayName"`
}
if err := json.Unmarshal([]byte(jsonParams), &params); err != nil {
j.logger.Error(fmt.Sprintf("dion-joiner: failed to parse params: %v", err))
return
}
slug := ParseRoom(params.RoomID)
if slug == "" {
j.logger.Error("dion-joiner: missing roomId")
return
}
j.roomID = slug
j.displayName = params.DisplayName
if j.displayName == "" {
j.displayName = "Joiner"
}
j.logger.Debug(fmt.Sprintf("dion-joiner: room=%s name=%s", j.roomID, j.displayName))
j.logger.Info("dion-joiner: connecting")
if err := j.runOnce(); err != nil {
j.logger.Error(fmt.Sprintf("dion-joiner: %v", err))
return
}
for {
if j.isClosed() {
j.logger.Debug("dion-joiner: stopped")
return
}
j.logger.Info("dion-joiner: tunnel lost")
if !j.waitBeforeRetry(int(j.reconnectAttempt.Load())) {
return
}
j.reconnectAttempt.Add(1)
if j.isClosed() {
return
}
j.logger.Info(fmt.Sprintf("dion-joiner: reconnect attempt #%d", j.reconnectAttempt.Load()))
if err := j.runOnce(); err != nil {
j.logger.Warn(fmt.Sprintf("dion-joiner: %v, will retry", err))
}
}
}
func (j *DionJoiner) MarkConfigAcked() { j.configAck.Mark() }
func (j *DionJoiner) Close() {
j.mu.Lock()
j.closed = true
call := j.call
j.call = nil
j.mu.Unlock()
j.stopOnce.Do(func() { close(j.stopCh) })
if call != nil {
call.Close()
}
}
func (j *DionJoiner) runOnce() error {
auth, event, err := JoinAsGuest(j.dialer, j.roomID, j.displayName)
if err != nil {
return fmt.Errorf("auth: %w", err)
}
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(event.Slug))
if err != nil {
return fmt.Errorf("obfuscator init: %w", err)
}
j.logger.Debug(fmt.Sprintf("dion-joiner: obf key-source=%q localEpoch=0x%08x", event.Slug, obf.LocalEpoch()))
call := NewCall(CallConfig{
Auth: auth,
Event: event,
Obfuscator: obf,
DisplayName: j.displayName,
Logger: j.logger,
Dialer: j.dialer,
DNSRouter: j.dnsRouter,
Role: RoleJoiner,
})
call.OnConnected = func(tun tunnel.DataTunnel) {
j.reconnectAttempt.Store(0)
j.logger.Info("dion-joiner: === TUNNEL CONNECTED ===")
if j.OnConnected != nil {
j.OnConnected(tun)
}
}
call.OnKicked = func() {
j.logger.Debug("dion-joiner: kicked from conference, shutting down")
go j.Close()
}
j.mu.Lock()
if j.closed {
j.mu.Unlock()
call.Close()
return nil
}
j.call = call
j.mu.Unlock()
if err := call.Start(); err != nil {
j.mu.Lock()
if j.call == call {
j.call = nil
}
j.mu.Unlock()
return fmt.Errorf("call: %w", err)
}
<-call.Done()
call.Close()
j.mu.Lock()
if j.call == call {
j.call = nil
}
j.mu.Unlock()
j.logger.Debug("dion-joiner: call ended")
return nil
}
func (j *DionJoiner) waitBeforeRetry(attempt int) bool {
delay := common.BackoffWithJitter(attempt, dionReconnectInitialDelay, dionReconnectMaxDelay)
j.logger.Debug(fmt.Sprintf("dion-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 *DionJoiner) isClosed() bool {
j.mu.Lock()
defer j.mu.Unlock()
return j.closed
}
+8 -68
View File
@@ -1,17 +1,14 @@
package dion
import (
"context"
"fmt"
"net"
"net/netip"
"strings"
"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/common/logger"
N "github.com/sagernet/sing/common/network"
"github.com/kulikov0/headless-client/webrtc"
)
type TransceiverPlan struct {
@@ -62,51 +59,14 @@ type PionPeer struct {
DatachannelDescs []DataChannelDesc
}
func NewPionAPI(customEngine ...*webrtc.SettingEngine) *webrtc.API {
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
panic(fmt.Errorf("dion: register default codecs: %w", err))
}
engine := webrtc.SettingEngine{}
if len(customEngine) > 0 && customEngine[0] != nil {
engine = *customEngine[0]
}
return webrtc.NewAPI(
webrtc.WithMediaEngine(mediaEngine),
webrtc.WithSettingEngine(engine),
)
}
func ResolveICEServerHosts(entries []ICEServerEntry, dnsRouter adapter.DNSRouter, d N.Dialer, logger logger.ContextLogger) []ICEServerEntry {
if dnsRouter == nil {
return entries
}
resolved := make(map[string]string)
out := make([]ICEServerEntry, 0, len(entries))
for _, entry := range entries {
urls := make([]string, len(entry.URLs))
copy(urls, entry.URLs)
for k, raw := range urls {
host := extractICEHost(raw)
if host == "" {
continue
}
ip, ok := resolved[host]
if !ok {
var addrs []netip.Addr
var err error
addrs, err = dnsRouter.Lookup(context.Background(), host, d.(dialer.ResolveDialer).QueryOptions())
if err != nil {
logger.Warn(fmt.Sprintf("[dion] resolve ICE host %s failed: %v", host, err))
continue
}
ip = addrs[0].String()
resolved[host] = ip
logger.Debug(fmt.Sprintf("[dion] resolved ICE host %s -> %s", host, addrs[0]))
}
urls[k] = strings.Replace(raw, host, ip, 1)
}
out = append(out, ICEServerEntry{URLs: urls, Username: entry.Username, Credential: entry.Credential})
out = append(out, ICEServerEntry{
URLs: common.ResolveICEHosts(entry.URLs, dnsRouter, d, logger, "[dion]"),
Username: entry.Username,
Credential: entry.Credential,
})
}
return out
}
@@ -240,23 +200,3 @@ func directionToDion(direction webrtc.RTPTransceiverDirection) string {
}
return "Unknown"
}
func extractICEHost(raw string) string {
value := raw
for _, prefix := range []string{"stun:", "turn:", "turns:"} {
value = strings.TrimPrefix(value, prefix)
}
if idx := strings.Index(value, "?"); idx >= 0 {
value = value[:idx]
}
if idx := strings.LastIndex(value, ":"); idx >= 0 {
value = value[:idx]
}
if value == "" {
return ""
}
if net.ParseIP(value) != nil {
return ""
}
return value
}
+16 -3
View File
@@ -12,11 +12,13 @@ import (
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/websocket"
)
const (
@@ -320,6 +322,7 @@ type SignalingClient struct {
OnGetVideoFromUserResponse func(resp GetVideoFromUserResponse, errCode int, errMessage string)
OnGetScreenSharingFromUserResponse func(resp GetScreenSharingFromUserResponse, errCode int, errMessage string)
OnHeartbeat func()
OnKicked func()
OnUnknown func(method string, params json.RawMessage)
OnDataChannelMessage func(method string, params json.RawMessage)
}
@@ -332,12 +335,17 @@ func DialSignaling(wssURL string, opts SignalingDialOptions) (*SignalingClient,
}
wssURL = wssURL + joiner + "socket_version=2.0"
}
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
var netDialContext func(ctx context.Context, network, addr string) (net.Conn, error)
if opts.Dialer != nil {
dialer.NetDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
netDialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return opts.Dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
}
}
dialer := headless.ChromeWindows.WebSocketDialer(headless.TLSOptions{DialContext: netDialContext})
dialer.HandshakeTimeout = 10 * time.Second
if netDialContext != nil {
dialer.NetDialContext = netDialContext
}
headers := http.Header{}
if opts.UserAgent != "" {
headers.Set("User-Agent", opts.UserAgent)
@@ -660,6 +668,11 @@ func (c *SignalingClient) dispatch(frame Frame) {
if c.OnHeartbeat != nil {
c.OnHeartbeat()
}
case MethodServerYouKicked:
c.logger.Debug("dion: server:you_kicked")
if c.OnKicked != nil {
c.OnKicked()
}
case MethodServerGetVideoFromUser:
var resp GetVideoFromUserResponse
_ = json.Unmarshal(frame.Params, &resp)
+71
View File
@@ -0,0 +1,71 @@
package headlessapi
import (
"fmt"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/webrtc"
)
type Options struct {
Profile headless.Profile
AnswerAsDTLSServer bool
Configure func(*webrtc.SettingEngine)
}
func resolveProfile(options Options) headless.Profile {
if options.Profile == (headless.Profile{}) {
return headless.ChromeWindows
}
return options.Profile
}
func WebRTCSettingEngine(options Options) (webrtc.SettingEngine, error) {
profile := resolveProfile(options)
settingEngine, err := profile.SettingEngine()
if err != nil {
return webrtc.SettingEngine{}, fmt.Errorf("build setting engine: %w", err)
}
if options.Configure != nil {
options.Configure(&settingEngine)
}
if options.AnswerAsDTLSServer {
if err := settingEngine.SetAnsweringDTLSRole(webrtc.DTLSRoleServer); err != nil {
return webrtc.SettingEngine{}, fmt.Errorf("set answering dtls role: %w", err)
}
}
return settingEngine, nil
}
func buildMediaEngine(options Options) (*webrtc.MediaEngine, error) {
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
return nil, fmt.Errorf("register default codecs: %w", err)
}
if err := resolveProfile(options).RegisterHeaderExtensions(mediaEngine); err != nil {
return nil, err
}
return mediaEngine, nil
}
func WebRTCAPI(options Options, apiOptions ...func(*webrtc.API)) (*webrtc.API, error) {
settingEngine, err := WebRTCSettingEngine(options)
if err != nil {
return nil, err
}
mediaEngine, err := buildMediaEngine(options)
if err != nil {
return nil, err
}
allAPIOptions := make([]func(*webrtc.API), 0, len(apiOptions)+2)
allAPIOptions = append(allAPIOptions, webrtc.WithMediaEngine(mediaEngine))
allAPIOptions = append(allAPIOptions, apiOptions...)
allAPIOptions = append(allAPIOptions, webrtc.WithSettingEngine(settingEngine))
return webrtc.NewAPI(allAPIOptions...), nil
}
+193 -168
View File
@@ -2,24 +2,21 @@ package livekit
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"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/headlessapi"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/webrtc"
"github.com/kulikov0/headless-client/websocket"
)
const (
@@ -38,19 +35,22 @@ const (
TrackSourceScreenShare = trackSourceScreenShare
)
type ICEServer = iceServer
type JoinResponse = joinResponse
type (
ICEServer = iceServer
JoinResponse = joinResponse
)
type Config struct {
ServerURL string
Token string
Origin string
UserAgent string
Logger logger.ContextLogger
SettingEngine *webrtc.SettingEngine
NetDialContext func(ctx context.Context, network, addr string) (net.Conn, error)
DNSRouter adapter.DNSRouter
Dialer N.Dialer
ServerURL string
Token string
Origin string
UserAgent string
Codec Codec
Logger logger.ContextLogger
ConfigureSettingEngine func(*webrtc.SettingEngine)
NetDialContext func(ctx context.Context, network, addr string) (net.Conn, error)
DNSRouter adapter.DNSRouter
Dialer N.Dialer
}
type Client struct {
@@ -60,11 +60,12 @@ type Client struct {
token string
origin string
ua string
codec Codec
settingEngine *webrtc.SettingEngine
netDialContext func(ctx context.Context, network, addr string) (net.Conn, error)
dnsRouter adapter.DNSRouter
dialer N.Dialer
configureSettingEngine func(*webrtc.SettingEngine)
netDialContext func(ctx context.Context, network, addr string) (net.Conn, error)
dnsRouter adapter.DNSRouter
dialer N.Dialer
ws *websocket.Conn
wsMu sync.Mutex
@@ -77,97 +78,106 @@ type Client struct {
subMu sync.Mutex
pubRemoteSet bool
subRemoteSet bool
pubPending []webrtc.ICECandidateInit
subPending []webrtc.ICECandidateInit
closed atomic.Bool
joined chan struct{}
joinedOnce sync.Once
closed atomic.Bool
OnReady func()
OnTrack func(*webrtc.TrackRemote, *webrtc.RTPReceiver)
OnDataChannel func(*webrtc.DataChannel)
OnPubConnected func()
OnSubConnected func()
OnParticipantUpdate func([]ParticipantInfo)
OnRemoteCandidate func(target int, candidate webrtc.ICECandidateInit)
OnRemoteSDP func(target int, sdpType, sdp string)
}
func NewClient(cfg Config) *Client {
return &Client{
logger: cfg.Logger,
wsURL: cfg.ServerURL,
token: cfg.Token,
origin: cfg.Origin,
ua: cfg.UserAgent,
settingEngine: cfg.SettingEngine,
netDialContext: cfg.NetDialContext,
dnsRouter: cfg.DNSRouter,
dialer: cfg.Dialer,
func NewClient(cfg Config) (*Client, error) {
if cfg.Codec == nil {
return nil, fmt.Errorf("livekit: no codec")
}
return &Client{
logger: cfg.Logger,
wsURL: cfg.ServerURL,
token: cfg.Token,
origin: cfg.Origin,
ua: cfg.UserAgent,
codec: cfg.Codec,
configureSettingEngine: cfg.ConfigureSettingEngine,
netDialContext: cfg.NetDialContext,
dnsRouter: cfg.DNSRouter,
dialer: cfg.Dialer,
joined: make(chan struct{}),
}, nil
}
func (c *Client) Join() JoinResponse { return c.join }
func (c *Client) PubPC() *webrtc.PeerConnection { return c.pubPC }
func (c *Client) SubPC() *webrtc.PeerConnection { return c.subPC }
func (c *Client) Joined() <-chan struct{} { return c.joined }
func (c *Client) Connect() error {
u, err := url.Parse(c.wsURL)
target, err := c.codec.DialURL(c.wsURL, c.token)
if err != nil {
return fmt.Errorf("parse url: %w", err)
return err
}
u.Path = "/rtc"
q := u.Query()
q.Set("access_token", c.token)
q.Set("protocol", ProtocolVersion)
q.Set("sdk", SDKName)
q.Set("version", SDKVersion)
q.Set("auto_subscribe", "1")
q.Set("adaptive_stream", "true")
u.RawQuery = q.Encode()
headers := http.Header{}
headers := headless.ChromeWindows.Headers(headless.DestWebSocket)
if c.ua != "" {
headers.Set("User-Agent", c.ua)
}
if c.origin != "" {
headers.Set("Origin", c.origin)
}
dialer := *websocket.DefaultDialer
dialer := headless.ChromeWindows.WebSocketDialer(headless.TLSOptions{DialContext: c.netDialContext})
if c.netDialContext != nil {
dialer.NetDialContext = c.netDialContext
}
conn, resp, err := dialer.Dial(u.String(), headers)
conn, resp, err := dialer.Dial(target, headers)
if err != nil {
if resp != nil {
return fmt.Errorf("ws dial: %w (status %d)", err, resp.StatusCode)
return fmt.Errorf("ws dial: %w, status %d", err, resp.StatusCode)
}
return fmt.Errorf("ws dial: %w", err)
}
c.wsMu.Lock()
c.ws = conn
c.wsMu.Unlock()
c.logger.Info("[lk] signaling connected")
return nil
}
func (c *Client) SendOffer(sdp string) error {
return c.sendSignal(encSignalRequestOffer(sessionDescription{Type: "offer", SDP: sdp}))
return c.sendSignal(c.codec.EncodeOffer(sdp))
}
func (c *Client) SendAnswer(sdp string) error {
return c.sendSignal(encSignalRequestAnswer(sessionDescription{Type: "answer", SDP: sdp}))
return c.sendSignal(c.codec.EncodeAnswer(sdp))
}
func (c *Client) SendTrickle(candidate webrtc.ICECandidateInit, target int) error {
js, _ := json.Marshal(candidate)
return c.sendSignal(encSignalRequestTrickle(trickleMsg{
CandidateInit: string(js),
Target: target,
}))
return c.sendSignal(c.codec.EncodeTrickle(candidate, target))
}
func (c *Client) SendAddTrack(cid, name string, trackType, source int, width, height uint32) error {
return c.sendSignal(encSignalRequestAddTrack(cid, name, trackType, source, width, height))
return c.sendSignal(c.codec.EncodeAddTrack(AddTrackParams{
CID: cid,
Name: name,
Type: trackType,
Source: source,
Width: width,
Height: height,
}))
}
func (c *Client) SendLeave() error { return c.sendSignal(encSignalRequestLeave()) }
func (c *Client) SendLeave() error { return c.sendSignal(c.codec.EncodeLeave()) }
func (c *Client) SendPing() error {
return c.sendSignal(encSignalRequestPing(time.Now().UnixMilli()))
return c.sendSignal(c.codec.EncodePing(time.Now().UnixMilli()))
}
func (c *Client) Close() {
@@ -176,24 +186,40 @@ func (c *Client) Close() {
}
c.wsMu.Lock()
ws := c.ws
c.ws = nil
c.wsMu.Unlock()
common.CloseWS(ws)
if c.pubPC != nil {
_ = c.pubPC.Close()
c.pubMu.Lock()
pubPC := c.pubPC
c.pubMu.Unlock()
c.subMu.Lock()
subPC := c.subPC
c.subMu.Unlock()
if pubPC != nil {
_ = pubPC.Close()
}
if c.subPC != nil {
_ = c.subPC.Close()
if subPC != nil {
_ = subPC.Close()
}
}
func (c *Client) ReadLoop() error {
defer c.Close()
c.wsMu.Lock()
ws := c.ws
c.wsMu.Unlock()
if ws == nil {
return fmt.Errorf("ws not connected")
}
for {
mt, data, err := c.ws.ReadMessage()
mt, data, err := ws.ReadMessage()
if err != nil {
if c.closed.Load() {
return nil
}
return err
}
if mt != websocket.BinaryMessage {
if !c.codec.Accepts(mt) {
continue
}
c.handleSignal(data)
@@ -201,6 +227,11 @@ func (c *Client) ReadLoop() error {
}
func (c *Client) PingLoop() {
select {
case <-c.joined:
case <-time.After(30 * time.Second):
return
}
period := PingPeriod
if c.join.PingIntervalSec > 0 {
period = time.Duration(c.join.PingIntervalSec) * time.Second
@@ -224,43 +255,21 @@ func (c *Client) PingLoop() {
}
func (c *Client) sendSignal(payload []byte) error {
if payload == nil {
return nil
}
c.wsMu.Lock()
defer c.wsMu.Unlock()
if c.ws == nil {
return fmt.Errorf("ws not connected")
}
return c.ws.WriteMessage(websocket.BinaryMessage, payload)
return c.ws.WriteMessage(c.codec.MessageType(), payload)
}
func (c *Client) iceServersAsWebRTC() []webrtc.ICEServer {
out := make([]webrtc.ICEServer, 0, len(c.join.ICEServers))
resolved := make(map[string]string)
for _, s := range c.join.ICEServers {
urls := make([]string, len(s.URLs))
copy(urls, s.URLs)
for k, u := range urls {
host := common.ExtractICEHost(u)
if host == "" || net.ParseIP(host) != nil {
continue
}
ip, ok := resolved[host]
if !ok {
rd, hasRD := c.dialer.(dialer.ResolveDialer)
if c.dnsRouter == nil || !hasRD {
continue
}
var addrs []netip.Addr
var err error
addrs, err = c.dnsRouter.Lookup(context.Background(), host, rd.QueryOptions())
if err != nil {
c.logger.Warn(fmt.Sprintf("[lk] resolve ICE host %s failed: %v", host, err))
continue
}
resolved[host] = addrs[0].String()
c.logger.Debug(fmt.Sprintf("[lk] resolved ICE host %s -> %s", host, addrs[0]))
}
urls[k] = strings.Replace(u, host, ip, 1)
}
urls := common.ResolveICEHosts(s.URLs, c.dnsRouter, c.dialer, c.logger, "[lk]")
ice := webrtc.ICEServer{URLs: urls}
if s.Username != "" {
ice.Username = s.Username
@@ -273,12 +282,20 @@ func (c *Client) iceServersAsWebRTC() []webrtc.ICEServer {
func (c *Client) buildPeerConnections() error {
cfg := webrtc.Configuration{ICEServers: c.iceServersAsWebRTC()}
se := webrtc.SettingEngine{}
if c.settingEngine != nil {
se = *c.settingEngine
api, err := headlessapi.WebRTCAPI(headlessapi.Options{
Profile: headless.ChromeWindows,
Configure: func(settingEngine *webrtc.SettingEngine) {
if c.configureSettingEngine != nil {
c.configureSettingEngine(settingEngine)
}
settingEngine.DetachDataChannels()
},
})
if err != nil {
return fmt.Errorf("build webrtc api: %w", err)
}
se.DetachDataChannels()
api := webrtc.NewAPI(webrtc.WithSettingEngine(se))
pubPC, err := api.NewPeerConnection(cfg)
if err != nil {
return fmt.Errorf("create pub pc: %w", err)
@@ -288,8 +305,12 @@ func (c *Client) buildPeerConnections() error {
_ = pubPC.Close()
return fmt.Errorf("create sub pc: %w", err)
}
c.pubMu.Lock()
c.pubPC = pubPC
c.pubMu.Unlock()
c.subMu.Lock()
c.subPC = subPC
c.subMu.Unlock()
pubPC.OnICECandidate(func(cand *webrtc.ICECandidate) {
if cand == nil {
c.logger.Debug("[lk] pub ICE gathering complete")
@@ -314,6 +335,9 @@ func (c *Client) buildPeerConnections() error {
})
subPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
c.logger.Debug(fmt.Sprintf("[lk] sub PC state: %s", state.String()))
if state == webrtc.PeerConnectionStateConnected && c.OnSubConnected != nil {
c.OnSubConnected()
}
})
pubPC.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
c.logger.Debug(fmt.Sprintf("[lk] pub ICE state: %s", state.String()))
@@ -333,7 +357,7 @@ func (c *Client) buildPeerConnections() error {
c.OnDataChannel(dc)
}
})
c.logger.Debug(fmt.Sprintf("[lk] PCs created (%d ICE servers)", len(c.join.ICEServers)))
c.logger.Debug(fmt.Sprintf("[lk] PCs created, %d ICE servers", len(c.join.ICEServers)))
for i, s := range c.join.ICEServers {
c.logger.Debug(fmt.Sprintf("[lk] iceServer[%d]: urls=%v hasCred=%v", i, s.URLs, s.Username != ""))
}
@@ -341,59 +365,50 @@ func (c *Client) buildPeerConnections() error {
}
func (c *Client) handleSignal(data []byte) {
sr, err := decSignalResponse(data)
ev, err := c.codec.Decode(data)
if err != nil {
c.logger.Warn(fmt.Sprintf("[lk] decode signal: %v", err))
return
}
switch sr.Kind {
case signalRespJoin:
if sr.Join != nil {
c.join = *sr.Join
c.logger.Info(fmt.Sprintf("[lk] join: room=%s participant=%s subscriberPrimary=%v iceServers=%d pingTimeout=%ds pingInterval=%ds",
c.join.RoomName, c.join.ParticipantID, c.join.SubscriberPrimary, len(c.join.ICEServers),
c.join.PingTimeoutSec, c.join.PingIntervalSec))
if err := c.buildPeerConnections(); err != nil {
c.logger.Error(fmt.Sprintf("[lk] %v", err))
return
}
if c.OnReady != nil {
c.OnReady()
}
switch ev.Kind {
case EventJoin:
c.join = *ev.Join
c.logger.Info(fmt.Sprintf("[lk] join: room=%s participant=%s subscriberPrimary=%v iceServers=%d pingTimeout=%ds pingInterval=%ds",
c.join.RoomName, c.join.ParticipantID, c.join.SubscriberPrimary, len(c.join.ICEServers),
c.join.PingTimeoutSec, c.join.PingIntervalSec))
if err := c.buildPeerConnections(); err != nil {
c.logger.Error(fmt.Sprintf("[lk] %v", err))
return
}
case signalRespAnswer:
c.logger.Debug(fmt.Sprintf("[lk] <- pub answer (%d bytes)", len(sr.SDP.SDP)))
if sr.SDP != nil {
c.applyPubAnswer(sr.SDP.SDP)
c.joinedOnce.Do(func() { close(c.joined) })
if c.OnReady != nil {
c.OnReady()
}
case signalRespOffer:
c.logger.Debug(fmt.Sprintf("[lk] <- sub offer (%d bytes)", len(sr.SDP.SDP)))
if sr.SDP != nil {
c.applySubOfferAndAnswer(sr.SDP.SDP)
}
case signalRespTrickle:
if sr.Trickle != nil {
c.logger.Debug(fmt.Sprintf("[lk] <- trickle target=%d", sr.Trickle.Target))
c.applyRemoteTrickle(*sr.Trickle)
}
case signalRespRefreshToken:
if sr.Token != "" {
c.token = sr.Token
c.logger.Debug("[lk] token refreshed")
}
case signalRespLeave:
if sr.Leave != nil {
case EventAnswer:
c.logger.Debug(fmt.Sprintf("[lk] pub answer received, %d bytes", len(ev.SDP)))
c.applyPubAnswer(ev.SDP)
case EventOffer:
c.logger.Debug(fmt.Sprintf("[lk] sub offer received, %d bytes", len(ev.SDP)))
c.applySubOfferAndAnswer(ev.SDP)
case EventTrickle:
c.logger.Debug(fmt.Sprintf("[lk] trickle target=%d", ev.Trickle.Target))
c.applyRemoteTrickle(*ev.Trickle)
case EventToken:
c.token = ev.Token
c.logger.Debug("[lk] token refreshed")
case EventLeave:
if ev.Leave != nil {
c.logger.Debug(fmt.Sprintf("[lk] ignored leave reason=%s action=%s",
DisconnectReasonName(sr.Leave.Reason), LeaveActionName(sr.Leave.Action)))
DisconnectReasonName(ev.Leave.Reason), LeaveActionName(ev.Leave.Action)))
} else {
c.logger.Debug("[lk] ignored leave")
}
case signalRespUpdate:
if c.OnParticipantUpdate != nil && len(sr.Participants) > 0 {
c.OnParticipantUpdate(sr.Participants)
case EventUpdate:
if c.OnParticipantUpdate != nil {
c.OnParticipantUpdate(ev.Participants)
}
default:
c.logger.Debug(fmt.Sprintf("[lk] <- signal kind=%d (%d bytes)", sr.Kind, len(data)))
c.logger.Debug(fmt.Sprintf("[lk] unhandled signal, %d bytes", len(data)))
}
}
@@ -411,6 +426,7 @@ func (c *Client) applyPubAnswer(sdp string) {
return
}
c.pubRemoteSet = true
c.drainPendingLocked(c.pubPC, &c.pubPending)
}
func (c *Client) applySubOfferAndAnswer(sdp string) {
@@ -418,21 +434,26 @@ func (c *Client) applySubOfferAndAnswer(sdp string) {
c.OnRemoteSDP(TargetSubscriber, "offer", sdp)
}
c.subMu.Lock()
defer c.subMu.Unlock()
if c.subPC == nil {
c.subMu.Unlock()
return
}
if err := c.subPC.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: sdp}); err != nil {
subPC := c.subPC
if err := subPC.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: sdp}); err != nil {
c.subMu.Unlock()
c.logger.Warn(fmt.Sprintf("[lk] set sub remote offer: %v", err))
return
}
c.subRemoteSet = true
answer, err := c.subPC.CreateAnswer(nil)
c.drainPendingLocked(subPC, &c.subPending)
c.subMu.Unlock()
answer, err := subPC.CreateAnswer(nil)
if err != nil {
c.logger.Warn(fmt.Sprintf("[lk] create sub answer: %v", err))
return
}
if err := c.subPC.SetLocalDescription(answer); err != nil {
if err := subPC.SetLocalDescription(answer); err != nil {
c.logger.Warn(fmt.Sprintf("[lk] set sub local answer: %v", err))
return
}
@@ -441,32 +462,36 @@ func (c *Client) applySubOfferAndAnswer(sdp string) {
}
}
func (c *Client) applyRemoteTrickle(m trickleMsg) {
if m.CandidateInit == "" {
return
}
var ic webrtc.ICECandidateInit
if err := json.Unmarshal([]byte(m.CandidateInit), &ic); err != nil {
c.logger.Warn(fmt.Sprintf("[lk] decode trickle candidate: %v", err))
return
func (c *Client) drainPendingLocked(pc *webrtc.PeerConnection, pending *[]webrtc.ICECandidateInit) {
for _, ic := range *pending {
if err := pc.AddICECandidate(ic); err != nil {
c.logger.Warn(fmt.Sprintf("[lk] add pending candidate: %v", err))
}
}
*pending = nil
}
func (c *Client) applyRemoteTrickle(m TrickleEvent) {
if c.OnRemoteCandidate != nil {
c.OnRemoteCandidate(m.Target, ic)
c.OnRemoteCandidate(m.Target, m.Candidate)
}
switch m.Target {
case TargetPublisher:
c.pubMu.Lock()
ready := c.pubRemoteSet
c.pubMu.Unlock()
if ready {
_ = c.pubPC.AddICECandidate(ic)
}
case TargetSubscriber:
c.subMu.Lock()
ready := c.subRemoteSet
c.subMu.Unlock()
if ready {
_ = c.subPC.AddICECandidate(ic)
}
mu := &c.subMu
pc := &c.subPC
ready := &c.subRemoteSet
pending := &c.subPending
if m.Target == TargetPublisher {
mu = &c.pubMu
pc = &c.pubPC
ready = &c.pubRemoteSet
pending = &c.pubPending
}
mu.Lock()
defer mu.Unlock()
if *pc == nil || !*ready {
*pending = append(*pending, m.Candidate)
return
}
if err := (*pc).AddICECandidate(m.Candidate); err != nil {
c.logger.Warn(fmt.Sprintf("[lk] add candidate: %v", err))
}
}
+332
View File
@@ -0,0 +1,332 @@
package livekit
import (
"encoding/json"
"fmt"
"net/url"
"github.com/kulikov0/headless-client/webrtc"
"github.com/kulikov0/headless-client/websocket"
)
const (
EventOther = iota
EventJoin
EventAnswer
EventOffer
EventTrickle
EventUpdate
EventLeave
EventToken
)
type TrickleEvent struct {
Candidate webrtc.ICECandidateInit
Target int
}
type Event struct {
Kind int
Join *JoinResponse
SDP string
Trickle *TrickleEvent
Participants []ParticipantInfo
Token string
Leave *LeaveInfo
}
type AddTrackParams struct {
CID string
Name string
Type int
Source int
Width uint32
Height uint32
}
type Codec interface {
DialURL(serverURL, token string) (string, error)
MessageType() int
Accepts(messageType int) bool
Decode(data []byte) (Event, error)
EncodeOffer(sdp string) []byte
EncodeAnswer(sdp string) []byte
EncodeTrickle(candidate webrtc.ICECandidateInit, target int) []byte
EncodeAddTrack(p AddTrackParams) []byte
EncodePing(timestamp int64) []byte
EncodeLeave() []byte
}
type ProtoCodec struct{}
func (ProtoCodec) DialURL(serverURL, token string) (string, error) {
u, err := url.Parse(serverURL)
if err != nil {
return "", fmt.Errorf("parse url: %w", err)
}
u.Path = "/rtc"
q := u.Query()
q.Set("access_token", token)
q.Set("protocol", ProtocolVersion)
q.Set("sdk", SDKName)
q.Set("version", SDKVersion)
q.Set("auto_subscribe", "1")
q.Set("adaptive_stream", "true")
u.RawQuery = q.Encode()
return u.String(), nil
}
func (ProtoCodec) MessageType() int { return websocket.BinaryMessage }
func (ProtoCodec) Accepts(messageType int) bool { return messageType == websocket.BinaryMessage }
func (ProtoCodec) Decode(data []byte) (Event, error) {
sr, err := decSignalResponse(data)
if err != nil {
return Event{}, err
}
var ev Event
switch sr.Kind {
case signalRespJoin:
if sr.Join == nil {
return ev, nil
}
ev.Kind = EventJoin
ev.Join = sr.Join
case signalRespAnswer:
if sr.SDP == nil {
return ev, nil
}
ev.Kind = EventAnswer
ev.SDP = sr.SDP.SDP
case signalRespOffer:
if sr.SDP == nil {
return ev, nil
}
ev.Kind = EventOffer
ev.SDP = sr.SDP.SDP
case signalRespTrickle:
if sr.Trickle == nil || sr.Trickle.CandidateInit == "" {
return ev, nil
}
var ic webrtc.ICECandidateInit
if err := json.Unmarshal([]byte(sr.Trickle.CandidateInit), &ic); err != nil {
return ev, fmt.Errorf("decode trickle candidate: %w", err)
}
ev.Kind = EventTrickle
ev.Trickle = &TrickleEvent{Candidate: ic, Target: sr.Trickle.Target}
case signalRespRefreshToken:
if sr.Token == "" {
return ev, nil
}
ev.Kind = EventToken
ev.Token = sr.Token
case signalRespLeave:
ev.Kind = EventLeave
ev.Leave = sr.Leave
case signalRespUpdate:
if len(sr.Participants) == 0 {
return ev, nil
}
ev.Kind = EventUpdate
ev.Participants = sr.Participants
}
return ev, nil
}
func (ProtoCodec) EncodeOffer(sdp string) []byte {
return encSignalRequestOffer(sessionDescription{Type: "offer", SDP: sdp})
}
func (ProtoCodec) EncodeAnswer(sdp string) []byte {
return encSignalRequestAnswer(sessionDescription{Type: "answer", SDP: sdp})
}
func (ProtoCodec) EncodeTrickle(candidate webrtc.ICECandidateInit, target int) []byte {
js, _ := json.Marshal(candidate)
return encSignalRequestTrickle(trickleMsg{CandidateInit: string(js), Target: target})
}
func (ProtoCodec) EncodeAddTrack(p AddTrackParams) []byte {
return encSignalRequestAddTrack(p.CID, p.Name, p.Type, p.Source, p.Width, p.Height)
}
func (ProtoCodec) EncodePing(timestamp int64) []byte {
return encSignalRequestPing(timestamp)
}
func (ProtoCodec) EncodeLeave() []byte { return encSignalRequestLeave() }
const (
jsonTargetPublisher = "PUBLISHER"
jsonTargetSubscriber = "SUBSCRIBER"
)
type jsonSDP struct {
SDP string `json:"sdp"`
Type string `json:"type"`
}
type jsonTrickle struct {
CandidateInit string `json:"candidateInit"`
Target string `json:"target"`
}
type jsonICEServer struct {
URLs []string `json:"urls"`
Username string `json:"username"`
Credential string `json:"credential"`
}
type jsonJoin struct {
RoomID string `json:"roomId"`
PingInterval int32 `json:"pingInterval"`
ICEServers []jsonICEServer `json:"iceServers"`
Local struct {
SID string `json:"sid"`
Name string `json:"name"`
UserID string `json:"userId"`
} `json:"localParticipant"`
}
type jsonVideoLayer struct {
Quality string `json:"quality"`
Width uint32 `json:"width"`
Height uint32 `json:"height"`
}
type jsonAddTrack struct {
CID string `json:"cid"`
Name string `json:"name"`
Type string `json:"type"`
Source string `json:"source"`
Width uint32 `json:"width"`
Height uint32 `json:"height"`
Layers []jsonVideoLayer `json:"layers"`
}
type JSONCodec struct{}
func (JSONCodec) DialURL(serverURL, _ string) (string, error) { return serverURL, nil }
func (JSONCodec) MessageType() int { return websocket.TextMessage }
func (JSONCodec) Accepts(messageType int) bool {
return messageType == websocket.TextMessage || messageType == websocket.BinaryMessage
}
func (JSONCodec) Decode(data []byte) (Event, error) {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(data, &envelope); err != nil {
return Event{}, err
}
var ev Event
for kind, raw := range envelope {
switch kind {
case "joinResponse":
var ji jsonJoin
if err := json.Unmarshal(raw, &ji); err != nil {
return ev, fmt.Errorf("joinResponse: %w", err)
}
join := JoinResponse{
RoomName: ji.RoomID,
ParticipantSID: ji.Local.SID,
ParticipantID: ji.Local.Name,
LocalUserID: ji.Local.UserID,
PingIntervalSec: ji.PingInterval,
}
for _, is := range ji.ICEServers {
join.ICEServers = append(join.ICEServers, ICEServer(is))
}
ev.Kind = EventJoin
ev.Join = &join
return ev, nil
case "offer", "answer":
var m jsonSDP
if err := json.Unmarshal(raw, &m); err != nil {
return ev, fmt.Errorf("%s: %w", kind, err)
}
if kind == "offer" {
ev.Kind = EventOffer
} else {
ev.Kind = EventAnswer
}
ev.SDP = m.SDP
return ev, nil
case "trickle":
var m jsonTrickle
if err := json.Unmarshal(raw, &m); err != nil {
return ev, fmt.Errorf("trickle: %w", err)
}
if m.CandidateInit == "" {
return ev, nil
}
var ic webrtc.ICECandidateInit
if err := json.Unmarshal([]byte(m.CandidateInit), &ic); err != nil {
return ev, fmt.Errorf("decode trickle candidate: %w", err)
}
target := TargetPublisher
if m.Target == jsonTargetSubscriber {
target = TargetSubscriber
}
ev.Kind = EventTrickle
ev.Trickle = &TrickleEvent{Candidate: ic, Target: target}
return ev, nil
}
}
return ev, nil
}
func (JSONCodec) encode(envelope string, payload any) []byte {
body, err := json.Marshal(map[string]any{envelope: payload})
if err != nil {
return nil
}
return body
}
func (c JSONCodec) EncodeOffer(sdp string) []byte {
return c.encode("offer", jsonSDP{SDP: sdp, Type: "offer"})
}
func (c JSONCodec) EncodeAnswer(sdp string) []byte {
return c.encode("answer", jsonSDP{SDP: sdp, Type: "answer"})
}
func (c JSONCodec) EncodeTrickle(candidate webrtc.ICECandidateInit, target int) []byte {
js, err := json.Marshal(candidate)
if err != nil {
return nil
}
wire := jsonTrickle{CandidateInit: string(js), Target: jsonTargetPublisher}
if target == TargetSubscriber {
wire.Target = jsonTargetSubscriber
}
return c.encode("trickle", wire)
}
func (c JSONCodec) EncodeAddTrack(p AddTrackParams) []byte {
kind := "VIDEO"
if p.Type == TrackTypeAudio {
kind = "AUDIO"
}
source := "CAMERA"
if p.Source == TrackSourceScreenShare {
source = "SCREEN_SHARE"
}
return c.encode("addTrack", jsonAddTrack{
CID: p.CID,
Name: p.Name,
Type: kind,
Source: source,
Width: p.Width,
Height: p.Height,
Layers: []jsonVideoLayer{{Quality: "HIGH", Width: p.Width, Height: p.Height}},
})
}
func (c JSONCodec) EncodePing(timestamp int64) []byte {
return c.encode("pingReq", map[string]any{"timestamp": timestamp, "rtt": 0})
}
func (JSONCodec) EncodeLeave() []byte { return nil }
+52
View File
@@ -0,0 +1,52 @@
package livekit
import (
"github.com/pion/datachannel"
)
type DataPacketWrapper struct {
inner datachannel.ReadWriteCloser
kind int
}
func NewDataPacketWrapper(inner datachannel.ReadWriteCloser, kind int) *DataPacketWrapper {
return &DataPacketWrapper{inner: inner, kind: kind}
}
func (w *DataPacketWrapper) ReadDataChannel(p []byte) (int, bool, error) {
buf := make([]byte, len(p))
for {
n, isString, err := w.inner.ReadDataChannel(buf)
if err != nil {
return 0, false, err
}
if n == 0 {
continue
}
payload, ok := DecodeDataPacketUser(buf[:n])
if !ok || len(payload) == 0 {
continue
}
copied := copy(p, payload)
return copied, isString, nil
}
}
func (w *DataPacketWrapper) WriteDataChannel(p []byte, isString bool) (int, error) {
wire := EncodeDataPacketUser(p, w.kind)
if _, err := w.inner.WriteDataChannel(wire, isString); err != nil {
return 0, err
}
return len(p), nil
}
func (w *DataPacketWrapper) Read(p []byte) (int, error) {
n, _, err := w.ReadDataChannel(p)
return n, err
}
func (w *DataPacketWrapper) Write(p []byte) (int, error) {
return w.WriteDataChannel(p, false)
}
func (w *DataPacketWrapper) Close() error { return w.inner.Close() }
+1
View File
@@ -155,6 +155,7 @@ type joinResponse struct {
RoomName string
ParticipantSID string
ParticipantID string
LocalUserID string
ServerVersion string
ServerRegion string
ICEServers []iceServer
+104 -62
View File
@@ -10,10 +10,12 @@ import (
"strings"
"time"
"github.com/sagernet/sing-box/transport/call/headlessapi"
"github.com/google/uuid"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/webrtc"
"github.com/pion/interceptor"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/transport/call/common"
)
const (
@@ -77,7 +79,7 @@ type Client struct {
InstanceID string
}
func (c *Client) Do(method, path string, body interface{}) ([]byte, int, error) {
func (c *Client) Do(method, path string, body any) ([]byte, int, error) {
var bodyReader io.Reader
if body != nil {
data, _ := json.Marshal(body)
@@ -87,15 +89,14 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, int, error)
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 = headless.ChromeWindows.Headers(headless.DestEmpty)
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
req.Header.Set("Origin", Origin)
req.Header.Set("Referer", Origin+"/")
req.Header.Set("Client-Instance-Id", instanceID)
@@ -110,7 +111,7 @@ func (c *Client) Do(method, path string, body interface{}) ([]byte, int, error)
}
client := c.HTTP
if client == nil {
client = http.DefaultClient
client = headless.ChromeWindows.HTTPClient()
}
resp, err := client.Do(req)
if err != nil {
@@ -127,10 +128,10 @@ func (c *Client) TMRequest(method, path string) ([]byte, int, error) {
func (c *Client) RequestStates(joinURI, peerID string) error {
confURL := url.QueryEscape(joinURI)
body := map[string]interface{}{
body := map[string]any{
"peers": []map[string]string{{"peer_id": peerID}},
"permissions": map[string]interface{}{},
"conference": map[string]interface{}{"version": -1},
"permissions": map[string]any{},
"conference": map[string]any{"version": -1},
}
r, status, err := c.Do("POST", "/conferences/"+confURL+"/request-states", body)
if err != nil {
@@ -142,39 +143,27 @@ func (c *Client) RequestStates(joinURI, peerID string) error {
return nil
}
func NewAPI(settingEngine *webrtc.SettingEngine) (*webrtc.API, error) {
func NewAPI(configure func(*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)
}
if err := headless.ChromeWindows.RegisterHeaderExtensions(mediaEngine); err != nil {
return nil, fmt.Errorf("telemost: %w", err)
}
registry := &interceptor.Registry{}
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, registry); err != nil {
return nil, err
}
opts := []func(*webrtc.API){
return headlessapi.WebRTCAPI(
headlessapi.Options{
Profile: headless.ChromeWindows,
AnswerAsDTLSServer: true,
Configure: configure,
},
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) {
@@ -208,23 +197,23 @@ func MungeSDPAddVideoContent(sdp string) string {
return strings.Join(out, "\r\n")
}
func SlotsConfigBindings(v interface{}) []SlotBindEvent {
m, ok := v.(map[string]interface{})
func SlotsConfigBindings(v any) []SlotBindEvent {
m, ok := v.(map[string]any)
if !ok {
return nil
}
slots, _ := m["slots"].([]interface{})
slots, _ := m["slots"].([]any)
var out []SlotBindEvent
for idx, s := range slots {
sm, _ := s.(map[string]interface{})
if pv, _ := sm["participantVideoByMid"].(map[string]interface{}); pv != nil {
sm, _ := s.(map[string]any)
if pv, _ := sm["participantVideoByMid"].(map[string]any); 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 {
if p, _ := sm["participant"].(map[string]any); p != nil {
pid, _ := p["participantId"].(string)
out = append(out, SlotBindEvent{Slot: idx, ParticipantID: pid})
}
@@ -232,7 +221,26 @@ func SlotsConfigBindings(v interface{}) []SlotBindEvent {
return out
}
func BriefJSON(v interface{}) string {
func ScreenShareBindings(v any) []SlotBindEvent {
m, ok := v.(map[string]any)
if !ok {
return nil
}
slots, _ := m["slots"].([]any)
var out []SlotBindEvent
for idx, s := range slots {
sm, _ := s.(map[string]any)
if ss, _ := sm["participantScreenSharingByMid"].(map[string]any); ss != nil {
pid, _ := ss["participantId"].(string)
mid, _ := ss["mid"].(string)
reason, _ := ss["limitationReason"].(string)
out = append(out, SlotBindEvent{Slot: idx, ParticipantID: pid, Mid: mid, Reason: reason})
}
}
return out
}
func BriefJSON(v any) string {
const max = 240
b, err := json.Marshal(v)
if err != nil {
@@ -244,28 +252,28 @@ func BriefJSON(v interface{}) string {
return string(b)
}
func SetSlotsMessage(key int) map[string]interface{} {
func SetSlotsMessage(key int) map[string]any {
rnd := mathrand.New(mathrand.NewSource(time.Now().UnixNano()))
return slotsMessageWithSizes(key, StartupSlotSizes[len(StartupSlotSizes)-1], rnd)
}
func StartupSetSlotsMessage(i, key int) map[string]interface{} {
func StartupSetSlotsMessage(i, key int) map[string]any {
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{}{
func SetSlotsOffsetMessage(offset int) map[string]any {
return map[string]any{
"uid": uuid.New().String(),
"setSlotsOffset": map[string]interface{}{"offset": offset},
"setSlotsOffset": map[string]any{"offset": offset},
}
}
func SdkCodecsInfoMessage() map[string]interface{} {
return map[string]interface{}{
func SdkCodecsInfoMessage() map[string]any {
return map[string]any{
"uid": uuid.New().String(),
"sdkCodecsInfo": map[string]interface{}{
"vp8": map[string]interface{}{
"sdkCodecsInfo": map[string]any{
"vp8": map[string]any{
"supported": "CODEC_FEATURE_SUPPORTED",
"hwDecode": "CODEC_FEATURE_NOT_SUPPORTED",
"hwEncode": "CODEC_FEATURE_NOT_SUPPORTED",
@@ -275,8 +283,42 @@ func SdkCodecsInfoMessage() map[string]interface{} {
}
}
func UpdatePublisherTrackDescriptionMessage(pc *webrtc.PeerConnection, audioLabel, videoLabel string) map[string]interface{} {
descs := []map[string]interface{}{}
func UpdateMeMessage(name string, sendVideo, sendSharing bool) map[string]any {
meta := map[string]any{
"name": name, "description": "", "role": "SPEAKER",
"sendAudio": false, "sendVideo": sendVideo,
}
return map[string]any{
"uid": uuid.New().String(),
"updateMe": map[string]any{
"participantMeta": meta,
"participantAttributes": map[string]any{"name": name, "role": "SPEAKER", "description": ""},
"sendAudio": false,
"sendVideo": sendVideo,
"sendSharing": sendSharing,
},
}
}
func DisplayVideoTrack(label string) map[string]any {
return map[string]any{
"kind": "DISPLAY_VIDEO", "label": label, "priority": 0, "dcLabel": "sharing", "mid": "",
"codecs": map[string]any{"96": map[string]any{"channels": 0, "clockRate": 90000, "mimeType": "video/VP8", "sdpFmtpLine": ""}},
"groupId": 2, "description": "",
}
}
func UpdatePublisherSharingTrackMessage(label string) map[string]any {
return map[string]any{
"uid": uuid.New().String(),
"updatePublisherTrackDescription": map[string]any{
"publisherTrackDescriptions": []map[string]any{DisplayVideoTrack(label)},
},
}
}
func UpdatePublisherTrackDescriptionMessage(pc *webrtc.PeerConnection, audioLabel, videoLabel string) map[string]any {
descs := []map[string]any{}
for _, tr := range pc.GetTransceivers() {
sender := tr.Sender()
if sender == nil || sender.Track() == nil {
@@ -290,20 +332,20 @@ func UpdatePublisherTrackDescriptionMessage(pc *webrtc.PeerConnection, audioLabe
label = audioLabel
groupId = 1
}
descs = append(descs, map[string]interface{}{
descs = append(descs, map[string]any{
"mid": mid,
"transceiverMid": mid,
"kind": kind,
"priority": 0,
"label": label,
"codecs": map[string]interface{}{},
"codecs": map[string]any{},
"groupId": groupId,
"description": "",
})
}
return map[string]interface{}{
return map[string]any{
"uid": uuid.New().String(),
"updatePublisherTrackDescription": map[string]interface{}{
"updatePublisherTrackDescription": map[string]any{
"publisherTrackDescriptions": descs,
},
}
@@ -317,25 +359,25 @@ func jitterSize(width int, rnd *mathrand.Rand) (int, int) {
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))
func slotsMessageWithSizes(key int, template [][2]int, rnd *mathrand.Rand) map[string]any {
slots := make([]map[string]any, 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}
slots[i] = map[string]any{"width": w, "height": h}
}
return map[string]interface{}{
return map[string]any{
"uid": uuid.New().String(),
"setSlots": map[string]interface{}{
"setSlots": map[string]any{
"slots": slots,
"audioSlotsCount": 0,
"key": key,
"shutdownAllVideo": nil,
"withSelfView": true,
"selfViewVisibility": "ON_LOADING_THEN_SHOW",
"gridConfig": map[string]interface{}{},
"gridConfig": map[string]any{},
},
}
}
+9 -2
View File
@@ -34,6 +34,7 @@ func ConnectCreator(ctx context.Context, cookieStr, joinLink string, readBuf int
config: cfg,
cookieStr: cookieStr,
peers: make(map[string]string),
seenPids: make(map[string]bool),
readBuf: readBuf,
dialer: dialer,
logger: logger,
@@ -53,10 +54,13 @@ func ConnectCreator(ctx context.Context, cookieStr, joinLink string, readBuf int
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) {
func ConnectJoiner(ctx context.Context, joinLink, displayName string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (*tunnel.RelayBridge, error) {
if displayName == "" {
displayName = "Joiner"
}
if readBuf <= 0 {
readBuf = 32768
}
joiner := NewTelemostJoiner(
logger,
dialer,
@@ -76,7 +80,10 @@ func ConnectJoiner(ctx context.Context, joinLink, displayName string, readBuf in
go joiner.RunWithParams(params)
select {
case tun := <-tunCh:
return tun, nil
rb := tunnel.NewRelayBridge(tun, "joiner", readBuf, dialer, logger)
rb.SetOnConfigAck(joiner.MarkConfigAcked)
rb.MarkReady()
return rb, nil
case <-ctx.Done():
joiner.Close()
return nil, ctx.Err()
+159 -93
View File
@@ -5,20 +5,21 @@ import (
"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"
"github.com/google/uuid"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/webrtc"
"github.com/kulikov0/headless-client/websocket"
)
const (
@@ -58,12 +59,15 @@ type Bridge struct {
setSlotsKey int
initBundleSent bool
shareMu sync.Mutex
shareEnabled bool
pendingKicks map[string]chan struct{}
boundPeers map[string]bool
unboundPeers map[string]bool
seenPids map[string]bool
}
func tmRequest(dialer N.Dialer, method, path string, body interface{}, cookieStr string, cfg TMConfig) ([]byte, int, error) {
func tmRequest(dialer N.Dialer, method, path string, body any, 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)
}
@@ -168,7 +172,7 @@ func CreateAndJoinCall(dialer N.Dialer, cookieStr string, cfg TMConfig, logger l
return info, nil
}
func (b *Bridge) wsSend(msg interface{}) {
func (b *Bridge) wsSend(msg any) {
b.mu.Lock()
defer b.mu.Unlock()
if b.ws == nil {
@@ -179,10 +183,10 @@ func (b *Bridge) wsSend(msg interface{}) {
}
func (b *Bridge) ack(uid string) {
b.wsSend(map[string]interface{}{
b.wsSend(map[string]any{
"uid": uid,
"ack": map[string]interface{}{
"status": map[string]interface{}{"code": "OK", "description": ""},
"ack": map[string]any{
"status": map[string]any{"code": "OK", "description": ""},
},
})
}
@@ -191,16 +195,16 @@ func (b *Bridge) sendHello() {
b.mu.Lock()
b.selfName = "Headless"
b.mu.Unlock()
b.wsSend(map[string]interface{}{
b.wsSend(map[string]any{
"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": ""},
"hello": map[string]any{
"participantMeta": map[string]any{"name": "Headless", "role": "SPEAKER", "description": "", "sendAudio": false, "sendVideo": true},
"participantAttributes": map[string]any{"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},
"sdkInfo": map[string]any{"implementation": "browser", "version": b.config.SDKVersion, "userAgent": headless.ChromeWindows.UserAgent(), "hwConcurrency": 8},
"sdkInitializationId": uuid.New().String(),
"disablePublisher": false, "disableSubscriber": false, "disableSubscriberAudio": false,
},
@@ -214,21 +218,68 @@ func (b *Bridge) sendPubOffer() {
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": ""})
b.sendPublisherOffer(offer, false)
}
func (b *Bridge) sendPublisherOffer(offer webrtc.SessionDescription, includeSharing bool) {
audioMids, videoMids := parseMids(offer.SDP)
b.logger.Debug(fmt.Sprintf("[tm-ws] -> publisherSdpOffer pcSeq=%d includeSharing=%v audio=%v video=%v", b.pubSeq, includeSharing, audioMids, videoMids))
var tracks []map[string]any
if len(audioMids) > 0 {
tracks = append(tracks, map[string]any{"mid": audioMids[0], "transceiverMid": audioMids[0], "kind": "AUDIO", "priority": 0, "label": "", "codecs": map[string]any{}, "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": ""})
if len(videoMids) > 0 {
tracks = append(tracks, map[string]any{"mid": videoMids[0], "transceiverMid": videoMids[0], "kind": "VIDEO", "priority": 0, "label": "", "codecs": map[string]any{}, "groupId": 1, "description": ""})
}
b.wsSend(map[string]interface{}{
if includeSharing {
tracks = append(tracks, DisplayVideoTrack("Screen"))
}
b.wsSend(map[string]any{
"uid": uuid.New().String(),
"publisherSdpOffer": map[string]interface{}{"pcSeq": b.pubSeq, "sdp": offer.SDP, "tracks": tracks},
"publisherSdpOffer": map[string]any{"pcSeq": b.pubSeq, "sdp": offer.SDP, "tracks": tracks},
})
}
func (b *Bridge) enableScreenshare() {
b.shareMu.Lock()
defer b.shareMu.Unlock()
if b.shareEnabled {
return
}
b.shareEnabled = true
b.logger.Debug("[ss] enabling screenshare: describe DISPLAY_VIDEO -> updateMe -> offer")
if err := b.relay.AddSharingDataChannel(); err != nil {
b.logger.Warn(fmt.Sprintf("[ss] AddSharingDataChannel: %v", err))
return
}
b.wsSend(UpdatePublisherSharingTrackMessage("Screen"))
b.wsSend(UpdateMeMessage(b.selfName, true, true))
offer, err := b.relay.CreatePubRenegotiate()
if err != nil {
b.logger.Warn(fmt.Sprintf("[ss] enable renegotiate failed: %v", err))
return
}
b.sendPublisherOffer(offer, true)
}
func (b *Bridge) disableScreenshare() {
b.shareMu.Lock()
defer b.shareMu.Unlock()
if !b.shareEnabled {
return
}
b.shareEnabled = false
b.logger.Debug("[ss] disabling screenshare (peer requested trackCount<2)")
b.wsSend(UpdateMeMessage(b.selfName, true, false))
b.relay.RemoveSharingDataChannel()
offer, err := b.relay.CreatePubRenegotiate()
if err != nil {
b.logger.Warn(fmt.Sprintf("[ss] disable renegotiate failed: %v", err))
return
}
b.sendPublisherOffer(offer, false)
}
func (b *Bridge) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) {
c := cand.ToJSON()
mid := ""
@@ -239,9 +290,9 @@ func (b *Bridge) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) {
if c.SDPMLineIndex != nil {
idx = *c.SDPMLineIndex
}
b.wsSend(map[string]interface{}{
b.wsSend(map[string]any{
"uid": uuid.New().String(),
"webrtcIceCandidate": map[string]interface{}{
"webrtcIceCandidate": map[string]any{
"candidate": c.Candidate, "sdpMid": mid,
"usernameFragment": extractUfrag(c.Candidate),
"sdpMlineIndex": idx, "target": target, "pcSeq": pcSeq,
@@ -249,31 +300,6 @@ func (b *Bridge) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) {
})
}
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
@@ -286,7 +312,7 @@ func (b *Bridge) sendInitBundle() {
}
func (b *Bridge) sendStartupSlotsRamp() {
for i := 0; i < 4; i++ {
for i := range 4 {
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))
@@ -294,14 +320,14 @@ func (b *Bridge) sendStartupSlotsRamp() {
}
func (b *Bridge) handleMessage(raw []byte) {
var msg map[string]interface{}
var msg map[string]any
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 {
if shMap, ok := sh.(map[string]any); ok {
b.parseICEServers(shMap)
}
b.ack(uid)
@@ -311,7 +337,7 @@ func (b *Bridge) handleMessage(raw []byte) {
return
}
if pa, ok := msg["publisherSdpAnswer"]; ok {
paMap, _ := pa.(map[string]interface{})
paMap, _ := pa.(map[string]any)
sdp, _ := paMap["sdp"].(string)
b.logger.Debug(fmt.Sprintf("[tm-ws] <- publisherSdpAnswer %d bytes", len(sdp)))
if err := b.relay.SetPubAnswer(sdp); err != nil {
@@ -322,7 +348,7 @@ func (b *Bridge) handleMessage(raw []byte) {
return
}
if so, ok := msg["subscriberSdpOffer"]; ok {
soMap, _ := so.(map[string]interface{})
soMap, _ := so.(map[string]any)
sdp, _ := soMap["sdp"].(string)
pcSeq, _ := soMap["pcSeq"].(float64)
b.subSeq = int(pcSeq)
@@ -334,15 +360,15 @@ func (b *Bridge) handleMessage(raw []byte) {
return
}
b.logger.Debug(fmt.Sprintf("[tm-ws] -> subscriberSdpAnswer pcSeq=%d", b.subSeq))
b.wsSend(map[string]interface{}{
b.wsSend(map[string]any{
"uid": uuid.New().String(),
"subscriberSdpAnswer": map[string]interface{}{"sdp": answer.SDP, "pcSeq": b.subSeq},
"subscriberSdpAnswer": map[string]any{"sdp": answer.SDP, "pcSeq": b.subSeq},
})
b.sendPubOffer()
return
}
if ic, ok := msg["webrtcIceCandidate"]; ok {
icMap, _ := ic.(map[string]interface{})
icMap, _ := ic.(map[string]any)
candidate, _ := icMap["candidate"].(string)
sdpMid, _ := icMap["sdpMid"].(string)
target, _ := icMap["target"].(string)
@@ -358,8 +384,8 @@ func (b *Bridge) handleMessage(raw []byte) {
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 ackMap, ok := ackData.(map[string]any); ok {
if status, ok := ackMap["status"].(map[string]any); 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))
@@ -370,17 +396,17 @@ func (b *Bridge) handleMessage(raw []byte) {
}
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{})
udMap, _ := ud.(map[string]any)
descs, _ := udMap["description"].([]any)
b.applyDescriptionSnapshot(descs)
b.ack(uid)
return
}
if ud, ok := msg["upsertDescription"]; ok {
udMap, _ := ud.(map[string]interface{})
descs, _ := udMap["description"].([]interface{})
udMap, _ := ud.(map[string]any)
descs, _ := udMap["description"].([]any)
for _, d := range descs {
dm, _ := d.(map[string]interface{})
dm, _ := d.(map[string]any)
b.applyDescriptionEntry(dm)
}
b.kickStaleSelves()
@@ -388,8 +414,8 @@ func (b *Bridge) handleMessage(raw []byte) {
return
}
if rd, ok := msg["removeDescription"]; ok {
rdMap, _ := rd.(map[string]interface{})
ids, _ := rdMap["descriptionId"].([]interface{})
rdMap, _ := rd.(map[string]any)
ids, _ := rdMap["descriptionId"].([]any)
for _, id := range ids {
pid, _ := id.(string)
b.mu.Lock()
@@ -479,7 +505,7 @@ func (b *Bridge) handleMessage(raw []byte) {
}
b.mu.Unlock()
if needRebind {
go b.forceReconnect("slot binding killed")
b.logger.Debug("[bind] slot kill/vanish observed - ignoring (tunnel data path is independent of slot binding)")
}
b.ack(uid)
return
@@ -496,20 +522,20 @@ func (b *Bridge) handleMessage(raw []byte) {
}
}
func (b *Bridge) parseICEServers(sh map[string]interface{}) {
rtcCfg, ok := sh["rtcConfiguration"].(map[string]interface{})
func (b *Bridge) parseICEServers(sh map[string]any) {
rtcCfg, ok := sh["rtcConfiguration"].(map[string]any)
if !ok {
return
}
servers, ok := rtcCfg["iceServers"].([]interface{})
servers, ok := rtcCfg["iceServers"].([]any)
if !ok {
return
}
var iceServers []webrtc.ICEServer
for _, s := range servers {
sm, _ := s.(map[string]interface{})
sm, _ := s.(map[string]any)
var urls []string
if u, ok := sm["urls"].([]interface{}); ok {
if u, ok := sm["urls"].([]any); ok {
for _, v := range u {
if vs, ok := v.(string); ok {
urls = append(urls, vs)
@@ -532,13 +558,13 @@ func (b *Bridge) requestStates() error {
return c.RequestStates(b.connInfo.ConferenceURI, b.connInfo.PeerID)
}
func (b *Bridge) applyDescriptionEntry(dm map[string]interface{}) {
func (b *Bridge) applyDescriptionEntry(dm map[string]any) {
pid, _ := dm["id"].(string)
if pid == "" {
return
}
name := ""
if meta, ok := dm["meta"].(map[string]interface{}); ok {
if meta, ok := dm["meta"].(map[string]any); ok {
name, _ = meta["name"].(string)
}
if pid == b.connInfo.PeerID {
@@ -552,10 +578,15 @@ func (b *Bridge) applyDescriptionEntry(dm map[string]interface{}) {
_, disconnected := dm["disconnectedAt"]
b.mu.Lock()
_, wasKnown := b.peers[pid]
newArrival := false
if disconnected {
delete(b.peers, pid)
} else {
b.peers[pid] = name
if !b.seenPids[pid] {
b.seenPids[pid] = true
newArrival = true
}
}
total := len(b.peers)
b.mu.Unlock()
@@ -565,17 +596,36 @@ func (b *Bridge) applyDescriptionEntry(dm map[string]interface{}) {
case disconnected:
b.logger.Debug(fmt.Sprintf("[tm-ws] Ghost participant: %s (%s) - kicking", name, pid))
go b.kickPeer(pid)
case !wasKnown:
case newArrival:
b.logger.Info(fmt.Sprintf("[tm-ws] Participant joined: %s (%s) total=%d", name, pid, total))
b.keepOnly(pid)
}
}
func (b *Bridge) applyDescriptionSnapshot(descs []interface{}) {
func (b *Bridge) keepOnly(keepPid string) {
b.mu.Lock()
victims := make([]string, 0, len(b.peers))
for pid := range b.peers {
if pid != keepPid {
victims = append(victims, pid)
}
}
for _, pid := range victims {
delete(b.peers, pid)
}
b.mu.Unlock()
for _, pid := range victims {
b.logger.Debug(fmt.Sprintf("[tm-ws] 1:1 evict %s", pid))
go b.kickPeer(pid)
}
}
func (b *Bridge) applyDescriptionSnapshot(descs []any) {
b.mu.Lock()
b.peers = make(map[string]string)
b.mu.Unlock()
for _, d := range descs {
dm, _ := d.(map[string]interface{})
dm, _ := d.(map[string]any)
b.applyDescriptionEntry(dm)
}
b.kickStaleSelves()
@@ -685,11 +735,18 @@ func (b *Bridge) initRelay() {
relay.OnPubReady = func() {
b.logger.Debug("[relay] pub PC connected")
}
relay.OnConnected = func(tun *tunnel.VP8DataTunnel) {
relay.OnConnected = func(tun tunnel.DataTunnel) {
if b.activeBridge != nil {
b.activeBridge.Reset()
}
b.activeBridge = tunnel.NewRelayBridge(tun, "creator", common.VP8BufSize, b.dialer, b.logger)
b.activeBridge.SetOnPeerConfig(func(fps, batch, trackCount int) {
if trackCount >= 2 {
b.enableScreenshare()
} else {
b.disableScreenshare()
}
})
b.logger.Debug("[relay] tunnel connected")
}
relay.OnPeerRestart = func() {
@@ -717,14 +774,13 @@ func (b *Bridge) initRelay() {
}
func (b *Bridge) Run() {
wsHeader := http.Header{}
wsHeader.Set("User-Agent", common.UserAgent)
wsHeader := headless.ChromeWindows.Headers(headless.DestWebSocket)
wsHeader.Set("Origin", tmOrigin)
wsDialer := websocket.Dialer{
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
wsDialer := headless.ChromeWindows.WebSocketDialer(headless.TLSOptions{
DialContext: 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)
@@ -761,7 +817,7 @@ func (b *Bridge) Run() {
case <-stopPing:
return
case <-ticker.C:
b.wsSend(map[string]interface{}{"uid": uuid.New().String(), "ping": map[string]interface{}{}})
b.wsSend(map[string]any{"uid": uuid.New().String(), "ping": map[string]any{}})
}
}
}()
@@ -809,6 +865,13 @@ func (b *Bridge) Run() {
time.Sleep(5 * time.Second)
continue
}
if oldPid := b.connInfo.PeerID; oldPid != "" && oldPid != newConn.PeerID {
b.mu.Lock()
b.seenPids[oldPid] = true
delete(b.peers, oldPid)
b.mu.Unlock()
go b.kickPeer(oldPid)
}
b.connInfo.PeerID = newConn.PeerID
b.connInfo.Credentials = newConn.Credentials
b.connInfo.MediaServerURL = newConn.MediaServerURL
@@ -817,20 +880,23 @@ func (b *Bridge) Run() {
}
}
func parseMids(sdp string) (audioMid, videoMid string) {
func parseMids(sdp string) (audioMids, videoMids []string) {
var media string
for _, line := range strings.Split(sdp, "\r\n") {
for line := range strings.SplitSeq(sdp, "\r\n") {
if strings.HasPrefix(line, "m=audio") {
media = "audio"
} else if strings.HasPrefix(line, "m=video") {
media = "video"
} else if strings.HasPrefix(line, "m=") {
media = ""
}
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
if after, ok := strings.CutPrefix(line, "a=mid:"); ok {
mid := after
switch media {
case "audio":
audioMids = append(audioMids, mid)
case "video":
videoMids = append(videoMids, mid)
}
}
}
+174 -120
View File
@@ -2,28 +2,28 @@ 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-box/transport/call/tunnel/rtc"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/google/uuid"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/webrtc"
"github.com/kulikov0/headless-client/websocket"
)
const (
@@ -62,10 +62,12 @@ type TelemostJoiner struct {
pubPending []webrtc.ICECandidateInit
sampleTrack *webrtc.TrackLocalStaticSample
vp8tunnel *tunnel.VP8DataTunnel
vp8tunnel *rtc.VP8DataTunnel
obf *tunnel.TunnelObfuscator
vp8FPS int
vp8Batch int
reliable bool
dualTrack bool
httpClient *http.Client
instanceID string
@@ -86,15 +88,17 @@ type TelemostJoiner struct {
configAck tunnel.ConfigAckTracker
reconnectAttempt atomic.Int32
setSlotsKey int
initBundleSent bool
boundPeers map[string]bool
unboundPeers map[string]bool
boundMu sync.Mutex
setSlotsKey int
slotsMu sync.Mutex
screenshareAsked bool
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{
j := &TelemostJoiner{
logger: logger,
dialer: dialer,
dnsRouter: dnsRouter,
@@ -103,14 +107,18 @@ func NewTelemostJoiner(logger logger.ContextLogger, dialer N.Dialer, dnsRouter a
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))
},
},
}
j.httpClient = &http.Client{
Timeout: 15 * time.Second,
Transport: headless.ChromeWindows.Transport(j.tlsOptions()),
}
return j
}
func (j *TelemostJoiner) tlsOptions() headless.TLSOptions {
return headless.TLSOptions{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return j.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
}
}
@@ -121,6 +129,8 @@ func (j *TelemostJoiner) RunWithParams(jsonParams string) {
DisplayName string `json:"displayName"`
VP8FPS int `json:"vp8Fps"`
VP8Batch int `json:"vp8Batch"`
Reliable bool `json:"reliable"`
DualTrack bool `json:"dualTrack"`
}
if err := json.Unmarshal([]byte(jsonParams), &params); err != nil {
j.logger.Error(fmt.Sprintf("telemost-joiner: failed to parse params: %v", err))
@@ -139,8 +149,10 @@ func (j *TelemostJoiner) RunWithParams(jsonParams string) {
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.reliable = params.Reliable
j.dualTrack = params.DualTrack
j.logger.Info(fmt.Sprintf("telemost-joiner: link=%s name=%s vp8Fps=%d vp8Batch=%d dualTrack=%v localEpoch=0x%08x",
j.joinLink, j.displayName, params.VP8FPS, params.VP8Batch, j.dualTrack, obf.LocalEpoch()))
j.logger.Info("telemost-joiner: connecting")
if err := j.runOnce(); err != nil {
j.logger.Error(fmt.Sprintf("telemost-joiner: %v", err))
@@ -189,14 +201,14 @@ func (j *TelemostJoiner) Close() {
func TmParseMids(sdp string) (audioMid, videoMid string) {
var media string
for _, line := range strings.Split(sdp, "\r\n") {
for line := range strings.SplitSeq(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 after, ok := strings.CutPrefix(line, "a=mid:"); ok {
mid := after
if media == "audio" && audioMid == "" {
audioMid = mid
} else if media == "video" && videoMid == "" {
@@ -355,7 +367,7 @@ func (j *TelemostJoiner) getConnection() error {
return nil
}
func (j *TelemostJoiner) wsSend(msg interface{}) {
func (j *TelemostJoiner) wsSend(msg any) {
j.wsMu.Lock()
defer j.wsMu.Unlock()
if j.ws != nil {
@@ -369,27 +381,27 @@ func (j *TelemostJoiner) ack(uid string) {
if uid == "" {
return
}
j.wsSend(map[string]interface{}{
j.wsSend(map[string]any{
"uid": uid,
"ack": map[string]interface{}{
"status": map[string]interface{}{"code": "OK", "description": ""},
"ack": map[string]any{
"status": map[string]any{"code": "OK", "description": ""},
},
})
}
func (j *TelemostJoiner) sendHello() {
j.wsSend(map[string]interface{}{
j.wsSend(map[string]any{
"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": ""},
"hello": map[string]any{
"participantMeta": map[string]any{"name": j.displayName, "role": "SPEAKER", "description": "", "sendAudio": false, "sendVideo": true},
"participantAttributes": map[string]any{"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},
"sdkInfo": map[string]any{"implementation": "browser", "version": "6.0.0", "userAgent": headless.ChromeWindows.UserAgent(), "hwConcurrency": 8},
"sdkInitializationId": uuid.New().String(),
"disablePublisher": false, "disableSubscriber": false, "disableSubscriberAudio": false,
},
@@ -399,9 +411,9 @@ func (j *TelemostJoiner) sendHello() {
func (j *TelemostJoiner) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) {
candidate := cand.ToJSON()
j.wsSend(map[string]interface{}{
j.wsSend(map[string]any{
"uid": uuid.New().String(),
"webrtcIceCandidate": map[string]interface{}{
"webrtcIceCandidate": map[string]any{
"candidate": candidate.Candidate, "sdpMid": *candidate.SDPMid,
"sdpMlineIndex": *candidate.SDPMLineIndex, "target": target, "pcSeq": pcSeq,
},
@@ -410,17 +422,23 @@ func (j *TelemostJoiner) sendICE(cand *webrtc.ICECandidate, target string, pcSeq
func (j *TelemostJoiner) initPC() {
config := webrtc.Configuration{ICEServers: j.iceServers}
settingEngine := webrtc.SettingEngine{}
settingEngine.DetachDataChannels()
if j.PCConfig != nil {
j.PCConfig.ConfigureSettingEngine(&settingEngine)
newAPI := func() (*webrtc.API, error) {
return NewAPI(func(settingEngine *webrtc.SettingEngine) {
settingEngine.DetachDataChannels()
if j.PCConfig != nil {
j.PCConfig.ConfigureSettingEngine(settingEngine)
}
})
}
api, err := NewAPI(&settingEngine)
subAPI, err := newAPI()
if err != nil {
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create webrtc API: %v", err))
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create subscriber webrtc API: %v", err))
return
}
subPC, err := api.NewPeerConnection(config)
subPC, err := subAPI.NewPeerConnection(config)
if err != nil {
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create sub PC: %v", err))
return
@@ -433,8 +451,9 @@ func (j *TelemostJoiner) initPC() {
})
subPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
j.logger.Debug(fmt.Sprintf("telemost-joiner: sub PC state: %s", state.String()))
if state == webrtc.PeerConnectionStateFailed {
if state == webrtc.PeerConnectionStateFailed && !j.isClosed() {
j.logger.Error("telemost-joiner: ERROR: subscriber connection failed")
go j.forceReconnect("subscriber connection failed")
}
})
subPC.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
@@ -445,7 +464,13 @@ func (j *TelemostJoiner) initPC() {
}
}, j.logger, "telemost-joiner")
})
pubPC, err := api.NewPeerConnection(config)
pubAPI, err := newAPI()
if err != nil {
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create publisher webrtc API: %v", err))
return
}
pubPC, err := pubAPI.NewPeerConnection(config)
if err != nil {
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: create pub PC: %v", err))
return
@@ -460,20 +485,34 @@ func (j *TelemostJoiner) initPC() {
})
pubPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
j.logger.Debug(fmt.Sprintf("telemost-joiner: pub PC state: %s", state.String()))
if state == webrtc.PeerConnectionStateFailed && !j.isClosed() {
j.logger.Error("telemost-joiner: ERROR: publisher connection failed")
go j.forceReconnect("publisher connection failed")
}
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)
j.vp8tunnel = rtc.NewVP8DataTunnel(j.sampleTrack, j.obf, j.logger)
vp8tun := j.vp8tunnel
vp8tun.Start(j.vp8FPS, j.vp8Batch)
var active tunnel.DataTunnel = vp8tun
if j.reliable {
mt := rtc.NewMultiTrackTunnel([]*rtc.VP8DataTunnel{vp8tun})
active = rtc.NewMultiTrackKCPTunnel(mt, j.logger)
j.logger.Debug("telemost-joiner: per-track kcp reliability active over video tunnel")
}
if !j.configAck.Acknowledged() {
trackCount := 1
if j.dualTrack {
trackCount = 2
}
acked, cancel := j.configAck.Arm()
go tunnel.SendVP8ConfigUntilAcked(acked, cancel, j.stopCh, vp8tun,
vp8tun.FPS(), vp8tun.Batch(), 1, j.logger, "telemost-joiner")
go tunnel.SendVP8ConfigUntilAcked(acked, cancel, j.stopCh, active,
vp8tun.FPS(), vp8tun.Batch(), trackCount, 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.OnConnected(active)
}
}
})
@@ -496,16 +535,16 @@ func (j *TelemostJoiner) sendPubOffer() {
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{}
var tracks []map[string]any
if audioMid != "" {
tracks = append(tracks, map[string]interface{}{"mid": audioMid, "transceiverMid": audioMid, "kind": "AUDIO", "priority": 0, "label": "", "codecs": map[string]interface{}{}, "groupId": 1, "description": ""})
tracks = append(tracks, map[string]any{"mid": audioMid, "transceiverMid": audioMid, "kind": "AUDIO", "priority": 0, "label": "", "codecs": map[string]any{}, "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": ""})
tracks = append(tracks, map[string]any{"mid": videoMid, "transceiverMid": videoMid, "kind": "VIDEO", "priority": 0, "label": "", "codecs": map[string]any{}, "groupId": 2, "description": ""})
}
j.wsSend(map[string]interface{}{
j.wsSend(map[string]any{
"uid": uuid.New().String(),
"publisherSdpOffer": map[string]interface{}{"pcSeq": j.pubSeq, "sdp": offer.SDP, "tracks": tracks},
"publisherSdpOffer": map[string]any{"pcSeq": j.pubSeq, "sdp": offer.SDP, "tracks": tracks},
})
}
@@ -543,10 +582,29 @@ func (j *TelemostJoiner) sendInitBundle() {
j.sendStartupSlotsRamp()
}
func (j *TelemostJoiner) requestVideoSlots() {
func (j *TelemostJoiner) nextSlotsKey() int {
j.slotsMu.Lock()
defer j.slotsMu.Unlock()
j.setSlotsKey++
j.logger.Debug(fmt.Sprintf("telemost-joiner: -> setSlots key=%d", j.setSlotsKey))
j.wsSend(SetSlotsMessage(j.setSlotsKey))
return j.setSlotsKey
}
func (j *TelemostJoiner) requestVideoSlots() {
key := j.nextSlotsKey()
j.logger.Debug(fmt.Sprintf("telemost-joiner: -> setSlots key=%d", key))
j.wsSend(SetSlotsMessage(key))
}
func (j *TelemostJoiner) pollScreenshareSlots() {
for i := range 8 {
select {
case <-j.stopCh:
return
case <-time.After(3 * time.Second):
}
j.logger.Debug(fmt.Sprintf("telemost-joiner: -> setSlots re-request %d to bind screenshare", i+1))
j.requestVideoSlots()
}
}
func (j *TelemostJoiner) forceReconnect(reason string) {
@@ -570,10 +628,13 @@ func (j *TelemostJoiner) forceReconnect(reason string) {
}
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))
for i := range 4 {
key := j.nextSlotsKey()
j.logger.Debug(fmt.Sprintf("telemost-joiner: -> setSlots key=%d (startup %d/4)", key, i+1))
j.wsSend(StartupSetSlotsMessage(i, key))
}
if j.dualTrack {
go j.pollScreenshareSlots()
}
}
@@ -606,22 +667,22 @@ func (j *TelemostJoiner) handleSubOffer(sdp string, pcSeq int) {
}
j.subPC.SetLocalDescription(answer)
j.logger.Debug(fmt.Sprintf("telemost-joiner: -> subscriberSdpAnswer pcSeq=%d", pcSeq))
j.wsSend(map[string]interface{}{
j.wsSend(map[string]any{
"uid": uuid.New().String(),
"subscriberSdpAnswer": map[string]interface{}{"sdp": answer.SDP, "pcSeq": pcSeq},
"subscriberSdpAnswer": map[string]any{"sdp": answer.SDP, "pcSeq": pcSeq},
})
j.sendPubOffer()
}
func (j *TelemostJoiner) handleMessage(raw []byte) {
var msg map[string]interface{}
var msg map[string]any
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 {
if sh, ok := msg["serverHello"].(map[string]any); ok {
j.parseICEServersFromHello(sh)
}
j.ack(uid)
@@ -629,7 +690,7 @@ func (j *TelemostJoiner) handleMessage(raw []byte) {
return
}
if so, ok := msg["subscriberSdpOffer"]; ok {
soMap, _ := so.(map[string]interface{})
soMap, _ := so.(map[string]any)
sdp, _ := soMap["sdp"].(string)
pcSeq, _ := soMap["pcSeq"].(float64)
j.logger.Debug(fmt.Sprintf("telemost-joiner: <- subscriberSdpOffer pcSeq=%d len=%d", int(pcSeq), len(sdp)))
@@ -638,14 +699,14 @@ func (j *TelemostJoiner) handleMessage(raw []byte) {
return
}
if pa, ok := msg["publisherSdpAnswer"]; ok {
paMap, _ := pa.(map[string]interface{})
paMap, _ := pa.(map[string]any)
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{})
icMap, _ := ic.(map[string]any)
candidate, _ := icMap["candidate"].(string)
sdpMid, _ := icMap["sdpMid"].(string)
target, _ := icMap["target"].(string)
@@ -659,13 +720,14 @@ func (j *TelemostJoiner) handleMessage(raw []byte) {
}
j.OnRemoteCandidate(tgt, candidate)
}
if target == "SUBSCRIBER" {
switch target {
case "SUBSCRIBER":
if j.subRemoteSet {
j.subPC.AddICECandidate(cand)
} else {
j.subPending = append(j.subPending, cand)
}
} else if target == "PUBLISHER" {
case "PUBLISHER":
if j.pubRemoteSet {
j.pubPC.AddICECandidate(cand)
} else {
@@ -676,8 +738,8 @@ func (j *TelemostJoiner) handleMessage(raw []byte) {
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 ackMap, ok := ackData.(map[string]any); ok {
if status, ok := ackMap["status"].(map[string]any); 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))
@@ -687,14 +749,14 @@ func (j *TelemostJoiner) handleMessage(raw []byte) {
return
}
if ud, ok := msg["upsertDescription"]; ok {
udMap, _ := ud.(map[string]interface{})
if descs, ok := udMap["description"].([]interface{}); ok {
udMap, _ := ud.(map[string]any)
if descs, ok := udMap["description"].([]any); ok {
for _, d := range descs {
dm, _ := d.(map[string]interface{})
dm, _ := d.(map[string]any)
pid, _ := dm["id"].(string)
if pid != "" && pid != j.peerID {
participantName := ""
if meta, ok := dm["meta"].(map[string]interface{}); ok {
if meta, ok := dm["meta"].(map[string]any); ok {
participantName, _ = meta["name"].(string)
}
j.logger.Debug(fmt.Sprintf("telemost-joiner: participant: %s (%s)", participantName, pid))
@@ -716,6 +778,24 @@ func (j *TelemostJoiner) handleMessage(raw []byte) {
}
if sc, ok := msg["slotsConfig"]; ok {
j.logger.Debug(fmt.Sprintf("telemost-joiner: <- slotsConfig %s", BriefJSON(sc)))
if j.dualTrack {
unboundScreenshare := false
for _, ev := range ScreenShareBindings(sc) {
pid := ev.ParticipantID
if len(pid) > 8 {
pid = pid[:8]
}
j.logger.Debug(fmt.Sprintf("telemost-joiner: [screenshare] slot=%d pid=%s mid=%q reason=%q", ev.Slot, pid, ev.Mid, ev.Reason))
if ev.Mid == "" {
unboundScreenshare = true
}
}
if unboundScreenshare && !j.screenshareAsked {
j.screenshareAsked = true
j.logger.Debug("telemost-joiner: [screenshare] advertised with empty mid, re-requesting sized slots once")
j.requestVideoSlots()
}
}
needRebind := false
presentPids := make(map[string]bool)
for _, ev := range SlotsConfigBindings(sc) {
@@ -769,7 +849,7 @@ func (j *TelemostJoiner) handleMessage(raw []byte) {
}
j.boundMu.Unlock()
if needRebind {
go j.forceReconnect("slot binding killed")
j.logger.Debug("telemost-joiner: slot kill/vanish observed - ignoring (tunnel data path is independent of slot binding)")
}
j.ack(uid)
return
@@ -786,59 +866,33 @@ func (j *TelemostJoiner) handleMessage(raw []byte) {
}
}
func (j *TelemostJoiner) parseICEServersFromHello(sh map[string]interface{}) {
rtcCfg, ok := sh["rtcConfiguration"].(map[string]interface{})
func (j *TelemostJoiner) parseICEServersFromHello(sh map[string]any) {
rtcCfg, ok := sh["rtcConfiguration"].(map[string]any)
if !ok {
return
}
servers, ok := rtcCfg["iceServers"].([]interface{})
servers, ok := rtcCfg["iceServers"].([]any)
if !ok {
return
}
var iceServers []webrtc.ICEServer
for _, s := range servers {
sm, _ := s.(map[string]interface{})
sm, _ := s.(map[string]any)
var urls []string
if u, ok := sm["urls"].([]interface{}); ok {
if u, ok := sm["urls"].([]any); ok {
for _, v := range u {
if vs, ok := v.(string); ok {
urls = append(urls, common.FixICEURL(vs))
urls = append(urls, vs)
}
}
}
ice := webrtc.ICEServer{URLs: urls}
ice := webrtc.ICEServer{URLs: common.ResolveICEHosts(urls, j.dnsRouter, j.dialer, j.logger, "telemost-joiner")}
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))
@@ -853,18 +907,18 @@ func (j *TelemostJoiner) connectAndRun() {
return
}
hostname := parsed.Hostname()
wsHeader := http.Header{}
wsHeader.Set("User-Agent", common.UserAgent)
wsHeader := headless.ChromeWindows.Headers(headless.DestWebSocket)
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) {
dialer := headless.ChromeWindows.WebSocketDialer(headless.TLSOptions{
ServerName: hostname,
InsecureSkipVerify: true,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return j.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
}
})
dialer.HandshakeTimeout = 10 * time.Second
dialer.WriteBufferSize = 65536
ws, _, err := dialer.Dial(j.mediaURL, wsHeader)
if err != nil {
j.logger.Error(fmt.Sprintf("telemost-joiner: ERROR: ws connect: %s", common.MaskError(err)))
@@ -884,7 +938,7 @@ func (j *TelemostJoiner) connectAndRun() {
case <-stopPing:
return
case <-ticker.C:
j.wsSend(map[string]interface{}{"uid": uuid.New().String(), "ping": map[string]interface{}{}})
j.wsSend(map[string]any{"uid": uuid.New().String(), "ping": map[string]any{}})
}
}
}()
+173 -12
View File
@@ -4,12 +4,22 @@ 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-box/transport/call/tunnel/rtc"
"github.com/sagernet/sing/common/logger"
"github.com/kulikov0/headless-client/webrtc"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
)
const (
sharingRTPMTU = 1200
sharingVP8PT = 96
sharingClockRate = 90000
sharingStreamSSRC = 0x1a2b3c4d
defaultSharingFPS = 24
)
type SFURelay struct {
@@ -23,15 +33,27 @@ type SFURelay struct {
logger logger.ContextLogger
sampleTrack *webrtc.TrackLocalStaticSample
tun *tunnel.VP8DataTunnel
tun *rtc.VP8DataTunnel
mt *rtc.MultiTrackTunnel
delivered tunnel.DataTunnel
obf *tunnel.TunnelObfuscator
OnConnected func(*tunnel.VP8DataTunnel)
OnConnected func(tunnel.DataTunnel)
OnPubReady func()
OnPeerRestart func()
OnPubICE func(*webrtc.ICECandidate)
OnSubICE func(*webrtc.ICECandidate)
sharingDC *webrtc.DataChannel
readBufSize int
tunFired bool
}
type sharingDCSink struct {
dc *webrtc.DataChannel
mu sync.Mutex
packetizer rtp.Packetizer
samples uint32
}
func (r *SFURelay) SetObfuscator(o *tunnel.TunnelObfuscator) { r.obf = o }
@@ -40,6 +62,80 @@ func NewSFURelay(logger logger.ContextLogger) *SFURelay {
return &SFURelay{logger: logger}
}
func newSharingDCSink(dc *webrtc.DataChannel, fps int) *sharingDCSink {
if fps <= 0 {
fps = defaultSharingFPS
}
return &sharingDCSink{
dc: dc,
packetizer: rtp.NewPacketizer(sharingRTPMTU, sharingVP8PT, sharingStreamSSRC,
&codecs.VP8Payloader{EnablePictureID: true}, rtp.NewRandomSequencer(), sharingClockRate),
samples: uint32(sharingClockRate / fps),
}
}
func (s *sharingDCSink) sendFrame(frame []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, pkt := range s.packetizer.Packetize(frame, s.samples) {
raw, err := pkt.Marshal()
if err != nil {
continue
}
if err := s.dc.Send(raw); err != nil {
return err
}
}
return nil
}
func (r *SFURelay) AddSharingDataChannel() error {
if r.pubPC == nil {
return fmt.Errorf("pub PC nil")
}
unordered := false
dc, err := r.pubPC.CreateDataChannel("sharing", &webrtc.DataChannelInit{Ordered: &unordered})
if err != nil {
return err
}
r.sharingDC = dc
sink := newSharingDCSink(dc, defaultSharingFPS)
keyframe := func() []byte {
return r.obf.EncodeKeepalive(0)
}
dc.OnOpen(func() {
r.logger.Debug("[ss] creator 'sharing' DC open, registering screenshare sub-tunnel")
sink.sendFrame(keyframe())
sub := rtc.NewVP8DataTunnel(nil, r.obf, r.logger)
sub.WriteFrame = sink.sendFrame
if r.mt == nil {
return
}
r.mt.AddSubTunnel(sub)
r.logger.Debug(fmt.Sprintf("[ss] screenshare sub-tunnel live, tracks=%d", r.mt.SubTunnelCount()))
})
dc.OnClose(func() { r.logger.Debug("[ss] creator 'sharing' DC closed") })
dc.OnError(func(e error) { r.logger.Warn(fmt.Sprintf("[ss] creator 'sharing' DC error: %v", e)) })
dc.OnMessage(func(m webrtc.DataChannelMessage) {
if len(m.Data) >= 2 && m.Data[0]&0xC0 == 0x80 && m.Data[1] >= 200 && m.Data[1] <= 206 {
if m.Data[1] == 206 {
sink.sendFrame(keyframe())
}
}
})
return nil
}
func (r *SFURelay) RemoveSharingDataChannel() {
if r.mt != nil {
r.mt.RemoveLastSubTunnel()
}
if r.sharingDC != nil {
r.sharingDC.Close()
r.sharingDC = nil
}
}
func (r *SFURelay) Init(iceServers []webrtc.ICEServer) error {
config := webrtc.Configuration{ICEServers: iceServers}
pubPC, err := NewPeerConnection(config)
@@ -49,15 +145,15 @@ func (r *SFURelay) Init(iceServers []webrtc.ICEServer) error {
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})
if videoTrx, err := pubPC.AddTransceiverFromTrack(r.sampleTrack, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}); err == nil {
go rtc.DrainSenderRTCP(videoTrx.Sender())
}
pubPC.OnICECandidate(func(cand *webrtc.ICECandidate) {
if cand == nil || r.OnPubICE == nil {
return
@@ -69,11 +165,11 @@ func (r *SFURelay) Init(iceServers []webrtc.ICEServer) error {
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 = rtc.NewVP8DataTunnel(r.sampleTrack, r.obf, r.logger)
r.tun.Start(0, 0)
if r.OnConnected != nil {
r.OnConnected(r.tun)
}
r.tunFired = false
r.mt = rtc.NewMultiTrackTunnel([]*rtc.VP8DataTunnel{r.tun})
r.mt.SetOnData(func(payload []byte) { r.activate(r.mt, payload) })
}
if r.OnPubReady != nil {
r.OnPubReady()
@@ -103,6 +199,53 @@ func (r *SFURelay) Init(iceServers []webrtc.ICEServer) error {
return nil
}
func (r *SFURelay) activate(mt *rtc.MultiTrackTunnel, payload []byte) {
r.mu.Lock()
if r.tunFired {
r.mu.Unlock()
return
}
r.tunFired = true
r.mu.Unlock()
var delivered tunnel.DataTunnel = mt
useKCP := false
if !tunnel.LooksLikeRelayFrame(payload) {
delivered = rtc.NewMultiTrackKCPTunnel(mt, r.logger)
useKCP = true
r.logger.Debug("[relay] per-track kcp reliability active over video tunnel")
}
r.logger.Debug(fmt.Sprintf("[relay] auto-detected active tunnel: %T", delivered))
r.mu.Lock()
r.delivered = delivered
r.mu.Unlock()
if r.OnConnected != nil {
r.OnConnected(delivered)
}
if useKCP {
if kcptun, ok := delivered.(*rtc.MultiTrackKCPTunnel); ok {
kcptun.InjectSegment(payload)
}
} else {
mt.DeliverData(payload)
}
}
func (r *SFURelay) resetForNewPeer() {
r.mu.Lock()
r.tunFired = false
mt := r.mt
old := r.delivered
r.delivered = nil
r.mu.Unlock()
if kcp, ok := old.(*rtc.MultiTrackKCPTunnel); ok {
kcp.StopLayer()
}
if mt != nil {
mt.SetOnData(func(payload []byte) { r.activate(mt, payload) })
}
}
func (r *SFURelay) CreatePubOffer() (webrtc.SessionDescription, error) {
offer, err := r.pubPC.CreateOffer(nil)
if err != nil {
@@ -174,6 +317,23 @@ func (r *SFURelay) AddSubICECandidate(cand webrtc.ICECandidateInit) {
r.subPC.AddICECandidate(cand)
}
func (r *SFURelay) CreatePubRenegotiate() (webrtc.SessionDescription, error) {
offer, err := r.pubPC.CreateOffer(&webrtc.OfferOptions{ICERestart: false})
if err != nil {
return offer, err
}
err = r.pubPC.SetLocalDescription(offer)
if err != nil {
return offer, err
}
offer.SDP = MungeSDPAddVideoContent(offer.SDP)
r.mu.Lock()
r.pubRemoteSet = false
r.pubPending = nil
r.mu.Unlock()
return offer, nil
}
func (r *SFURelay) Close() {
if r.tun != nil {
r.tun.Stop()
@@ -253,6 +413,7 @@ func (r *SFURelay) readTrack(track *webrtc.TrackRemote) {
}
if res.PeerRestart {
r.logger.Info(fmt.Sprintf("[video] peer restart detected, new epoch=0x%08x", res.PeerEpoch))
r.resetForNewPeer()
if r.OnPeerRestart != nil {
r.OnPeerRestart()
}
+3 -3
View File
@@ -29,7 +29,7 @@ const (
vp8InterframeLen = 17
epochFieldLen = 4
keepaliveHdrLen = vp8KeepaliveLen + epochFieldLen
interframeHdrLen = vp8InterframeLen + epochFieldLen
InterframeHdrLen = vp8InterframeLen + epochFieldLen
)
var ErrEmptySecret = errors.New("tunnel: obfuscator requires a non-empty secret")
@@ -150,7 +150,7 @@ func (o *TunnelObfuscator) Decode(frame []byte) DecodeResult {
epochOff = vp8KeepaliveLen
isKeepaliveFrame = true
case vp8Interframe[0]:
hdrLen = interframeHdrLen
hdrLen = InterframeHdrLen
epochOff = vp8InterframeLen
default:
return DecodeResult{}
@@ -214,7 +214,7 @@ func (o *TunnelObfuscator) keepaliveHeader() []byte {
}
func (o *TunnelObfuscator) dataHeader() []byte {
hdr := make([]byte, interframeHdrLen)
hdr := make([]byte, InterframeHdrLen)
copy(hdr, vp8Interframe)
binary.BigEndian.PutUint32(hdr[vp8InterframeLen:], o.localEpoch)
return hdr
+2
View File
@@ -12,6 +12,8 @@ const (
MsgUDPReply byte = 0x07
MsgConfig byte = 0x08
MsgConfigAck byte = 0x09
WireHeaderLen = 4 + 1
)
const ControlConnID uint32 = 0
+17
View File
@@ -44,6 +44,9 @@ type RelayBridge struct {
onPeerConfigMu sync.Mutex
onPeerConfig func(fps, batch, trackCount int)
onConfigAckMu sync.Mutex
onConfigAck func()
}
func NewRelayBridge(tunnel DataTunnel, mode string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) *RelayBridge {
@@ -78,6 +81,12 @@ func (rb *RelayBridge) SetOnPeerConfig(fn func(fps, batch, trackCount int)) {
rb.onPeerConfigMu.Unlock()
}
func (rb *RelayBridge) SetOnConfigAck(fn func()) {
rb.onConfigAckMu.Lock()
rb.onConfigAck = fn
rb.onConfigAckMu.Unlock()
}
func (rb *RelayBridge) DialContext(ctx context.Context, destination string) (net.Conn, error) {
if rb.closed.Load() {
return nil, fmt.Errorf("relay: bridge already closed")
@@ -216,6 +225,14 @@ func (rb *RelayBridge) handleTunnelData(data []byte) {
return
}
if connID == ControlConnID && msgType == MsgConfigAck {
if rb.mode == "joiner" {
rb.onConfigAckMu.Lock()
cb := rb.onConfigAck
rb.onConfigAckMu.Unlock()
if cb != nil {
cb()
}
}
return
}
switch rb.mode {
@@ -1,4 +1,4 @@
package tunnel
package rtc
import (
"encoding/binary"
@@ -8,9 +8,11 @@ import (
"sync"
"sync/atomic"
"github.com/pion/datachannel"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing/common/logger"
"github.com/kulikov0/headless-client/webrtc"
"github.com/pion/datachannel"
)
const chunkSize = 994
@@ -28,15 +30,15 @@ type DCTunnel struct {
logger logger.ContextLogger
onData func([]byte)
onClose func()
obf *TunnelObfuscator
obf *tunnel.TunnelObfuscator
chunked bool
readBuf int
recvBufs sync.Map
sendMsgID uint32
sendMsgID atomic.Uint32
}
func NewDCTunnel(dc *webrtc.DataChannel, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
func NewDCTunnel(dc *webrtc.DataChannel, obf *tunnel.TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
t := &DCTunnel{dc: dc, obf: obf, readBuf: readBuf, logger: logger}
raw, err := dc.Detach()
if err != nil {
@@ -56,13 +58,13 @@ func NewDCTunnel(dc *webrtc.DataChannel, obf *TunnelObfuscator, readBuf int, log
return t
}
func NewDCTunnelFromRaw(dc *webrtc.DataChannel, raw datachannel.ReadWriteCloser, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
func NewDCTunnelFromRaw(dc *webrtc.DataChannel, raw datachannel.ReadWriteCloser, obf *tunnel.TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
t := &DCTunnel{dc: dc, raw: raw, obf: obf, readBuf: readBuf, logger: logger}
go t.readLoop()
return t
}
func NewChunkedDCTunnel(readRaw datachannel.ReadWriteCloser, writeDC *webrtc.DataChannel, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
func NewChunkedDCTunnel(readRaw datachannel.ReadWriteCloser, writeDC *webrtc.DataChannel, obf *tunnel.TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
writeRaw, err := writeDC.Detach()
if err != nil {
logger.Error(fmt.Sprintf("dctunnel: write DC detach failed: %v", err))
@@ -73,7 +75,7 @@ func NewChunkedDCTunnel(readRaw datachannel.ReadWriteCloser, writeDC *webrtc.Dat
return t
}
func NewChunkedDCTunnelFromRaw(readRaw, writeRaw datachannel.ReadWriteCloser, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
func NewChunkedDCTunnelFromRaw(readRaw, writeRaw datachannel.ReadWriteCloser, obf *tunnel.TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
t := &DCTunnel{raw: readRaw, writeRaw: writeRaw, obf: obf, readBuf: readBuf, logger: logger, chunked: true}
go t.readLoop()
return t
@@ -194,13 +196,10 @@ func (t *DCTunnel) sendChunked(data []byte) {
if total == 0 {
total = 1
}
id := uint16(atomic.AddUint32(&t.sendMsgID, 1)) & 0xFFFF
id := uint16(t.sendMsgID.Add(1)) & 0xFFFF
for i := 0; i < total; i++ {
start := i * chunkSize
end := start + chunkSize
if end > len(data) {
end = len(data)
}
end := min(start+chunkSize, len(data))
p := data[start:end]
f := make([]byte, 6+len(p))
f[0] = byte(id >> 8)
@@ -1,9 +1,11 @@
package tunnel
package rtc
import (
"context"
"io"
"testing"
"github.com/sagernet/sing-box/transport/call/tunnel"
)
type discardRawConn struct{}
@@ -43,7 +45,7 @@ func BenchmarkDCTunnelSendData(b *testing.B) {
sizes := []int{64, 512, 4096}
for _, size := range sizes {
payload := make([]byte, size)
frame := EncodeFrame(42, MsgData, payload)
frame := tunnel.EncodeFrame(42, tunnel.MsgData, payload)
b.Run(sizeLabel(size), func(b *testing.B) {
t := newBenchDCTunnel()
b.ReportAllocs()
+23
View File
@@ -0,0 +1,23 @@
package rtc
import (
"github.com/sagernet/sing-box/transport/call/common"
"github.com/kulikov0/headless-client/webrtc"
)
func DrainSenderRTCP(sender *webrtc.RTPSender) {
common.DrainSenderRTCP(sender)
}
func DrainTrack(track *webrtc.TrackRemote) {
if track == nil {
return
}
buf := make([]byte, common.UDPBufSize)
for {
if _, _, err := track.Read(buf); err != nil {
return
}
}
}
@@ -1,4 +1,4 @@
package tunnel
package rtc
import (
"encoding/binary"
@@ -7,19 +7,22 @@ import (
"sync/atomic"
"time"
kcp "github.com/xtaci/kcp-go/v5"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing/common/logger"
kcp "github.com/xtaci/kcp-go/v5"
)
const (
kcpConvBase = 0x77627374
kcpUpdateInterval = 10 * time.Millisecond
kcpConvBase = 0x77627374
kcpUpdateInterval = 10 * time.Millisecond
kcpIdleUpdateInterval = 500 * time.Millisecond
kcpIdleAfterTicks = 50
// One KCP segment must ride in a single RTP packet so a dropped packet
// loses only its own frame, not a two-packet frame that readVP8Track
// would discard whole. 1200 RTP budget - 1 VP8 descriptor - interframe
// header - 24 XChaCha20 nonce - 16 Poly1305 tag - 1 channel tag.
kcpSegmentMTU = 1200 - 1 - interframeHdrLen - 24 - 16 - 1
kcpSegmentMTU = 1200 - 1 - tunnel.InterframeHdrLen - 24 - 16 - 1
kcpReceiveBufSize = 128 * 1024
kcpStatsEvery = 500
@@ -147,6 +150,7 @@ type MultiTrackKCPTunnel struct {
stopCh chan struct{}
stopOnce sync.Once
nudge chan struct{}
currentWindow atomic.Int32
@@ -166,6 +170,7 @@ func NewMultiTrackKCPTunnel(mt *MultiTrackTunnel, logger logger.ContextLogger) *
convMap: make(map[uint32]*trackKCPSession),
connPin: make(map[uint32]int),
stopCh: make(chan struct{}),
nudge: make(chan struct{}, 1),
}
subs := mt.SubTunnels()
window := kcpWindowFloor
@@ -195,10 +200,11 @@ func (t *MultiTrackKCPTunnel) SendData(frame []byte) {
connID := binary.BigEndian.Uint32(frame[4:8])
msgType := frame[8]
if msgType == MsgUDP || msgType == MsgUDPReply {
if msgType == tunnel.MsgUDP || msgType == tunnel.MsgUDPReply {
t.sendRaw(connID, frame)
return
}
t.wake()
t.mu.Lock()
if len(t.sessions) == 0 {
@@ -213,7 +219,7 @@ func (t *MultiTrackKCPTunnel) SendData(frame []byte) {
session := t.sessions[index]
t.mu.Unlock()
if msgType == MsgData {
if msgType == tunnel.MsgData {
sndCap := int(t.currentWindow.Load()) * kcpWaitSndFactor
for session.waitSnd() >= sndCap {
select {
@@ -227,7 +233,7 @@ func (t *MultiTrackKCPTunnel) SendData(frame []byte) {
t.sentMessages.Add(1)
session.send(frame)
if msgType == MsgClose {
if msgType == tunnel.MsgClose {
t.mu.Lock()
delete(t.connPin, connID)
t.mu.Unlock()
@@ -286,6 +292,7 @@ func (t *MultiTrackKCPTunnel) handleDecodedSegment(payload []byte) {
return
}
t.inputSegments.Add(1)
t.wake()
messages := session.input(body)
if callback == nil {
return
@@ -369,21 +376,52 @@ func (t *MultiTrackKCPTunnel) handleInnerClose() {
}
}
func (t *MultiTrackKCPTunnel) wake() {
select {
case t.nudge <- struct{}{}:
default:
}
}
func (t *MultiTrackKCPTunnel) updateLoop() {
ticker := time.NewTicker(kcpUpdateInterval)
defer ticker.Stop()
ticks := 0
idleTicks := 0
fast := true
for {
select {
case <-t.stopCh:
return
case <-t.nudge:
if !fast {
fast = true
idleTicks = 0
ticker.Reset(kcpUpdateInterval)
}
case <-ticker.C:
t.mu.Lock()
sessions := make([]*trackKCPSession, len(t.sessions))
copy(sessions, t.sessions)
t.mu.Unlock()
pending := 0
for _, session := range sessions {
session.update()
pending += session.waitSnd()
}
if pending > 0 {
idleTicks = 0
if !fast {
fast = true
ticker.Reset(kcpUpdateInterval)
}
} else if fast {
idleTicks++
if idleTicks >= kcpIdleAfterTicks {
fast = false
idleTicks = 0
ticker.Reset(kcpIdleUpdateInterval)
}
}
ticks++
if ticks%kcpStatsEvery == 0 && t.logger != nil {
@@ -1,4 +1,4 @@
package tunnel
package rtc
import (
"encoding/binary"
@@ -1,4 +1,4 @@
package tunnel
package rtc
import (
"encoding/binary"
@@ -8,6 +8,7 @@ import (
"time"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing/common/logger"
)
@@ -20,7 +21,7 @@ const (
)
type ScreenWriter struct {
obf *TunnelObfuscator
obf *tunnel.TunnelObfuscator
logger logger.ContextLogger
label string
@@ -39,7 +40,7 @@ type ScreenWriter struct {
sent atomic.Uint64
}
func NewScreenWriter(obf *TunnelObfuscator, label string, logger logger.ContextLogger) *ScreenWriter {
func NewScreenWriter(obf *tunnel.TunnelObfuscator, label string, logger logger.ContextLogger) *ScreenWriter {
return &ScreenWriter{
obf: obf,
logger: logger,
@@ -121,10 +122,7 @@ func (w *ScreenWriter) interval() time.Duration {
}
func (w *ScreenWriter) nextKeepalive(sample time.Duration) (ticks, padLen int) {
ticks = int(common.DurationInRange(keepaliveIdleMin, keepaliveIdleMax) / sample)
if ticks < 1 {
ticks = 1
}
ticks = max(int(common.DurationInRange(keepaliveIdleMin, keepaliveIdleMax)/sample), 1)
return ticks, common.IntInRange(0, screenKeepalivePadMax)
}
@@ -184,7 +182,7 @@ func (w *ScreenWriter) writerLoop() {
type SymmetricScreenTunnel struct {
cam *VP8DataTunnel
screen *ScreenWriter
obf *TunnelObfuscator
obf *tunnel.TunnelObfuscator
logger logger.ContextLogger
screenReady func() bool
@@ -194,7 +192,7 @@ type SymmetricScreenTunnel struct {
trackCount atomic.Int32
}
func NewSymmetricScreenTunnel(cam *VP8DataTunnel, screen *ScreenWriter, obf *TunnelObfuscator, screenReady func() bool, logger logger.ContextLogger) *SymmetricScreenTunnel {
func NewSymmetricScreenTunnel(cam *VP8DataTunnel, screen *ScreenWriter, obf *tunnel.TunnelObfuscator, screenReady func() bool, logger logger.ContextLogger) *SymmetricScreenTunnel {
return &SymmetricScreenTunnel{cam: cam, screen: screen, obf: obf, screenReady: screenReady, logger: logger}
}
@@ -219,14 +217,11 @@ func (s *SymmetricScreenTunnel) SendData(data []byte) {
if len(data) >= 8 {
connID = binary.BigEndian.Uint32(data[4:8])
}
if connID == ControlConnID {
if connID == tunnel.ControlConnID {
s.cam.SendData(data)
return
}
tc := uint32(s.trackCount.Load())
if tc < 1 {
tc = 1
}
tc := max(uint32(s.trackCount.Load()), 1)
if connID%tc == 1 && s.screenUp() {
s.screen.SendData(data)
return
@@ -1,4 +1,4 @@
package tunnel
package rtc
import (
"fmt"
@@ -6,10 +6,12 @@ import (
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing/common/logger"
"github.com/kulikov0/headless-client/webrtc"
"github.com/kulikov0/headless-client/webrtc/pkg/media"
)
const (
@@ -23,12 +25,14 @@ const (
paceBatchFloorPercent = 80
paceDriftMin = 5 * time.Second
paceDriftMax = 20 * time.Second
idleSpinTicks = 40
)
type VP8DataTunnel struct {
track *webrtc.TrackLocalStaticSample
logger logger.ContextLogger
obf *TunnelObfuscator
obf *tunnel.TunnelObfuscator
stopCh chan struct{}
sendQueue chan []byte
cfgChan chan struct{}
@@ -50,17 +54,19 @@ type VP8DataTunnel struct {
OnData func([]byte)
OnClose func()
OnPeerRestart func()
WriteFrame func([]byte) error
}
func (t *VP8DataTunnel) SetOnData(fn func([]byte)) { t.OnData = fn }
func (t *VP8DataTunnel) SetOnClose(fn func()) { t.OnClose = fn }
func (t *VP8DataTunnel) SetOnPeerRestart(fn func()) { t.OnPeerRestart = fn }
func NewVP8DataTunnel(track *webrtc.TrackLocalStaticSample, obf *TunnelObfuscator, logger logger.ContextLogger) *VP8DataTunnel {
func NewVP8DataTunnel(track *webrtc.TrackLocalStaticSample, obf *tunnel.TunnelObfuscator, logger logger.ContextLogger) *VP8DataTunnel {
return NewVP8DataTunnelWithQueue(track, obf, logger, sendQueueDepth)
}
func NewVP8DataTunnelWithQueue(track *webrtc.TrackLocalStaticSample, obf *TunnelObfuscator, logger logger.ContextLogger, queueDepth int) *VP8DataTunnel {
func NewVP8DataTunnelWithQueue(track *webrtc.TrackLocalStaticSample, obf *tunnel.TunnelObfuscator, logger logger.ContextLogger, queueDepth int) *VP8DataTunnel {
if queueDepth < sendQueueDepth {
queueDepth = sendQueueDepth
}
@@ -79,30 +85,11 @@ func NewVP8DataTunnelWithQueue(track *webrtc.TrackLocalStaticSample, obf *Tunnel
}
}
func (t *VP8DataTunnel) SetKeepaliveShape(minPeriod, maxPeriod time.Duration, padMax int) {
t.cfgMu.Lock()
if minPeriod > 0 {
t.keepaliveMin = minPeriod
}
if maxPeriod >= t.keepaliveMin {
t.keepaliveMax = maxPeriod
}
if padMax >= 0 {
t.keepalivePadMax = padMax
}
newMin, newMax, newPad := t.keepaliveMin, t.keepaliveMax, t.keepalivePadMax
t.cfgMu.Unlock()
t.logger.Debug(fmt.Sprintf("vp8tunnel: keepalive shape min=%s max=%s padMax=%d", newMin, newMax, newPad))
}
func (t *VP8DataTunnel) nextKeepalive(sampleInterval time.Duration) (ticks, padLen int) {
t.cfgMu.Lock()
minPeriod, maxPeriod, padMax := t.keepaliveMin, t.keepaliveMax, t.keepalivePadMax
t.cfgMu.Unlock()
ticks = int(common.DurationInRange(minPeriod, maxPeriod) / sampleInterval)
if ticks < 1 {
ticks = 1
}
ticks = max(int(common.DurationInRange(minPeriod, maxPeriod)/sampleInterval), 1)
return ticks, common.IntInRange(0, padMax)
}
@@ -244,10 +231,7 @@ func pacedBatchFor(batch int) int {
if batch <= 1 {
return batch
}
floor := batch * paceBatchFloorPercent / 100
if floor < 1 {
floor = 1
}
floor := max(batch*paceBatchFloorPercent/100, 1)
return common.IntInRange(floor, batch)
}
@@ -262,58 +246,111 @@ func (t *VP8DataTunnel) writerLoop() {
ticker := time.NewTicker(sampleInterval)
drift := time.NewTimer(common.DurationInRange(paceDriftMin, paceDriftMax))
idle := time.NewTimer(time.Hour)
if !idle.Stop() {
<-idle.C
}
idleTicks := 0
spinning := true
reconfigure := false
emit := func(sample []byte, isKeepalive bool) {
if sample == nil {
return
}
if t.WriteFrame != nil {
if err := t.WriteFrame(sample); err != nil {
t.logger.Debug(fmt.Sprintf("vp8tunnel: WriteFrame error: %v", err))
return
}
} else if err := t.track.WriteSample(media.Sample{Data: sample, Duration: sampleInterval}); err != nil {
t.logger.Debug(fmt.Sprintf("vp8tunnel: WriteSample error: %v", err))
return
}
n := t.sentFrames.Add(1)
if isKeepalive {
t.keepaliveFrames.Add(1)
}
if n <= 5 || n%500 == 0 {
keepalives := t.keepaliveFrames.Load()
t.logger.Debug(fmt.Sprintf("vp8tunnel: sent frame #%d size=%d data=%d keepalive=%d", n, len(sample), n-keepalives, keepalives))
}
}
repace := func() {
pacedBatch = pacedBatchFor(batch)
sampleInterval = sampleIntervalFor(fps, pacedBatch)
if spinning {
ticker.Reset(sampleInterval)
}
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
drift.Reset(common.DurationInRange(paceDriftMin, paceDriftMax))
t.logger.Debug(fmt.Sprintf("vp8tunnel: pace drift pacedBatch=%d/%d sampleInterval=%s", pacedBatch, batch, sampleInterval))
}
for !reconfigure {
if spinning {
select {
case <-t.stopCh:
ticker.Stop()
drift.Stop()
idle.Stop()
return
case <-t.cfgChan:
reconfigure = true
case <-drift.C:
repace()
case <-ticker.C:
select {
case data := <-t.sendQueue:
emit(t.obf.EncodeData(data), false)
idleTicks = 0
default:
idleTicks++
switch {
case idleTicks >= keepaliveEvery:
idleTicks = 0
emit(t.obf.EncodeKeepalive(keepalivePad), true)
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
case idleTicks >= idleSpinTicks:
ticker.Stop()
spinning = false
idle.Reset(time.Duration(keepaliveEvery-idleTicks) * sampleInterval)
}
}
}
continue
}
select {
case <-t.stopCh:
ticker.Stop()
drift.Stop()
idle.Stop()
return
case <-t.cfgChan:
reconfigure = true
case <-drift.C:
pacedBatch = pacedBatchFor(batch)
sampleInterval = sampleIntervalFor(fps, pacedBatch)
ticker.Reset(sampleInterval)
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
drift.Reset(common.DurationInRange(paceDriftMin, paceDriftMax))
t.logger.Debug(fmt.Sprintf("vp8tunnel: pace drift pacedBatch=%d/%d sampleInterval=%s", pacedBatch, batch, sampleInterval))
case <-ticker.C:
var sample []byte
isKeepalive := false
select {
case data := <-t.sendQueue:
sample = t.obf.EncodeData(data)
idleTicks = 0
default:
idleTicks++
if idleTicks < keepaliveEvery {
continue
repace()
case data := <-t.sendQueue:
if !idle.Stop() {
select {
case <-idle.C:
default:
}
idleTicks = 0
sample = t.obf.EncodeKeepalive(keepalivePad)
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
isKeepalive = true
}
if sample == nil {
continue
}
if err := t.track.WriteSample(media.Sample{Data: sample, Duration: sampleInterval}); err != nil {
t.logger.Debug(fmt.Sprintf("vp8tunnel: WriteSample error: %v", err))
continue
}
n := t.sentFrames.Add(1)
if isKeepalive {
t.keepaliveFrames.Add(1)
}
if n <= 5 || n%500 == 0 {
keepalives := t.keepaliveFrames.Load()
t.logger.Debug(fmt.Sprintf("vp8tunnel: sent frame #%d size=%d data=%d keepalive=%d", n, len(sample), n-keepalives, keepalives))
}
emit(t.obf.EncodeData(data), false)
idleTicks = 0
spinning = true
ticker.Reset(sampleInterval)
case <-idle.C:
idleTicks = 0
emit(t.obf.EncodeKeepalive(keepalivePad), true)
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
idle.Reset(time.Duration(keepaliveEvery) * sampleInterval)
}
}
ticker.Stop()
drift.Stop()
idle.Stop()
}
}
+78
View File
@@ -0,0 +1,78 @@
package tunnel
import (
"encoding/binary"
"sync"
)
type WSTunnel struct {
mu sync.RWMutex
sendFn func([]byte)
onData func([]byte)
onClose func()
}
func NewWSTunnel() *WSTunnel {
return &WSTunnel{}
}
func (t *WSTunnel) SetSendFn(sendFn func([]byte)) {
t.mu.Lock()
t.sendFn = sendFn
t.mu.Unlock()
}
func (t *WSTunnel) SendData(data []byte) {
t.mu.RLock()
sendFn := t.sendFn
t.mu.RUnlock()
if sendFn == nil {
return
}
DecodeFrames(data, func(connID uint32, msgType byte, payload []byte) {
msg := make([]byte, WireHeaderLen+len(payload))
binary.BigEndian.PutUint32(msg[0:4], connID)
msg[4] = msgType
copy(msg[WireHeaderLen:], payload)
sendFn(msg)
})
}
func (t *WSTunnel) Deliver(msg []byte) {
if len(msg) < WireHeaderLen {
return
}
t.mu.RLock()
onData := t.onData
t.mu.RUnlock()
if onData == nil {
return
}
frame := make([]byte, 4+len(msg))
binary.BigEndian.PutUint32(frame[0:4], uint32(len(msg)))
copy(frame[4:], msg)
onData(frame)
}
func (t *WSTunnel) NotifyClose() {
t.mu.RLock()
onClose := t.onClose
t.mu.RUnlock()
if onClose != nil {
onClose()
}
}
func (t *WSTunnel) SetOnData(fn func([]byte)) {
t.mu.Lock()
t.onData = fn
t.mu.Unlock()
}
func (t *WSTunnel) SetOnClose(fn func()) {
t.mu.Lock()
t.onClose = fn
t.mu.Unlock()
}
func (t *WSTunnel) Reconfigure(fps, batch int) {}
+15 -9
View File
@@ -11,6 +11,8 @@ import (
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
headless "github.com/kulikov0/headless-client"
)
type TurnServer struct {
@@ -31,6 +33,7 @@ type CallInfo struct {
TurnServer TurnServer
StunServer StunServer
WSEndpoint string
WtEndpoint string
}
type vkTokenResponse struct {
@@ -60,6 +63,7 @@ type okAuthResponse struct {
type joinResponse struct {
Endpoint string `json:"endpoint"`
WtEndpoint string `json:"wt_endpoint"`
TurnServer TurnServer `json:"turn_server"`
StunServer StunServer `json:"stun_server"`
}
@@ -83,6 +87,7 @@ func JoinExistingCall(dialer N.Dialer, cookieStr, vkLink string, cfg VKConfig, l
TurnServer: resp.TurnServer,
StunServer: resp.StunServer,
WSEndpoint: resp.Endpoint,
WtEndpoint: resp.WtEndpoint,
}, nil
}
@@ -94,7 +99,7 @@ func CreateAndJoinCall(dialer N.Dialer, cookieStr, peerId string, cfg VKConfig,
return map[string]string{"Authorization": "Bearer " + bearer}
}
logger.Info("[auth] Getting VK token...")
r, err := httpPost(dialer, "https://login.vk.com/?act=web_token",
r, err := httpPost(dialer, "https://login.vk.ru/?act=web_token",
url.Values{"version": {"1"}, "app_id": {cfg.AppID}},
map[string]string{"Cookie": cookieStr})
if err != nil {
@@ -107,7 +112,7 @@ func CreateAndJoinCall(dialer N.Dialer, cookieStr, peerId string, cfg VKConfig,
return nil, fmt.Errorf("empty VK token, response: %s", string(r))
}
logger.Info(fmt.Sprintf("[auth] Creating call peer_id=%s...", peerId))
r, err = httpPost(dialer, "https://api.vk.com/method/calls.start",
r, err = httpPost(dialer, "https://api.vk.ru/method/calls.start",
url.Values{"v": {cfg.APIVersion}, "peer_id": {peerId}}, auth(vkToken))
if err != nil {
return nil, fmt.Errorf("calls.start: %w", err)
@@ -141,6 +146,7 @@ func CreateAndJoinCall(dialer N.Dialer, cookieStr, peerId string, cfg VKConfig,
CallID: c.CallID, JoinLink: c.JoinLink, ShortLink: c.ShortCredentials.LinkWithPassword,
OKJoinLink: c.OKJoinLink, TurnServer: resp.TurnServer, StunServer: resp.StunServer,
WSEndpoint: resp.Endpoint,
WtEndpoint: resp.WtEndpoint,
}, nil
}
@@ -169,7 +175,7 @@ func authAndJoin(dialer N.Dialer, cookieStr, okJoinLink string, cfg VKConfig) (*
auth := func(bearer string) map[string]string {
return map[string]string{"Authorization": "Bearer " + bearer}
}
r, err := httpPost(dialer, "https://login.vk.com/?act=web_token",
r, err := httpPost(dialer, "https://login.vk.ru/?act=web_token",
url.Values{"version": {"1"}, "app_id": {cfg.AppID}},
map[string]string{"Cookie": cookieStr})
if err != nil {
@@ -180,7 +186,7 @@ func authAndJoin(dialer N.Dialer, cookieStr, okJoinLink string, cfg VKConfig) (*
if tok.Data.AccessToken == "" {
return nil, fmt.Errorf("empty VK token, response: %s", string(r))
}
r, err = httpPost(dialer, "https://api.vk.com/method/calls.getSettings",
r, err = httpPost(dialer, "https://api.vk.ru/method/calls.getSettings",
url.Values{"v": {cfg.APIVersion}}, auth(tok.Data.AccessToken))
if err != nil {
return nil, fmt.Errorf("calls.getSettings: %w", err)
@@ -191,7 +197,7 @@ func authAndJoin(dialer N.Dialer, cookieStr, okJoinLink string, cfg VKConfig) (*
if appKey == "" {
return nil, fmt.Errorf("empty public_key, response: %s", string(r))
}
r, err = httpPost(dialer, "https://api.vk.com/method/messages.getCallToken",
r, err = httpPost(dialer, "https://api.vk.ru/method/messages.getCallToken",
url.Values{"v": {cfg.APIVersion}, "env": {"production"}}, auth(tok.Data.AccessToken))
if err != nil {
return nil, fmt.Errorf("messages.getCallToken: %w", err)
@@ -208,7 +214,7 @@ func authAndJoin(dialer N.Dialer, cookieStr, okJoinLink string, cfg VKConfig) (*
if !strings.HasSuffix(apiBaseURL, "/fb.do") {
apiBaseURL += "/fb.do"
}
sd, _ := json.Marshal(map[string]interface{}{
sd, _ := json.Marshal(map[string]any{
"device_id": "sing-box-go-1", "client_version": cfg.AppVersion,
"client_type": "SDK_JS", "auth_token": callToken.Response.Token, "version": 3,
})
@@ -269,9 +275,9 @@ func httpPost(dialer N.Dialer, endpoint string, form url.Values, extraHeaders ma
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", common.UserAgent)
req.Header.Set("Origin", "https://vk.com")
req.Header.Set("Referer", "https://vk.com/")
req.Header.Set("User-Agent", headless.ChromeWindows.UserAgent())
req.Header.Set("Origin", "https://vk.ru")
req.Header.Set("Referer", "https://vk.ru/")
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
+16 -8
View File
@@ -9,11 +9,12 @@ import (
"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-box/transport/call/tunnel/rtc"
"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) {
func ConnectCreator(ctx context.Context, cookieStr, joinLink string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
cfg, err := FetchConfig(logger)
if err != nil {
return nil, "", err
@@ -35,9 +36,10 @@ func ConnectCreator(ctx context.Context, cookieStr, joinLink string, readBuf int
return nil, "", fmt.Errorf("vk: obfuscator init: %w", err)
}
bridge := &Bridge{
dialer: dialer,
readBuf: readBuf,
logger: logger,
dialer: dialer,
dnsRouter: dnsRouter,
readBuf: readBuf,
logger: logger,
}
bridge.newRelay = func() Relay {
ur := NewTunnelRelay(dialer, logger)
@@ -45,12 +47,12 @@ func ConnectCreator(ctx context.Context, cookieStr, joinLink string, readBuf int
ur.SetObfuscator(obf)
ur.OnConnected = func(tun tunnel.DataTunnel) {
bridgeReadBuf := common.VP8BufSize
if _, ok := tun.(*tunnel.DCTunnel); ok {
if _, ok := tun.(*rtc.DCTunnel); ok {
bridgeReadBuf = readBuf
}
rb := tunnel.NewRelayBridge(tun, "creator", bridgeReadBuf, dialer, logger)
rb.MarkReady()
if st, ok := tun.(*tunnel.SymmetricScreenTunnel); ok {
if st, ok := tun.(*rtc.SymmetricScreenTunnel); ok {
rb.SetOnPeerConfig(func(fps, batch, trackCount int) {
st.SetTrackCount(trackCount)
bridge.setScreenSharing(trackCount > 1)
@@ -82,10 +84,13 @@ func ConnectCreator(ctx context.Context, cookieStr, joinLink string, readBuf int
}
}
func ConnectJoiner(ctx context.Context, joinLink, displayName string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
func ConnectJoiner(ctx context.Context, joinLink, displayName string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (*tunnel.RelayBridge, error) {
if displayName == "" {
displayName = "Joiner"
}
if readBuf <= 0 {
readBuf = 32768
}
authJSON, err := RunVKAuth(dialer, joinLink, displayName, logger)
if err != nil {
return nil, fmt.Errorf("vk: auth: %w", err)
@@ -117,7 +122,10 @@ func ConnectJoiner(ctx context.Context, joinLink, displayName string, readBuf in
go joiner.RunWithParams(string(paramsJSON))
select {
case tun := <-tunCh:
return tun, nil
rb := tunnel.NewRelayBridge(tun, "joiner", readBuf, dialer, logger)
rb.SetOnConfigAck(joiner.MarkConfigAcked)
rb.MarkReady()
return rb, nil
case <-ctx.Done():
joiner.Close()
return nil, ctx.Err()
+71 -85
View File
@@ -5,26 +5,27 @@ import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"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-box/transport/call/wtsignal"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/kulikov0/headless-client/webrtc"
)
const topologyDirect = "DIRECT"
const maxServerBounces = 5
type Bridge struct {
mu sync.Mutex
vkWs *websocket.Conn
sfu *wtsignal.Conn
vkSeq int
iceServers []webrtc.ICEServer
topology string
@@ -34,11 +35,8 @@ type Bridge struct {
p2p *P2PHandler
screenSharing bool
serverBounces int
suppressScreenshare bool
bouncing bool
dialer N.Dialer
dnsRouter adapter.DNSRouter
activeBridge *tunnel.RelayBridge
readBuf int
logger logger.ContextLogger
@@ -46,25 +44,20 @@ type Bridge struct {
func (b *Bridge) setScreenSharing(enabled bool) {
b.mu.Lock()
if b.vkWs == nil || b.screenSharing == enabled {
if b.sfu == nil || b.screenSharing == enabled {
b.mu.Unlock()
return
}
if enabled && b.suppressScreenshare {
b.mu.Unlock()
b.logger.Debug("[vk-ws] screenshare suppressed after SERVER flap, staying single-track DIRECT")
return
}
b.screenSharing = enabled
b.mu.Unlock()
b.logger.Debug(fmt.Sprintf("[vk-ws] peer track count change, screenshare=%v", enabled))
b.sendMediaSettings(enabled)
}
func (b *Bridge) vkSend(command string, extra map[string]interface{}) {
func (b *Bridge) vkSend(command string, extra map[string]any) {
b.mu.Lock()
defer b.mu.Unlock()
if b.vkWs == nil {
if b.sfu == nil {
return
}
b.vkSeq++
@@ -79,13 +72,13 @@ func (b *Bridge) vkSend(command string, extra map[string]interface{}) {
extra["sequence"] = seq
out, _ = json.Marshal(extra)
}
b.vkWs.WriteMessage(websocket.TextMessage, out)
b.sfu.Send(out)
b.logger.Debug(fmt.Sprintf("[vk-ws] -> %s", command))
}
func (b *Bridge) sendMediaSettings(screenSharing bool) {
b.vkSend("change-media-settings", map[string]interface{}{
"mediaSettings": map[string]interface{}{
b.vkSend("change-media-settings", map[string]any{
"mediaSettings": map[string]any{
"isAudioEnabled": false, "isVideoEnabled": true,
"isScreenSharingEnabled": screenSharing, "isFastScreenSharingEnabled": false,
"isAudioSharingEnabled": false, "isAnimojiEnabled": false,
@@ -94,7 +87,7 @@ func (b *Bridge) sendMediaSettings(screenSharing bool) {
}
func (b *Bridge) handleVKMessage(raw []byte) {
var msg map[string]interface{}
var msg map[string]any
if err := json.Unmarshal(raw, &msg); err != nil {
return
}
@@ -105,9 +98,13 @@ func (b *Bridge) handleVKMessage(raw []byte) {
b.logger.Debug(fmt.Sprintf("[vk-ws] <- notification: %s", notif))
switch notif {
case "connection":
if conv, ok := msg["conversation"].(map[string]any); ok {
topo, _ := conv["topology"].(string)
b.logger.Debug(fmt.Sprintf("[vk-ws] connection topology=%q", topo))
}
b.logger.Debug("[vk-ws] TURN creds received")
case "transmitted-data":
data, _ := msg["data"].(map[string]interface{})
data, _ := msg["data"].(map[string]any)
if data != nil && b.topology == topologyDirect && b.p2p != nil {
b.p2p.OnTransmittedData(data)
}
@@ -121,7 +118,7 @@ func (b *Bridge) handleVKMessage(raw []byte) {
b.logger.Debug(fmt.Sprintf("[vk-ws] Topology changed to %s", topo))
b.topology = topo
if topo != topologyDirect {
b.bounceForServerTopology("SERVER topology")
b.failForServerTopology("SERVER topology")
return
}
case "participant-joined", "participant-added":
@@ -129,7 +126,7 @@ func (b *Bridge) handleVKMessage(raw []byte) {
b.peers[int64(pid)] = struct{}{}
b.logger.Debug(fmt.Sprintf("[vk-ws] Participant %d joined (total: %d)", int64(pid), len(b.peers)))
if b.topology != topologyDirect {
b.bounceForServerTopology("participant joined under SERVER")
b.failForServerTopology("participant joined under SERVER")
return
}
}
@@ -149,8 +146,8 @@ func (b *Bridge) handleVKMessage(raw []byte) {
reason, _ := msg["reason"].(string)
b.logger.Debug(fmt.Sprintf("[vk-ws] Conversation closed: %s", reason))
b.mu.Lock()
if b.vkWs != nil {
b.vkWs.Close()
if b.sfu != nil {
b.sfu.Close()
}
b.mu.Unlock()
default:
@@ -174,27 +171,43 @@ func (b *Bridge) handleVKMessage(raw []byte) {
}
}
func (b *Bridge) connectVKWs(wsURL string) error {
vkHeader := http.Header{}
vkHeader.Set("User-Agent", common.UserAgent)
vkHeader.Set("Origin", "https://vk.com")
vkDialer := websocket.Dialer{
WriteBufferSize: common.RTPBufSize,
NetDialContext: b.dialContext,
func (b *Bridge) connectVKWs(wtURL string) error {
parsed, err := url.Parse(wtURL)
if err != nil {
return err
}
vkWs, _, err := vkDialer.Dial(wsURL, vkHeader)
host := parsed.Hostname()
resolvedIP, err := b.resolveHost(host)
if err != nil {
return fmt.Errorf("resolve %s: %w", host, err)
}
sfu, err := wtsignal.Dial(wtURL, host, resolvedIP, vkOrigin)
if err != nil {
return err
}
b.mu.Lock()
b.vkWs = vkWs
b.sfu = sfu
b.vkSeq = 0
b.mu.Unlock()
return nil
}
func (b *Bridge) dialContext(ctx context.Context, network, addr string) (net.Conn, error) {
return b.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
func (b *Bridge) resolveHost(host string) (string, error) {
if ip := net.ParseIP(host); ip != nil {
return host, nil
}
rd, hasRD := b.dialer.(dialer.ResolveDialer)
if b.dnsRouter == nil || !hasRD {
return "", fmt.Errorf("no DNS router available to resolve %s", host)
}
addrs, err := b.dnsRouter.Lookup(context.Background(), host, rd.QueryOptions())
if err != nil {
return "", err
}
if len(addrs) == 0 {
return "", fmt.Errorf("no addresses for %s", host)
}
return addrs[0].String(), nil
}
func (b *Bridge) initRelay() {
@@ -208,41 +221,24 @@ func (b *Bridge) initRelay() {
b.p2p.Init()
}
func (b *Bridge) bounceForServerTopology(reason string) {
b.mu.Lock()
if b.bouncing {
b.mu.Unlock()
return
}
b.bouncing = true
b.serverBounces++
count := b.serverBounces
if count > maxServerBounces {
b.suppressScreenshare = true
}
suppress := b.suppressScreenshare
ws := b.vkWs
b.mu.Unlock()
if suppress {
b.logger.Debug(fmt.Sprintf("[vk-ws] %s -> reconnect #%d, suppressing screenshare to settle single-track DIRECT", reason, count))
} else {
b.logger.Debug(fmt.Sprintf("[vk-ws] %s -> manual reconnect #%d to recover DIRECT", reason, count))
}
if ws != nil {
ws.Close()
}
func (b *Bridge) failForServerTopology(reason string) {
b.logger.Error(fmt.Sprintf("[vk-ws] %s -> VK moved this call to server topology, it cannot be tunneled, create a new call and connect again", reason))
}
func (b *Bridge) readLoop() error {
b.mu.Lock()
sfu := b.sfu
b.mu.Unlock()
if sfu == nil {
return fmt.Errorf("no transport")
}
for {
_, msg, err := b.vkWs.ReadMessage()
msg, err := sfu.Recv()
if err != nil {
return err
}
if string(msg) == "ping" {
b.mu.Lock()
b.vkWs.WriteMessage(websocket.TextMessage, []byte("pong"))
b.mu.Unlock()
sfu.Send([]byte("pong"))
continue
}
b.handleVKMessage(msg)
@@ -253,32 +249,23 @@ func (b *Bridge) Run(callInfo *CallInfo, cookieStr string, cfg VKConfig) {
b.logger.Info(fmt.Sprintf("CALL CREATED join_link=%s turn=%s protocol=v%s sdk=%s",
callInfo.JoinLink, strings.Join(callInfo.TurnServer.URLs, ", "), cfg.ProtocolVersion, cfg.SDKVersion))
b.iceServers = buildWebRTCICEServers(BuildICEServers(callInfo))
wsEndpoint := callInfo.WSEndpoint
wtEndpoint := callInfo.WtEndpoint
capabilities := "2F7F"
makeWSURL := func(ep string) string {
makeWtURL := func(ep string) string {
return ep +
"&platform=WEB" +
"&appVersion=" + cfg.AppVersion +
"&version=" + cfg.ProtocolVersion +
"&device=browser&capabilities=" + capabilities + "&clientType=VK&tgt=join"
"&device=browser&capabilities=" + capabilities + "&clientType=VK&tgt=join&compression=deflate-raw"
}
go func() {
for {
time.Sleep(15 * time.Second)
b.mu.Lock()
ws := b.vkWs
b.mu.Unlock()
if ws != nil {
b.mu.Lock()
ws.WriteMessage(websocket.PingMessage, nil)
b.mu.Unlock()
}
}
}()
for {
b.initRelay()
if wtEndpoint == "" {
b.logger.Error("[vk-ws] no wt_endpoint in join response")
return
}
b.logger.Debug("[vk-ws] Connecting...")
if err := b.connectVKWs(makeWSURL(wsEndpoint)); err != nil {
if err := b.connectVKWs(makeWtURL(wtEndpoint)); err != nil {
b.logger.Warn(fmt.Sprintf("[vk-ws] Connect failed: %s, retrying in 5s...", common.MaskError(err)))
time.Sleep(5 * time.Second)
continue
@@ -286,13 +273,12 @@ func (b *Bridge) Run(callInfo *CallInfo, cookieStr string, cfg VKConfig) {
b.logger.Debug("[vk-ws] Connected")
b.mu.Lock()
b.screenSharing = false
b.bouncing = false
b.mu.Unlock()
b.sendMediaSettings(false)
err := b.readLoop()
b.logger.Debug(fmt.Sprintf("[vk-ws] Closed: %s", common.MaskError(err)))
b.mu.Lock()
b.vkWs = nil
b.sfu = nil
b.mu.Unlock()
b.logger.Debug("[vk-ws] Rejoining in 3s...")
time.Sleep(3 * time.Second)
@@ -302,7 +288,7 @@ func (b *Bridge) Run(callInfo *CallInfo, cookieStr string, cfg VKConfig) {
time.Sleep(5 * time.Second)
continue
}
wsEndpoint = joinResp.Endpoint
wtEndpoint = joinResp.WtEndpoint
callInfo.TurnServer = joinResp.TurnServer
callInfo.StunServer = joinResp.StunServer
b.iceServers = buildWebRTCICEServers(BuildICEServers(callInfo))
+62 -54
View File
@@ -2,7 +2,6 @@ package vk
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
@@ -15,15 +14,19 @@ import (
"sync/atomic"
"time"
"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/headlessapi"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing-box/transport/call/tunnel/rtc"
"github.com/sagernet/sing-box/transport/call/wtsignal"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/webrtc"
)
const (
@@ -32,6 +35,8 @@ const (
vkMaxReconnectAttempts = 10
)
const vkOrigin = "https://vk.ru"
const vkTopologyDirect = "DIRECT"
type vkAuthRottenError struct {
@@ -94,8 +99,8 @@ type VKJoiner struct {
pc *webrtc.PeerConnection
sampleTrack *webrtc.TrackLocalStaticSample
dc *webrtc.DataChannel
vp8tunnel *tunnel.VP8DataTunnel
sym *tunnel.SymmetricScreenTunnel
vp8tunnel *rtc.VP8DataTunnel
sym *rtc.SymmetricScreenTunnel
producerScreen screenUplink
obf *tunnel.TunnelObfuscator
vp8FPS int
@@ -165,8 +170,7 @@ func (h *VKJoiner) RunWithParams(jsonParams string) {
}
h.logger.Info(fmt.Sprintf("vk-joiner: reconnect attempt #%d", attempt))
if err := h.runOnce(); err != nil {
var authRotten *vkAuthRottenError
if errors.As(err, &authRotten) {
if _, ok := errors.AsType[*vkAuthRottenError](err); ok {
h.logger.Error(fmt.Sprintf("vk-joiner: %v, surrendering", err))
return
}
@@ -313,17 +317,17 @@ func (h *VKJoiner) joinCall() error {
}
client := &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{ServerName: parsed.Hostname()},
DialContext: h.dialContext,
},
Transport: headless.ChromeWindows.Transport(headless.TLSOptions{
ServerName: parsed.Hostname(),
DialContext: h.dialContext,
}),
}
req, err := http.NewRequest("POST", apiURL, strings.NewReader(body.Encode()))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", common.UserAgent)
req.Header.Set("User-Agent", headless.ChromeWindows.UserAgent())
h.logger.Debug("vk-joiner: calling joinConversationByLink...")
resp, err := client.Do(req)
if err != nil {
@@ -336,13 +340,13 @@ func (h *VKJoiner) joinCall() error {
}
var joinResp VKJoinResponse
if jsonErr := json.Unmarshal(raw, &joinResp); jsonErr != nil {
return fmt.Errorf("decode join response: %w (body: %s)", jsonErr, truncateBody(raw))
return fmt.Errorf("decode join response: %w (body: %s)", jsonErr, common.BodySnippet(raw))
}
if joinResp.Endpoint == "" {
if rotten := detectVKAuthRotten(raw); rotten != nil {
return rotten
}
return fmt.Errorf("empty endpoint in join response: %s", truncateBody(raw))
return fmt.Errorf("empty endpoint in join response: %s", common.BodySnippet(raw))
}
h.joinResp = &joinResp
h.logger.Debug(fmt.Sprintf("vk-joiner: joined, turn=%v", joinResp.TurnServer.URLs))
@@ -373,7 +377,7 @@ func (h *VKJoiner) connectSFU() {
"&appVersion=" + h.authParams.AppVersion +
"&version=" + h.authParams.ProtocolVersion +
"&device=browser&capabilities=" + capabilities + "&clientType=VK&tgt=join&compression=deflate-raw"
sfu, err := wtsignal.Dial(wtURL, hostname, resolvedIP)
sfu, err := wtsignal.Dial(wtURL, hostname, resolvedIP, vkOrigin)
if err != nil {
h.logger.Error(fmt.Sprintf("vk-joiner: WebTransport connect failed: %s", common.MaskError(err)))
return
@@ -383,11 +387,11 @@ func (h *VKJoiner) connectSFU() {
h.vkSeq = 0
h.vkMu.Unlock()
h.logger.Debug("vk-joiner: WebTransport connected")
h.vkSend("update-media-modifiers", map[string]interface{}{
"mediaModifiers": map[string]interface{}{"denoise": true, "denoiseAnn": true},
h.vkSend("update-media-modifiers", map[string]any{
"mediaModifiers": map[string]any{"denoise": true, "denoiseAnn": true},
})
h.vkSend("change-media-settings", map[string]interface{}{
"mediaSettings": map[string]interface{}{
h.vkSend("change-media-settings", map[string]any{
"mediaSettings": map[string]any{
"isAudioEnabled": false, "isVideoEnabled": true,
"isScreenSharingEnabled": h.dualTrack, "isFastScreenSharingEnabled": false,
"isAudioSharingEnabled": false, "isAnimojiEnabled": false,
@@ -396,7 +400,7 @@ func (h *VKJoiner) connectSFU() {
h.readLoop()
}
func (h *VKJoiner) vkSend(command string, extra map[string]interface{}) {
func (h *VKJoiner) vkSend(command string, extra map[string]any) {
h.vkMu.Lock()
defer h.vkMu.Unlock()
if h.sfu == nil {
@@ -410,7 +414,7 @@ func (h *VKJoiner) vkSend(command string, extra map[string]interface{}) {
h.logger.Debug(fmt.Sprintf("vk-joiner: -> %s", command))
}
func (h *VKJoiner) vkSendTransmitData(participantId int64, payload map[string]interface{}) {
func (h *VKJoiner) vkSendTransmitData(participantId int64, payload map[string]any) {
h.vkMu.Lock()
defer h.vkMu.Unlock()
if h.sfu == nil {
@@ -445,7 +449,7 @@ func (h *VKJoiner) readLoop() {
}
func (h *VKJoiner) handleVKMessage(raw []byte) {
var msg map[string]interface{}
var msg map[string]any
if err := json.Unmarshal(raw, &msg); err != nil {
return
}
@@ -457,7 +461,7 @@ func (h *VKJoiner) handleVKMessage(raw []byte) {
case "connection":
h.handleConnection(msg)
case "transmitted-data":
data, _ := msg["data"].(map[string]interface{})
data, _ := msg["data"].(map[string]any)
if data != nil {
if pid, ok := msg["participantId"].(float64); ok && h.remotePeerID == nil {
h.onRegisteredPeer(int64(pid))
@@ -493,20 +497,20 @@ func (h *VKJoiner) handleVKMessage(raw []byte) {
}
}
func (h *VKJoiner) handleConnection(msg map[string]interface{}) {
if conv, ok := msg["conversation"].(map[string]interface{}); ok {
func (h *VKJoiner) handleConnection(msg map[string]any) {
if conv, ok := msg["conversation"].(map[string]any); ok {
topo, _ := conv["topology"].(string)
h.logger.Debug(fmt.Sprintf("vk-joiner: connection topology=%q", topo))
}
convParams, ok := msg["conversationParams"].(map[string]interface{})
convParams, ok := msg["conversationParams"].(map[string]any)
if !ok {
return
}
turn, ok := convParams["turn"].(map[string]interface{})
turn, ok := convParams["turn"].(map[string]any)
if !ok {
return
}
urlsRaw, _ := turn["urls"].([]interface{})
urlsRaw, _ := turn["urls"].([]any)
var urls []string
for _, u := range urlsRaw {
if s, ok := u.(string); ok {
@@ -527,23 +531,34 @@ func (h *VKJoiner) handleConnection(msg map[string]interface{}) {
func (h *VKJoiner) initPC() {
var iceServers []webrtc.ICEServer
if len(h.joinResp.StunServer.URLs) > 0 {
iceServers = append(iceServers, webrtc.ICEServer{URLs: h.joinResp.StunServer.URLs})
iceServers = append(iceServers, webrtc.ICEServer{
URLs: common.ResolveICEHosts(h.joinResp.StunServer.URLs, h.DNSRouter, h.Dialer, h.logger, "vk-joiner"),
})
}
if len(h.joinResp.TurnServer.URLs) > 0 {
iceServers = append(iceServers, webrtc.ICEServer{
URLs: h.joinResp.TurnServer.URLs,
URLs: common.ResolveICEHosts(h.joinResp.TurnServer.URLs, h.DNSRouter, h.Dialer, h.logger, "vk-joiner"),
Username: h.joinResp.TurnServer.Username,
Credential: h.joinResp.TurnServer.Credential,
})
}
mode := h.authParams.TunnelMode
settingEngine := webrtc.SettingEngine{}
settingEngine.DisableCloseByDTLS(true)
settingEngine.DetachDataChannels()
if h.PCConfig != nil {
h.PCConfig.ConfigureSettingEngine(&settingEngine)
api, err := headlessapi.WebRTCAPI(headlessapi.Options{
Profile: headless.ChromeWindows.WithDTLS13Mimicry(),
Configure: func(settingEngine *webrtc.SettingEngine) {
settingEngine.DisableCloseByDTLS(true)
settingEngine.DetachDataChannels()
if h.PCConfig != nil {
h.PCConfig.ConfigureSettingEngine(settingEngine)
}
},
})
if err != nil {
h.logger.Error(fmt.Sprintf("vk-joiner: failed to build webrtc api: %v", err))
return
}
pc, err := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine)).NewPeerConnection(webrtc.Configuration{
pc, err := api.NewPeerConnection(webrtc.Configuration{
ICEServers: iceServers,
})
if err != nil {
@@ -571,7 +586,7 @@ func (h *VKJoiner) initPC() {
h.reconnectAttempt.Store(0)
h.logger.Info("vk-joiner: === DC TUNNEL CONNECTED ===")
if h.OnConnected != nil {
h.OnConnected(tunnel.NewDCTunnel(dc, h.obf, common.RTPBufSize, h.logger))
h.OnConnected(rtc.NewDCTunnel(dc, h.obf, common.RTPBufSize, h.logger))
}
}
})
@@ -594,15 +609,15 @@ func (h *VKJoiner) initPC() {
if mode == "video" && state == webrtc.PeerConnectionStateConnected && h.vp8tunnel == nil {
h.reconnectAttempt.Store(0)
h.logger.Info("vk-joiner: === TUNNEL CONNECTED ===")
h.vp8tunnel = tunnel.NewVP8DataTunnel(h.sampleTrack, h.obf, h.logger)
h.vp8tunnel = rtc.NewVP8DataTunnel(h.sampleTrack, h.obf, h.logger)
h.vp8tunnel.Start(h.vp8FPS, h.vp8Batch)
var downlink tunnel.DataTunnel = h.vp8tunnel
trackCount := 1
if h.dualTrack {
writer := tunnel.NewScreenWriter(h.obf, "screen-up", h.logger)
writer := rtc.NewScreenWriter(h.obf, "screen-up", h.logger)
writer.Reconfigure(h.vp8tunnel.FPS(), h.vp8tunnel.Batch())
writer.SetSend(h.producerScreen.send)
h.sym = tunnel.NewSymmetricScreenTunnel(h.vp8tunnel, writer, h.obf, h.producerScreen.ready, h.logger)
h.sym = rtc.NewSymmetricScreenTunnel(h.vp8tunnel, writer, h.obf, h.producerScreen.ready, h.logger)
h.sym.SetTrackCount(2)
downlink = h.sym
trackCount = 2
@@ -660,12 +675,12 @@ func (h *VKJoiner) onLocalICECandidate(candidate *webrtc.ICECandidate) {
}
candidateJSON := candidate.ToJSON()
raw, _ := json.Marshal(candidateJSON)
var parsed interface{}
var parsed any
json.Unmarshal(raw, &parsed)
h.vkSendTransmitData(*h.remotePeerID, map[string]interface{}{"candidate": parsed})
h.vkSendTransmitData(*h.remotePeerID, map[string]any{"candidate": parsed})
}
func (h *VKJoiner) onTransmittedData(data map[string]interface{}) {
func (h *VKJoiner) onTransmittedData(data map[string]any) {
if h.pc == nil {
return
}
@@ -682,21 +697,22 @@ func (h *VKJoiner) onTransmittedData(data map[string]interface{}) {
h.pendingICE = append(h.pendingICE, candidateInit)
}
}
if sdp, ok := data["sdp"].(map[string]interface{}); ok {
if sdp, ok := data["sdp"].(map[string]any); ok {
sdpType, _ := sdp["type"].(string)
sdpStr, _ := sdp["sdp"].(string)
if h.OnRemoteCandidate != nil {
h.OnRemoteCandidate(-1, sdpStr)
}
h.logger.Debug(fmt.Sprintf("vk-joiner: remote SDP: %s", sdpType))
if sdpType == "answer" {
switch sdpType {
case "answer":
h.pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: sdpStr})
h.remoteSet = true
for _, candidate := range h.pendingICE {
h.pc.AddICECandidate(candidate)
}
h.pendingICE = nil
} else if sdpType == "offer" {
case "offer":
h.pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: sdpStr})
h.remoteSet = true
for _, candidate := range h.pendingICE {
@@ -724,7 +740,7 @@ func (h *VKJoiner) onTransmittedData(data map[string]interface{}) {
}
func detectVKAuthRotten(raw []byte) *vkAuthRottenError {
var generic map[string]interface{}
var generic map[string]any
if err := json.Unmarshal(raw, &generic); err != nil {
return nil
}
@@ -747,11 +763,3 @@ func detectVKAuthRotten(raw []byte) *vkAuthRottenError {
}
return nil
}
func truncateBody(raw []byte) string {
const maxLen = 200
if len(raw) > maxLen {
return string(raw[:maxLen]) + "..."
}
return string(raw)
}
+17 -17
View File
@@ -4,8 +4,7 @@ import (
"encoding/json"
"fmt"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
"github.com/kulikov0/headless-client/webrtc"
)
type P2PHandler struct {
@@ -72,7 +71,7 @@ func (p *P2PHandler) OnRegisteredPeer(participantId int64) {
p.sendOfferToPeer(participantId)
}
func (p *P2PHandler) OnTransmittedData(data map[string]interface{}) {
func (p *P2PHandler) OnTransmittedData(data map[string]any) {
if cand, ok := data["candidate"]; ok {
p.bridge.logger.Debug("[p2p] Remote ICE candidate")
candJSON, _ := json.Marshal(cand)
@@ -80,19 +79,20 @@ func (p *P2PHandler) OnTransmittedData(data map[string]interface{}) {
json.Unmarshal(candJSON, &candInit)
p.bridge.relay.AddICECandidate(candInit)
}
if sdp, ok := data["sdp"].(map[string]interface{}); ok {
if sdp, ok := data["sdp"].(map[string]any); ok {
sdpType, _ := sdp["type"].(string)
sdpStr, _ := sdp["sdp"].(string)
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Remote SDP: %s", sdpType))
if sdpType == "answer" {
switch sdpType {
case "answer":
p.bridge.relay.SetRemoteDescription(webrtc.SDPTypeAnswer, sdpStr)
} else if sdpType == "offer" {
case "offer":
p.bridge.relay.SetRemoteDescription(webrtc.SDPTypeOffer, sdpStr)
answer, err := p.bridge.relay.CreateAnswer()
if err == nil && p.remotePeerId != nil {
p.bridge.vkSend("transmit-data", map[string]interface{}{
p.bridge.vkSend("transmit-data", map[string]any{
"participantId": *p.remotePeerId,
"data": map[string]interface{}{"sdp": map[string]interface{}{
"data": map[string]any{"sdp": map[string]any{
"type": answer.Type.String(), "sdp": answer.SDP,
}},
})
@@ -103,11 +103,11 @@ func (p *P2PHandler) OnTransmittedData(data map[string]interface{}) {
func (p *P2PHandler) OnPionICECandidate(data json.RawMessage) {
if p.remotePeerId != nil {
var cand interface{}
var cand any
json.Unmarshal(data, &cand)
p.bridge.vkSend("transmit-data", map[string]interface{}{
p.bridge.vkSend("transmit-data", map[string]any{
"participantId": *p.remotePeerId,
"data": map[string]interface{}{"candidate": cand},
"data": map[string]any{"candidate": cand},
})
} else {
var candInit webrtc.ICECandidateInit
@@ -153,7 +153,7 @@ func (p *P2PHandler) setupCallbacks() {
func (p *P2PHandler) kickRemotePeer() {
if p.remotePeerId != nil {
p.bridge.vkSend("remove-participant", map[string]interface{}{
p.bridge.vkSend("remove-participant", map[string]any{
"participantId": *p.remotePeerId,
"ban": false,
})
@@ -173,19 +173,19 @@ func (p *P2PHandler) sendOfferToPeer(participantId int64) {
seq := p.bridge.vkSeq
raw := fmt.Sprintf(`{"command":"transmit-data","sequence":%d,"participantId":%d,"data":{"sdp":{"type":%q,"sdp":%s}}}`,
seq, participantId, offer.Type.String(), sdpStr)
if p.bridge.vkWs != nil {
p.bridge.vkWs.WriteMessage(websocket.TextMessage, []byte(raw))
if p.bridge.sfu != nil {
p.bridge.sfu.Send([]byte(raw))
}
p.bridge.mu.Unlock()
p.bridge.logger.Debug("[vk-ws] -> transmit-data (offer)")
}
for _, cand := range candidates {
candJSON, _ := json.Marshal(cand)
var c interface{}
var c any
json.Unmarshal(candJSON, &c)
p.bridge.vkSend("transmit-data", map[string]interface{}{
p.bridge.vkSend("transmit-data", map[string]any{
"participantId": participantId,
"data": map[string]interface{}{"candidate": c},
"data": map[string]any{"candidate": c},
})
}
if len(candidates) > 0 {
+11 -19
View File
@@ -9,14 +9,16 @@ import (
"sync"
"time"
"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-box/transport/call/tunnel/rtc"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/kulikov0/headless-client/webrtc"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
)
type Relay interface {
@@ -47,13 +49,13 @@ type TunnelRelay struct {
conns sync.Map
sampleTrack *webrtc.TrackLocalStaticSample
tun *tunnel.VP8DataTunnel
tun *rtc.VP8DataTunnel
obf *tunnel.TunnelObfuscator
OnConnected func(tunnel.DataTunnel)
screenDC *webrtc.DataChannel
producerScreen *webrtc.DataChannel
sym *tunnel.SymmetricScreenTunnel
sym *rtc.SymmetricScreenTunnel
dialer N.Dialer
readBufSize int
@@ -102,17 +104,7 @@ func (u *TunnelRelay) Init(iceServers []webrtc.ICEServer) error {
u.handleDCMessage(msg.Data)
})
}
sampleTrack, _ := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8},
"video", "tunnel-video",
)
u.sampleTrack = sampleTrack
audioTrack, _ := webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
"audio", "tunnel-audio",
)
pc.AddTrack(audioTrack)
pc.AddTrack(sampleTrack)
u.sampleTrack = common.AddTunnelTracks(pc, u.logger, "vk")
ordered := true
dcNotif, err := pc.CreateDataChannel("producerNotification", &webrtc.DataChannelInit{Ordered: &ordered})
if err == nil {
@@ -162,14 +154,14 @@ func (u *TunnelRelay) Init(iceServers []webrtc.ICEServer) error {
u.modeOnce.Do(func() {
u.mode = "video"
u.logger.Info("[relay] === MODE: VIDEO ===")
u.tun = tunnel.NewVP8DataTunnel(sampleTrack, u.obf, u.logger)
u.tun = rtc.NewVP8DataTunnel(u.sampleTrack, u.obf, u.logger)
u.tun.Start(0, 0)
var downlink tunnel.DataTunnel = u.tun
if u.screenDC != nil {
writer := tunnel.NewScreenWriter(u.obf, "screen-down", u.logger)
writer := rtc.NewScreenWriter(u.obf, "screen-down", u.logger)
dc := u.screenDC
writer.SetSend(dc.Send)
u.sym = tunnel.NewSymmetricScreenTunnel(u.tun, writer, u.obf, func() bool {
u.sym = rtc.NewSymmetricScreenTunnel(u.tun, writer, u.obf, func() bool {
return dc.ReadyState() == webrtc.DataChannelStateOpen
}, u.logger)
downlink = u.sym
+8 -5
View File
@@ -2,12 +2,15 @@ package vk
import (
"errors"
"fmt"
"io"
"sync"
"github.com/pion/datachannel"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing/common/logger"
"github.com/kulikov0/headless-client/webrtc"
"github.com/pion/datachannel"
)
var errScreenNotReady = errors.New("screen DC not ready")
@@ -61,7 +64,7 @@ func readScreenDataChannel(dc *webrtc.DataChannel, handler func([]byte), logger
var raw datachannel.ReadWriteCloser
raw, err := dc.Detach()
if err != nil {
logger.Warn("[vk-joiner] screen DC detach failed, using OnMessage")
logger.Warn(fmt.Sprintf("[vk-joiner] screen DC detach failed, using OnMessage: %v", err))
dc.OnMessage(func(m webrtc.DataChannelMessage) {
if !m.IsString && len(m.Data) > 0 {
frame := make([]byte, len(m.Data))
@@ -72,7 +75,7 @@ func readScreenDataChannel(dc *webrtc.DataChannel, handler func([]byte), logger
return
}
logger.Debug("[vk-joiner] screen DC attached for reading")
buf := make([]byte, 65536)
buf := make([]byte, common.RTPBufSize)
for {
n, isString, rerr := raw.ReadDataChannel(buf)
if rerr != nil {
@@ -92,7 +95,7 @@ func attachScreenWriterDC(dc *webrtc.DataChannel, onRaw func(io.WriteCloser), lo
dc.OnOpen(func() {
raw, err := dc.Detach()
if err != nil {
logger.Warn("[vk-joiner] screen writer DC detach failed")
logger.Warn(fmt.Sprintf("[vk-joiner] screen writer DC detach failed: %v", err))
return
}
logger.Debug("[vk-joiner] screen DC attached for writing")
+14 -12
View File
@@ -12,6 +12,8 @@ import (
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
headless "github.com/kulikov0/headless-client"
)
type vkAuthConfig struct {
@@ -32,10 +34,10 @@ type vkCaptchaError struct {
func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.ContextLogger) (string, error) {
client := common.HttpClient(dialer)
httpPost := func(targetURL string, form url.Values, extraHeaders map[string]string) (map[string]interface{}, error) {
httpPost := func(targetURL string, form url.Values, extraHeaders map[string]string) (map[string]any, error) {
req, _ := http.NewRequest("POST", targetURL, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", common.UserAgent)
req.Header.Set("User-Agent", headless.ChromeWindows.UserAgent())
req.Header.Set("Origin", "https://vk.ru")
req.Header.Set("Referer", "https://vk.ru/")
for k, v := range extraHeaders {
@@ -50,7 +52,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
if err != nil {
return nil, err
}
var result map[string]interface{}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("json: %w (body: %s)", err, string(body[:minInt(len(body), 200)]))
}
@@ -70,7 +72,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
if err != nil {
return "", fmt.Errorf("get_anonym_token: %w", err)
}
dataMap, _ := anonResp["data"].(map[string]interface{})
dataMap, _ := anonResp["data"].(map[string]any)
accessToken, _ := dataMap["access_token"].(string)
if accessToken == "" {
return "", fmt.Errorf("empty access_token: %v", anonResp)
@@ -84,8 +86,8 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
if err != nil {
return "", fmt.Errorf("calls.getSettings: %w", err)
}
if respObj, ok := settingsResp["response"].(map[string]interface{}); ok {
if settings, ok := respObj["settings"].(map[string]interface{}); ok {
if respObj, ok := settingsResp["response"].(map[string]any); ok {
if settings, ok := respObj["settings"].(map[string]any); ok {
if pk, ok := settings["public_key"].(string); ok {
cfg.PublicKey = pk
}
@@ -98,7 +100,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
"vk_join_link": {joinLink},
}, auth)
if err == nil {
if respObj, ok := previewResp["response"].(map[string]interface{}); ok {
if respObj, ok := previewResp["response"].(map[string]any); ok {
if okLink, ok := respObj["ok_join_link"].(string); ok {
cfg.OkJoinLink = okLink
}
@@ -113,12 +115,12 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
var callToken string
var apiBaseURL string
var okJoinLink string
for attempt := 0; attempt < 5; attempt++ {
for range 5 {
callResp, err := httpPost("https://api.vk.ru/method/calls.getAnonymousToken", callParams, auth)
if err != nil {
return "", fmt.Errorf("getAnonymousToken: %w", err)
}
if errObj, hasErr := callResp["error"].(map[string]interface{}); hasErr {
if errObj, hasErr := callResp["error"].(map[string]any); hasErr {
errCode, _ := errObj["error_code"].(float64)
if int(errCode) == 14 {
captchaErr := parseVKCaptchaError(errObj)
@@ -156,7 +158,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
}
return "", fmt.Errorf("VK API error: %v", errObj)
}
respMap, ok := callResp["response"].(map[string]interface{})
respMap, ok := callResp["response"].(map[string]any)
if !ok {
return "", fmt.Errorf("unexpected response: %v", callResp)
}
@@ -174,7 +176,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
baseURL += "/fb.do"
}
deviceID := fmt.Sprintf("%d", rand.Int63n(9e18))
sessionData, _ := json.Marshal(map[string]interface{}{
sessionData, _ := json.Marshal(map[string]any{
"version": 2,
"device_id": deviceID,
"client_version": cfg.AppVersion,
@@ -215,7 +217,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
return string(jsonBytes), nil
}
func parseVKCaptchaError(errObj map[string]interface{}) *vkCaptchaError {
func parseVKCaptchaError(errObj map[string]any) *vkCaptchaError {
redirectURI, _ := errObj["redirect_uri"].(string)
if redirectURI == "" {
return nil
+116 -28
View File
@@ -10,12 +10,13 @@ import (
"net/url"
"strings"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/kulikov0/headless-client"
)
const (
APIBase = "https://stream.wb.ru"
Origin = "https://stream.wb.ru"
APIBase = "https://stream.wb.ru"
Origin = "https://stream.wb.ru"
AccessTokenEntry = "wb_access_token"
)
var WBStreamCookieAllowlist = []string{
@@ -72,7 +73,13 @@ type cookieTransport struct {
type slideV3Response struct {
Payload struct {
AccessToken string `json:"access_token"`
Sticker string `json:"sticker"`
} `json:"payload"`
Error string `json:"error"`
}
type slideV3ConfirmResponse struct {
Error string `json:"error"`
}
func ParseRoomID(input string) string {
@@ -213,13 +220,13 @@ func AuthAsLoggedIn(client *http.Client, cookieHeader, accessToken, roomID, disp
return joinAndGetDetails(client, accessToken, roomID, displayName)
}
func RefreshAccessToken(client *http.Client, cookieHeader, deviceID string) (string, error) {
func RefreshAccessToken(client *http.Client, cookieHeader, deviceID string) (string, map[string]string, error) {
if deviceID == "" {
return "", nil, fmt.Errorf("slide-v3: device id is required")
}
req, err := http.NewRequest(http.MethodPost, "https://auth-stream.wb.ru/v2/auth/slide-v3", bytes.NewReader(nil))
if err != nil {
return "", err
}
if deviceID == "" {
deviceID = newRequestID()
return "", nil, err
}
req.Header.Set("wb-apptype", "web")
req.Header.Set("X-Real-IP", "")
@@ -228,27 +235,104 @@ func RefreshAccessToken(client *http.Client, cookieHeader, deviceID string) (str
req.Header.Set("Origin", Origin)
req.Header.Set("Referer", Origin+"/")
req.Header.Set("Cookie", cookieHeader)
req.Header.Set("User-Agent", common.UserAgent)
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
resp, err := httpDo(client, req)
if err != nil {
return "", err
return "", nil, err
}
defer resp.Body.Close()
rotated := make(map[string]string)
for _, ck := range resp.Cookies() {
if ck.Value != "" {
rotated[ck.Name] = ck.Value
}
}
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", nil, fmt.Errorf("slide-v3: status %d: %s", resp.StatusCode, string(raw))
}
var r slideV3Response
if err := json.Unmarshal(raw, &r); err != nil {
return "", nil, fmt.Errorf("slide-v3 decode: %w", err)
}
if r.Error != "" {
return "", nil, fmt.Errorf("slide-v3: %s", string(raw))
}
if r.Payload.AccessToken == "" {
return "", nil, fmt.Errorf("slide-v3: empty access_token in response: %s", string(raw))
}
if err := confirmRefresh(client, mergeCookies(cookieHeader, rotated), deviceID, r.Payload.Sticker); err != nil {
return "", nil, err
}
return r.Payload.AccessToken, rotated, nil
}
func confirmRefresh(client *http.Client, cookieHeader, deviceID, sticker string) error {
if sticker == "" {
return fmt.Errorf("slide-v3-confirm: slide-v3 returned no sticker")
}
body, err := json.Marshal(map[string]string{"sticker": sticker})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "https://auth-stream.wb.ru/v2/auth/slide-v3-confirm", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("wb-apptype", "web")
req.Header.Set("X-Real-IP", "")
req.Header.Set("deviceId", deviceID)
req.Header.Set("X-Request-ID", newRequestID())
req.Header.Set("Origin", Origin)
req.Header.Set("Referer", Origin+"/")
req.Header.Set("Cookie", cookieHeader)
resp, err := httpDo(client, req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("slide-v3: status %d: %s", resp.StatusCode, string(raw))
return fmt.Errorf("slide-v3-confirm: status %d: %s", resp.StatusCode, string(raw))
}
var r slideV3Response
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("slide-v3 decode: %w", err)
var confirmed slideV3ConfirmResponse
if err := json.Unmarshal(raw, &confirmed); err != nil {
return fmt.Errorf("slide-v3-confirm decode: %w", err)
}
if r.Payload.AccessToken == "" {
return "", fmt.Errorf("slide-v3: empty access_token in response: %s", string(raw))
if confirmed.Error != "" {
return fmt.Errorf("slide-v3-confirm: %s", confirmed.Error)
}
return r.Payload.AccessToken, nil
return nil
}
func mergeCookies(cookieHeader string, updates map[string]string) string {
if len(updates) == 0 {
return cookieHeader
}
replaced := make(map[string]bool, len(updates))
var merged []string
for part := range strings.SplitSeq(cookieHeader, ";") {
trimmed := strings.TrimSpace(part)
before, _, ok := strings.Cut(trimmed, "=")
if !ok {
continue
}
name := before
if value, ok := updates[name]; ok {
merged = append(merged, name+"="+value)
replaced[name] = true
continue
}
merged = append(merged, trimmed)
}
for name, value := range updates {
if !replaced[name] {
merged = append(merged, name+"="+value)
}
}
return strings.Join(merged, "; ")
}
func SetParticipantPermissions(client *http.Client, accessToken, roomID, participantID string, permissions []string) error {
@@ -277,7 +361,7 @@ func SetParticipantPermissions(client *http.Client, accessToken, roomID, partici
func KickParticipant(client *http.Client, accessToken, roomID, participantID string) error {
if client == nil {
client = http.DefaultClient
client = headless.ChromeWindows.HTTPClient()
}
kickURL := fmt.Sprintf("%s/api-room-manager/api/v1/room/%s/participant/%s/kick", APIBase, roomID, participantID)
req, err := http.NewRequest("DELETE", kickURL, strings.NewReader("{}"))
@@ -286,8 +370,7 @@ func KickParticipant(client *http.Client, accessToken, roomID, participantID str
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("User-Agent", common.UserAgent)
resp, err := client.Do(req)
resp, err := httpDo(client, req)
if err != nil {
return err
}
@@ -303,15 +386,20 @@ func (t *cookieTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Cookie", t.cookie)
base := t.base
if base == nil {
base = http.DefaultTransport
base = headless.ChromeWindows.HTTPClient().Transport
}
return base.RoundTrip(req)
}
func httpDo(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", common.UserAgent)
req.Header.Set("User-Agent", headless.ChromeWindows.UserAgent())
for name, values := range headless.ChromeWindows.Headers(headless.DestEmpty) {
if _, present := req.Header[name]; !present {
req.Header[name] = values
}
}
if client == nil {
client = http.DefaultClient
client = headless.ChromeWindows.HTTPClient()
}
return client.Do(req)
}
@@ -321,7 +409,7 @@ func clientWithCookies(client *http.Client, cookieHeader string) *http.Client {
return client
}
if client == nil {
client = &http.Client{}
client = headless.ChromeWindows.HTTPClient()
}
wrapped := *client
wrapped.Transport = &cookieTransport{base: client.Transport, cookie: cookieHeader}
+34 -14
View File
@@ -9,6 +9,7 @@ import (
"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-box/transport/call/tunnel/rtc"
"github.com/sagernet/sing/common/logger"
N "github.com/sagernet/sing/common/network"
)
@@ -20,10 +21,11 @@ func ConnectCreator(ctx context.Context, cookieStr, roomID, mode string, readBuf
}
cookieHeader := common.FilterCookies(cookieStr, WBStreamCookieAllowlist)
httpClient := common.HttpClient(dialer)
bearer, err := RefreshAccessToken(httpClient, cookieHeader, deviceID)
bearer, rotated, err := RefreshAccessToken(httpClient, cookieHeader, deviceID)
if err != nil {
return nil, "", fmt.Errorf("wbstream: slide-v3 refresh: %w", err)
}
cookieHeader = mergeCookies(cookieHeader, rotated)
requestedRoom := ParseRoomID(roomID)
resolvedRoomID, roomToken, accessToken, serverURL, err := AuthAsLoggedIn(httpClient, cookieHeader, bearer, requestedRoom, "Creator")
if err != nil {
@@ -40,6 +42,7 @@ func ConnectCreator(ctx context.Context, cookieStr, roomID, mode string, readBuf
return nil, "", fmt.Errorf("wbstream: obfuscator init: %w", err)
}
var relay *tunnel.RelayBridge
joinSession := func(token, access, server string) (*Session, <-chan tunnel.DataTunnel) {
tunCh := make(chan tunnel.DataTunnel, 1)
sess := NewSession(SessionConfig{
@@ -60,6 +63,11 @@ func ConnectCreator(ctx context.Context, cookieStr, roomID, mode string, readBuf
default:
}
}
sess.OnPeerRestart = func() {
if relay != nil {
relay.Reset()
}
}
return sess, tunCh
}
@@ -78,12 +86,12 @@ func ConnectCreator(ctx context.Context, cookieStr, roomID, mode string, readBuf
return nil, "", fmt.Errorf("wbstream: creator tunnel timed out")
}
relay := tunnel.NewRelayBridge(firstTun, "creator", bridgeReadBufFor(firstTun, readBuf), dialer, logger)
go creatorReconnectLoop(ctx, relay, sess, joinSession, httpClient, cookieHeader, deviceID, resolvedRoomID, readBuf, logger)
relay = tunnel.NewRelayBridge(firstTun, "creator", bridgeReadBufFor(firstTun, readBuf), dialer, logger)
go creatorReconnectLoop(ctx, relay, sess, joinSession, httpClient, cookieHeader, bearer, deviceID, resolvedRoomID, readBuf, logger)
return relay, APIBase + "/room/" + resolvedRoomID, nil
}
func ConnectJoiner(ctx context.Context, roomID, displayName, mode string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
func ConnectJoiner(ctx context.Context, roomID, displayName, mode string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (*tunnel.RelayBridge, error) {
roomID = ParseRoomID(roomID)
if displayName == "" {
displayName = "Joiner"
@@ -91,6 +99,9 @@ func ConnectJoiner(ctx context.Context, roomID, displayName, mode string, readBu
if mode == "" {
mode = TunnelModeDC
}
if readBuf <= 0 {
readBuf = 32768
}
joiner := NewWBStreamJoiner(logger, dialer, dnsRouter, nil)
tunCh := make(chan tunnel.DataTunnel, 1)
joiner.OnConnected = func(tun tunnel.DataTunnel) {
@@ -103,7 +114,10 @@ func ConnectJoiner(ctx context.Context, roomID, displayName, mode string, readBu
go joiner.RunWithParams(params)
select {
case tun := <-tunCh:
return tun, nil
rb := tunnel.NewRelayBridge(tun, "joiner", bridgeReadBufFor(tun, readBuf), dialer, logger)
rb.SetOnConfigAck(joiner.MarkConfigAcked)
rb.MarkReady()
return rb, nil
case <-ctx.Done():
joiner.Close()
return nil, ctx.Err()
@@ -116,7 +130,7 @@ func creatorReconnectLoop(
sess *Session,
joinSession func(token, access, server string) (*Session, <-chan tunnel.DataTunnel),
httpClient *http.Client,
cookieHeader, deviceID, roomID string,
cookieHeader, bearer, deviceID, roomID string,
readBuf int,
logger logger.ContextLogger,
) {
@@ -143,15 +157,21 @@ func creatorReconnectLoop(
if relay.IsClosed() {
return
}
bearer, err := RefreshAccessToken(httpClient, cookieHeader, deviceID)
if err != nil {
logger.Warn(fmt.Sprintf("wbstream: rejoin token refresh failed: %v, retrying", err))
continue
}
_, roomToken, accessToken, serverURL, err := AuthAsLoggedIn(httpClient, cookieHeader, bearer, roomID, "Creator")
if err != nil {
logger.Warn(fmt.Sprintf("wbstream: rejoin auth failed: %v, retrying", err))
continue
logger.Warn(fmt.Sprintf("wbstream: rejoin auth failed: %v, refreshing bearer", err))
var rotated map[string]string
bearer, rotated, err = RefreshAccessToken(httpClient, cookieHeader, deviceID)
if err != nil {
logger.Warn(fmt.Sprintf("wbstream: rejoin token refresh failed: %v, retrying", err))
continue
}
cookieHeader = mergeCookies(cookieHeader, rotated)
_, roomToken, accessToken, serverURL, err = AuthAsLoggedIn(httpClient, cookieHeader, bearer, roomID, "Creator")
if err != nil {
logger.Warn(fmt.Sprintf("wbstream: rejoin auth failed: %v, retrying", err))
continue
}
}
newSess, tunCh := joinSession(roomToken, accessToken, serverURL)
if err := newSess.Start(); err != nil {
@@ -178,7 +198,7 @@ func creatorReconnectLoop(
func bridgeReadBufFor(tun tunnel.DataTunnel, readBuf int) int {
switch tun.(type) {
case *tunnel.DCTunnel, *tunnel.MultiTrackKCPTunnel:
case *rtc.DCTunnel, *rtc.MultiTrackKCPTunnel:
return readBuf
}
return common.VP8BufSize
-53
View File
@@ -1,53 +0,0 @@
package wbstream
import (
"github.com/pion/datachannel"
"github.com/sagernet/sing-box/transport/call/livekit"
)
type dataPacketWrapper struct {
inner datachannel.ReadWriteCloser
kind int
}
func (w *dataPacketWrapper) ReadDataChannel(p []byte) (int, bool, error) {
buf := make([]byte, len(p))
for {
n, isString, err := w.inner.ReadDataChannel(buf)
if err != nil {
return 0, false, err
}
if n == 0 {
continue
}
payload, ok := livekit.DecodeDataPacketUser(buf[:n])
if !ok || len(payload) == 0 {
continue
}
copied := copy(p, payload)
return copied, isString, nil
}
}
func (w *dataPacketWrapper) WriteDataChannel(p []byte, isString bool) (int, error) {
wire := livekit.EncodeDataPacketUser(p, w.kind)
if _, err := w.inner.WriteDataChannel(wire, isString); err != nil {
return 0, err
}
return len(p), nil
}
func (w *dataPacketWrapper) Read(p []byte) (int, error) {
n, _, err := w.ReadDataChannel(p)
return n, err
}
func (w *dataPacketWrapper) Write(p []byte) (int, error) {
return w.WriteDataChannel(p, false)
}
func (w *dataPacketWrapper) Close() error { return w.inner.Close() }
func newDataPacketWrapper(inner datachannel.ReadWriteCloser, kind int) *dataPacketWrapper {
return &dataPacketWrapper{inner: inner, kind: kind}
}
+26 -24
View File
@@ -10,13 +10,15 @@ import (
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
"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"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/webrtc"
)
const (
@@ -78,15 +80,13 @@ func (j *WBStreamJoiner) RunWithParams(jsonParams string) {
return
}
j.logger.Debug(fmt.Sprintf("wbstream-joiner: obf key-source=%q localEpoch=0x%08x", params.RoomID, obf.LocalEpoch()))
var settingEngine *webrtc.SettingEngine
var configureSettingEngine func(*webrtc.SettingEngine)
if j.PCConfig != nil {
se := webrtc.SettingEngine{}
j.PCConfig.ConfigureSettingEngine(&se)
settingEngine = &se
configureSettingEngine = j.PCConfig.ConfigureSettingEngine
}
var attempt atomic.Int32
j.logger.Info("wbstream-joiner: connecting")
if err := j.runOnce(httpClient, params.RoomID, params.DisplayName, params.TunnelMode, obf, settingEngine, params.VP8FPS, params.VP8Batch, params.DualTrack, reliable, &attempt); err != nil {
if err := j.runOnce(httpClient, params.RoomID, params.DisplayName, params.TunnelMode, obf, configureSettingEngine, params.VP8FPS, params.VP8Batch, params.DualTrack, reliable, &attempt); err != nil {
j.logger.Error(fmt.Sprintf("wbstream-joiner: %v", err))
return
}
@@ -104,7 +104,7 @@ func (j *WBStreamJoiner) RunWithParams(jsonParams string) {
return
}
j.logger.Info(fmt.Sprintf("wbstream-joiner: reconnect attempt #%d", attempt.Load()))
if err := j.runOnce(httpClient, params.RoomID, params.DisplayName, params.TunnelMode, obf, settingEngine, params.VP8FPS, params.VP8Batch, params.DualTrack, reliable, &attempt); err != nil {
if err := j.runOnce(httpClient, params.RoomID, params.DisplayName, params.TunnelMode, obf, configureSettingEngine, params.VP8FPS, params.VP8Batch, params.DualTrack, reliable, &attempt); err != nil {
j.logger.Warn(fmt.Sprintf("wbstream-joiner: %v, will retry", err))
}
}
@@ -131,27 +131,27 @@ func (j *WBStreamJoiner) Close() {
}
}
func (j *WBStreamJoiner) runOnce(httpClient *http.Client, roomID, displayName, tunnelMode string, obf *tunnel.TunnelObfuscator, settingEngine *webrtc.SettingEngine, vp8FPS, vp8Batch int, dualTrack, reliable bool, attempt *atomic.Int32) error {
func (j *WBStreamJoiner) runOnce(httpClient *http.Client, roomID, displayName, tunnelMode string, obf *tunnel.TunnelObfuscator, configureSettingEngine func(*webrtc.SettingEngine), vp8FPS, vp8Batch int, dualTrack, reliable bool, attempt *atomic.Int32) error {
_, roomToken, _, serverURL, authErr := AuthAndGetToken(httpClient, roomID, displayName)
if authErr != nil {
return fmt.Errorf("auth: %w", authErr)
}
j.logger.Debug(fmt.Sprintf("wbstream-joiner: server=%s", serverURL))
sess := NewSession(SessionConfig{
RoomToken: roomToken,
ServerURL: serverURL,
DisplayName: displayName,
TunnelMode: tunnelMode,
Obfuscator: obf,
Logger: j.logger,
SettingEngine: settingEngine,
Dialer: j.dialer,
DNSRouter: j.dnsRouter,
VP8FPS: vp8FPS,
VP8Batch: vp8Batch,
ScreenShare: dualTrack,
IsJoiner: true,
Reliable: reliable,
RoomToken: roomToken,
ServerURL: serverURL,
DisplayName: displayName,
TunnelMode: tunnelMode,
Obfuscator: obf,
Logger: j.logger,
ConfigureSettingEngine: configureSettingEngine,
Dialer: j.dialer,
DNSRouter: j.dnsRouter,
VP8FPS: vp8FPS,
VP8Batch: vp8Batch,
ScreenShare: dualTrack,
IsJoiner: true,
Reliable: reliable,
})
sess.OnConnected = func(tun tunnel.DataTunnel) {
attempt.Store(0)
@@ -212,6 +212,8 @@ func (j *WBStreamJoiner) makeDialContext() func(ctx context.Context, network, ad
}
func (j *WBStreamJoiner) makeHTTPClient() *http.Client {
transport := &http.Transport{DialContext: j.makeDialContext()}
return &http.Client{Timeout: 60 * time.Second, Transport: transport}
return &http.Client{
Timeout: 60 * time.Second,
Transport: headless.ChromeWindows.Transport(headless.TLSOptions{DialContext: j.makeDialContext()}),
}
}
+70 -88
View File
@@ -7,17 +7,19 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
"github.com/pion/webrtc/v4"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/transport/call/common"
"github.com/sagernet/sing-box/transport/call/livekit"
"github.com/sagernet/sing-box/transport/call/tunnel"
"github.com/sagernet/sing-box/transport/call/tunnel/rtc"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/webrtc"
"github.com/pion/rtp"
"github.com/pion/rtp/codecs"
)
type peerEntry struct {
@@ -34,23 +36,23 @@ const (
)
type SessionConfig struct {
RoomToken string
ServerURL string
DisplayName string
TunnelMode string
Obfuscator *tunnel.TunnelObfuscator
Logger logger.ContextLogger
SettingEngine *webrtc.SettingEngine
Dialer N.Dialer
DNSRouter adapter.DNSRouter
VP8FPS int
VP8Batch int
RoomID string
AccessToken string
ReadBuf int
ScreenShare bool
IsJoiner bool
Reliable bool
RoomToken string
ServerURL string
DisplayName string
TunnelMode string
Obfuscator *tunnel.TunnelObfuscator
Logger logger.ContextLogger
ConfigureSettingEngine func(*webrtc.SettingEngine)
Dialer N.Dialer
DNSRouter adapter.DNSRouter
VP8FPS int
VP8Batch int
RoomID string
AccessToken string
ReadBuf int
ScreenShare bool
IsJoiner bool
Reliable bool
}
type Session struct {
@@ -64,12 +66,13 @@ type Session struct {
pubReliableDCReady bool
subReliableDC *webrtc.DataChannel
vp8tun *tunnel.MultiTrackTunnel
kcptun *tunnel.MultiTrackKCPTunnel
dctun *tunnel.DCTunnel
mu sync.Mutex
tunFired bool
done chan struct{}
vp8tun *rtc.MultiTrackTunnel
kcptun *rtc.MultiTrackKCPTunnel
dctun *rtc.DCTunnel
dcStarted bool
mu sync.Mutex
tunFired bool
done chan struct{}
peersBySID map[string]peerEntry
kickedSIDs map[string]bool
@@ -100,18 +103,23 @@ func (s *Session) MarkConfigAcked() {
func (s *Session) Done() <-chan struct{} { return s.done }
func (s *Session) Start() error {
s.lk = livekit.NewClient(livekit.Config{
ServerURL: s.cfg.ServerURL,
Token: s.cfg.RoomToken,
Origin: Origin,
UserAgent: common.UserAgent,
Logger: s.cfg.Logger,
SettingEngine: s.cfg.SettingEngine,
lk, err := livekit.NewClient(livekit.Config{
ServerURL: s.cfg.ServerURL,
Token: s.cfg.RoomToken,
Origin: Origin,
UserAgent: headless.ChromeWindows.UserAgent(),
Codec: livekit.ProtoCodec{},
Logger: s.cfg.Logger,
ConfigureSettingEngine: s.cfg.ConfigureSettingEngine,
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return s.cfg.Dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
DNSRouter: s.cfg.DNSRouter,
})
if err != nil {
return err
}
s.lk = lk
s.lk.OnReady = s.onLKReady
s.lk.OnTrack = s.onRemoteTrack
s.lk.OnDataChannel = s.onRemoteDataChannel
@@ -213,10 +221,8 @@ func (s *Session) onLKReady() {
if pubPC == nil {
return
}
camID := "videochannel-" + uuid.New().String()
trackCam, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
camID, "tunnel-video-"+uuid.New().String(),
)
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] create local cam track: %v", err))
@@ -224,10 +230,8 @@ func (s *Session) onLKReady() {
}
tracks := []*webrtc.TrackLocalStaticSample{trackCam}
if s.cfg.ScreenShare {
screenID := "screenchannel-" + uuid.New().String()
trackScreen, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
screenID, "tunnel-screen-"+uuid.New().String(),
)
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] create local screen track: %v", err))
@@ -244,6 +248,7 @@ func (s *Session) onLKReady() {
return
}
transceivers = append(transceivers, trx)
go rtc.DrainSenderRTCP(trx.Sender())
}
s.mu.Lock()
s.sampleTracks = tracks
@@ -300,11 +305,11 @@ func (s *Session) startTunnel() {
s.mu.Unlock()
return
}
subs := make([]*tunnel.VP8DataTunnel, 0, len(s.sampleTracks))
subs := make([]*rtc.VP8DataTunnel, 0, len(s.sampleTracks))
for _, t := range s.sampleTracks {
subs = append(subs, tunnel.NewVP8DataTunnelWithQueue(t, s.cfg.Obfuscator, s.cfg.Logger, tunnel.KCPCarrierQueueDepth))
subs = append(subs, rtc.NewVP8DataTunnelWithQueue(t, s.cfg.Obfuscator, s.cfg.Logger, rtc.KCPCarrierQueueDepth))
}
s.vp8tun = tunnel.NewMultiTrackTunnel(subs)
s.vp8tun = rtc.NewMultiTrackTunnel(subs)
s.vp8tun.SetOnPeerRestart(func() {
s.cfg.Logger.Debug("[wb] peer epoch changed, signalling peer-restart")
s.rearmAutoDetect()
@@ -333,29 +338,12 @@ func (s *Session) startTunnel() {
}
func (s *Session) configPingPong(tun tunnel.DataTunnel, trackCount int) {
frame := tunnel.EncodeVP8Config(s.cfg.VP8FPS, s.cfg.VP8Batch, trackCount)
tun.SendData(frame)
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.configAcked:
return
case <-s.done:
return
case <-ticker.C:
s.cfg.Logger.Debug("[lk] resending vp8 config (no ack yet)")
tun.SendData(tunnel.EncodeVP8Config(s.cfg.VP8FPS, s.cfg.VP8Batch, trackCount))
}
}
tunnel.SendVP8ConfigUntilAcked(s.configAcked, nil, s.done, tun,
s.cfg.VP8FPS, s.cfg.VP8Batch, trackCount, s.cfg.Logger, "[lk]")
}
func (s *Session) maybeStartDCTunnel() {
s.mu.Lock()
if s.dctun != nil {
s.mu.Unlock()
return
}
pubDC := s.pubReliableDC
subDC := s.subReliableDC
pubReady := s.pubReliableDCReady
@@ -366,6 +354,14 @@ func (s *Session) maybeStartDCTunnel() {
if subDC.ReadyState() != webrtc.DataChannelStateOpen {
return
}
s.mu.Lock()
if s.dcStarted {
s.mu.Unlock()
return
}
s.dcStarted = true
s.mu.Unlock()
subRaw, err := subDC.Detach()
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] detach sub DC: %v", err))
@@ -376,16 +372,13 @@ func (s *Session) maybeStartDCTunnel() {
s.cfg.Logger.Error(fmt.Sprintf("[lk] detach pub DC: %v", err))
return
}
readWrapped := newDataPacketWrapper(subRaw, livekit.DataPacketKindReliable)
writeWrapped := newDataPacketWrapper(pubRaw, livekit.DataPacketKindReliable)
readWrapped := livekit.NewDataPacketWrapper(subRaw, livekit.DataPacketKindReliable)
writeWrapped := livekit.NewDataPacketWrapper(pubRaw, livekit.DataPacketKindReliable)
readBuf := s.cfg.ReadBuf
if readBuf == 0 {
readBuf = common.DCBufSize
}
dctun := tunnel.NewChunkedDCTunnelFromRaw(readWrapped, writeWrapped, s.cfg.Obfuscator, readBuf, s.cfg.Logger)
if dctun == nil {
return
}
dctun := rtc.NewChunkedDCTunnelFromRaw(readWrapped, writeWrapped, s.cfg.Obfuscator, readBuf, s.cfg.Logger)
s.mu.Lock()
s.dctun = dctun
s.mu.Unlock()
@@ -420,9 +413,9 @@ func (s *Session) activate(tun tunnel.DataTunnel, payload []byte) {
}
s.tunFired = true
s.mu.Unlock()
var delivered tunnel.DataTunnel = tun
delivered := tun
useKCP := false
if _, ok := tun.(*tunnel.MultiTrackTunnel); ok && !tunnel.LooksLikeRelayFrame(payload) {
if _, ok := tun.(*rtc.MultiTrackTunnel); ok && !tunnel.LooksLikeRelayFrame(payload) {
delivered = s.maybeWrapReliable(tun)
useKCP = true
}
@@ -431,13 +424,13 @@ func (s *Session) activate(tun tunnel.DataTunnel, payload []byte) {
s.OnConnected(delivered)
}
switch v := tun.(type) {
case *tunnel.DCTunnel:
case *rtc.DCTunnel:
if fwd := v.OnData(); fwd != nil {
fwd(payload)
}
case *tunnel.MultiTrackTunnel:
case *rtc.MultiTrackTunnel:
if useKCP {
if kcptun, ok := delivered.(*tunnel.MultiTrackKCPTunnel); ok {
if kcptun, ok := delivered.(*rtc.MultiTrackKCPTunnel); ok {
kcptun.InjectSegment(payload)
}
} else {
@@ -447,11 +440,11 @@ func (s *Session) activate(tun tunnel.DataTunnel, payload []byte) {
}
func (s *Session) maybeWrapReliable(tun tunnel.DataTunnel) tunnel.DataTunnel {
vp8, ok := tun.(*tunnel.MultiTrackTunnel)
vp8, ok := tun.(*rtc.MultiTrackTunnel)
if !ok {
return tun
}
wrapped := tunnel.NewMultiTrackKCPTunnel(vp8, s.cfg.Logger)
wrapped := rtc.NewMultiTrackKCPTunnel(vp8, s.cfg.Logger)
s.mu.Lock()
if s.kcptun != nil {
s.kcptun.StopLayer()
@@ -462,7 +455,7 @@ func (s *Session) maybeWrapReliable(tun tunnel.DataTunnel) tunnel.DataTunnel {
return wrapped
}
func (s *Session) currentVP8Tun() *tunnel.MultiTrackTunnel {
func (s *Session) currentVP8Tun() *rtc.MultiTrackTunnel {
s.mu.Lock()
defer s.mu.Unlock()
return s.vp8tun
@@ -496,17 +489,12 @@ func (s *Session) removePublisherTrack() bool {
}
func (s *Session) addPublisherTrack(pubPC *webrtc.PeerConnection, slot int) bool {
labelPrefix := "screenchannel-"
streamPrefix := "tunnel-screen-"
source := livekit.TrackSourceScreenShare
if slot == 0 {
labelPrefix = "videochannel-"
streamPrefix = "tunnel-video-"
source = livekit.TrackSourceCamera
}
track, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000},
labelPrefix+uuid.New().String(), streamPrefix+uuid.New().String(),
)
if err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: new track slot=%d: %v", slot, err))
@@ -518,6 +506,7 @@ func (s *Session) addPublisherTrack(pubPC *webrtc.PeerConnection, slot int) bool
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: add transceiver slot=%d: %v", slot, err))
return false
}
go rtc.DrainSenderRTCP(trx.Sender())
if err := s.lk.SendAddTrack(track.ID(), "videochannel",
livekit.TrackTypeVideo, source, 1280, 720); err != nil {
s.cfg.Logger.Error(fmt.Sprintf("[lk] adapt-track-count: send add-track slot=%d: %v", slot, err))
@@ -530,7 +519,7 @@ func (s *Session) addPublisherTrack(pubPC *webrtc.PeerConnection, slot int) bool
kcptun := s.kcptun
s.mu.Unlock()
if vp8 != nil {
newSub := tunnel.NewVP8DataTunnelWithQueue(track, s.cfg.Obfuscator, s.cfg.Logger, tunnel.KCPCarrierQueueDepth)
newSub := rtc.NewVP8DataTunnelWithQueue(track, s.cfg.Obfuscator, s.cfg.Logger, rtc.KCPCarrierQueueDepth)
vp8.AddSubTunnel(newSub)
if kcptun != nil {
kcptun.AddSession(newSub)
@@ -563,14 +552,7 @@ func (s *Session) rearmAutoDetect() {
func (s *Session) onRemoteTrack(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
go func() {
buf := make([]byte, common.UDPBufSize)
for {
if _, _, err := track.Read(buf); err != nil {
return
}
}
}()
go rtc.DrainTrack(track)
return
}
go s.readVP8Track(track)
+31 -25
View File
@@ -5,7 +5,6 @@ import (
"bytes"
"compress/flate"
"context"
"crypto/tls"
"fmt"
"io"
"net"
@@ -14,9 +13,10 @@ import (
"sync"
"time"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
"github.com/quic-go/quic-go/quicvarint"
headless "github.com/kulikov0/headless-client"
"github.com/kulikov0/headless-client/quic"
"github.com/kulikov0/headless-client/quic/http3"
"github.com/kulikov0/headless-client/quic/quicvarint"
)
const (
@@ -28,6 +28,11 @@ const (
webTransportFrameType uint64 = 0x41
webTransportUniStreamType uint64 = 0x54
settingsQPACKMaxTableCapacity = 0x01
settingsMaxFieldSectionSize = 0x06
settingsQPACKBlockedStreams = 0x07
settingsDatagram = 0x33
settingsDatagramDraft04 = 0xffd277
settingsEnableWebtransportDraft06 = 0x2b603742
settingsWebTransportEnabled = 0x2c7cf000
settingsWebTransportMaxSessions = 0x14e9cd29
@@ -46,7 +51,7 @@ type Conn struct {
writeMu sync.Mutex
}
func Dial(endpoint, serverName, resolvedIP string) (*Conn, error) {
func Dial(endpoint, serverName, resolvedIP, origin string) (*Conn, error) {
target, err := url.Parse(endpoint)
if err != nil {
return nil, err
@@ -57,34 +62,35 @@ func Dial(endpoint, serverName, resolvedIP string) (*Conn, error) {
}
compress := target.Query().Get("compression") == "deflate-raw"
tlsConf := &tls.Config{
InsecureSkipVerify: true,
ServerName: serverName,
NextProtos: []string{"h3"},
}
quicConf := &quic.Config{
EnableDatagrams: true,
EnableStreamResetPartialDelivery: true,
KeepAlivePeriod: keepAlivePeriod,
MaxIdleTimeout: maxIdleTimeout,
}
dialCtx, cancel := context.WithTimeout(context.Background(), dialTimeout)
defer cancel()
qconn, err := quic.DialAddrEarly(dialCtx, net.JoinHostPort(resolvedIP, port), tlsConf, quicConf)
qconn, err := headless.ChromeWindows.DialQUIC(dialCtx, net.JoinHostPort(resolvedIP, port), headless.QUICOptions{
Transport: headless.QUICWebTransport,
ServerName: serverName,
InsecureSkipVerify: true,
EnableDatagrams: true,
KeepAlivePeriod: keepAlivePeriod,
MaxIdleTimeout: maxIdleTimeout,
})
if err != nil {
return nil, fmt.Errorf("wt dial: %w", err)
}
// The VK/OK SFU advertises the HTTP/3 datagram setting but does not
// negotiate QUIC transport-level datagrams, which makes quic-go's http3
// layer close the connection. Signaling only uses WebTransport streams, so
// disable HTTP/3 datagrams on our side, and send the draft-06
// ENABLE_WEBTRANSPORT codepoint the SFU expects.
// The SFU advertises HTTP/3 datagrams but never negotiates them at the QUIC
// layer, and only EnableDatagrams feeds the check that closes the connection.
tr := &http3.Transport{
EnableDatagrams: false,
AdditionalSettings: map[uint64]uint64{settingsEnableWebtransportDraft06: 1},
SendGreaseFrames: true,
DisableCompression: true,
AdditionalSettings: map[uint64]uint64{
settingsQPACKMaxTableCapacity: 65536,
settingsMaxFieldSectionSize: 16384,
settingsQPACKBlockedStreams: 100,
settingsDatagram: 1,
settingsDatagramDraft04: 1,
settingsEnableWebtransportDraft06: 1,
},
}
control := tr.NewRawClientConn(qconn)
context.AfterFunc(qconn.Context(), func() { tr.Close() })
@@ -120,7 +126,7 @@ func Dial(endpoint, serverName, resolvedIP string) (*Conn, error) {
req := (&http.Request{
Method: http.MethodConnect,
Header: http.Header{},
Header: headless.ChromeWindows.WebTransportConnectHeader(origin),
Proto: protocolHeaderLegacy,
Host: target.Host,
URL: target,