From 311d9b51ebed40d232916aed6d317e8417437ca1 Mon Sep 17 00:00:00 2001 From: Shtorm <108103062+shtorm-7@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:57:51 +0300 Subject: [PATCH] call: Update vk, telemost, dion and wbstream implementations --- go.mod | 13 +- go.sum | 28 +- transport/call/bitrix/api.go | 665 ++++++++++++++++++ transport/call/bitrix/auth.go | 238 +++++++ transport/call/bitrix/connect.go | 65 ++ transport/call/bitrix/joiner.go | 348 +++++++++ transport/call/bitrix/kick.go | 40 ++ transport/call/bitrix/mediasession.go | 500 +++++++++++++ transport/call/bitrix/pull.go | 221 ++++++ transport/call/bitrix/session.go | 127 ++++ transport/call/bitrix/signal.go | 228 ++++++ transport/call/common/deps.go | 9 +- transport/call/common/device.go | 208 ++++++ transport/call/common/http.go | 57 +- transport/call/common/ice.go | 52 +- transport/call/common/signaling.go | 20 +- transport/call/common/ws.go | 2 +- transport/call/config.go | 30 +- transport/call/dion/api.go | 15 +- transport/call/dion/call.go | 52 +- transport/call/dion/connect.go | 48 +- transport/call/dion/cookies.go | 2 +- transport/call/dion/device.go | 169 +---- transport/call/dion/joiner.go | 184 +++++ transport/call/dion/pion_offer.go | 76 +- transport/call/dion/signaling.go | 19 +- transport/call/headlessapi/webrtc.go | 71 ++ transport/call/livekit/client.go | 361 +++++----- transport/call/livekit/codec.go | 332 +++++++++ transport/call/livekit/dc_packet.go | 52 ++ transport/call/livekit/messages.go | 1 + transport/call/telemost/api.go | 166 +++-- transport/call/telemost/connect.go | 11 +- transport/call/telemost/creator.go | 252 ++++--- transport/call/telemost/joiner.go | 294 ++++---- transport/call/telemost/relay.go | 185 ++++- transport/call/tunnel/obfuscator.go | 6 +- transport/call/tunnel/protocol.go | 2 + transport/call/tunnel/relay_bridge.go | 17 + transport/call/tunnel/{ => rtc}/dctunnel.go | 27 +- .../tunnel/{ => rtc}/dctunnel_bench_test.go | 6 +- transport/call/tunnel/rtc/drain.go | 23 + .../{ => rtc}/multi_track_kcp_tunnel.go | 56 +- .../tunnel/{ => rtc}/multi_track_tunnel.go | 2 +- .../call/tunnel/{ => rtc}/screen_tunnel.go | 23 +- transport/call/tunnel/{ => rtc}/vp8tunnel.go | 171 +++-- transport/call/tunnel/wstunnel.go | 78 ++ transport/call/vk/api.go | 24 +- transport/call/vk/connect.go | 24 +- transport/call/vk/creator.go | 156 ++-- transport/call/vk/joiner.go | 116 +-- transport/call/vk/p2p.go | 34 +- transport/call/vk/relay.go | 30 +- transport/call/vk/screen_datachannels.go | 13 +- transport/call/vk/vk_auth.go | 26 +- transport/call/wbstream/api.go | 144 +++- transport/call/wbstream/connect.go | 48 +- transport/call/wbstream/dc_packet.go | 53 -- transport/call/wbstream/joiner.go | 50 +- transport/call/wbstream/session.go | 158 ++--- transport/call/wtsignal/wtsignal.go | 56 +- 61 files changed, 5121 insertions(+), 1363 deletions(-) create mode 100644 transport/call/bitrix/api.go create mode 100644 transport/call/bitrix/auth.go create mode 100644 transport/call/bitrix/connect.go create mode 100644 transport/call/bitrix/joiner.go create mode 100644 transport/call/bitrix/kick.go create mode 100644 transport/call/bitrix/mediasession.go create mode 100644 transport/call/bitrix/pull.go create mode 100644 transport/call/bitrix/session.go create mode 100644 transport/call/bitrix/signal.go create mode 100644 transport/call/common/device.go create mode 100644 transport/call/dion/joiner.go create mode 100644 transport/call/headlessapi/webrtc.go create mode 100644 transport/call/livekit/codec.go create mode 100644 transport/call/livekit/dc_packet.go rename transport/call/tunnel/{ => rtc}/dctunnel.go (87%) rename transport/call/tunnel/{ => rtc}/dctunnel_bench_test.go (94%) create mode 100644 transport/call/tunnel/rtc/drain.go rename transport/call/tunnel/{ => rtc}/multi_track_kcp_tunnel.go (89%) rename transport/call/tunnel/{ => rtc}/multi_track_tunnel.go (99%) rename transport/call/tunnel/{ => rtc}/screen_tunnel.go (90%) rename transport/call/tunnel/{ => rtc}/vp8tunnel.go (66%) create mode 100644 transport/call/tunnel/wstunnel.go delete mode 100644 transport/call/wbstream/dc_packet.go diff --git a/go.mod b/go.mod index b2acbe6a..c73e29a8 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index d90769c3..9f6e59a7 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/transport/call/bitrix/api.go b/transport/call/bitrix/api.go new file mode 100644 index 00000000..b946dcab --- /dev/null +++ b/transport/call/bitrix/api.go @@ -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 +} diff --git a/transport/call/bitrix/auth.go b/transport/call/bitrix/auth.go new file mode 100644 index 00000000..f972acb6 --- /dev/null +++ b/transport/call/bitrix/auth.go @@ -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"[^>]*>(\{[^<]*\})`) + 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") +} diff --git a/transport/call/bitrix/connect.go b/transport/call/bitrix/connect.go new file mode 100644 index 00000000..4231cb5e --- /dev/null +++ b/transport/call/bitrix/connect.go @@ -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 +} diff --git a/transport/call/bitrix/joiner.go b/transport/call/bitrix/joiner.go new file mode 100644 index 00000000..e08ee28d --- /dev/null +++ b/transport/call/bitrix/joiner.go @@ -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), ¶ms); 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 +} diff --git a/transport/call/bitrix/kick.go b/transport/call/bitrix/kick.go new file mode 100644 index 00000000..d8fa0556 --- /dev/null +++ b/transport/call/bitrix/kick.go @@ -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) +} diff --git a/transport/call/bitrix/mediasession.go b/transport/call/bitrix/mediasession.go new file mode 100644 index 00000000..a69c7ba5 --- /dev/null +++ b/transport/call/bitrix/mediasession.go @@ -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 + } +} diff --git a/transport/call/bitrix/pull.go b/transport/call/bitrix/pull.go new file mode 100644 index 00000000..e120424e --- /dev/null +++ b/transport/call/bitrix/pull.go @@ -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 +} diff --git a/transport/call/bitrix/session.go b/transport/call/bitrix/session.go new file mode 100644 index 00000000..7b4378ed --- /dev/null +++ b/transport/call/bitrix/session.go @@ -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") +} diff --git a/transport/call/bitrix/signal.go b/transport/call/bitrix/signal.go new file mode 100644 index 00000000..c0b959ac --- /dev/null +++ b/transport/call/bitrix/signal.go @@ -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 +} diff --git a/transport/call/common/deps.go b/transport/call/common/deps.go index 14d77196..0e7db8c1 100644 --- a/transport/call/common/deps.go +++ b/transport/call/common/deps.go @@ -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) +) diff --git a/transport/call/common/device.go b/transport/call/common/device.go new file mode 100644 index 00000000..64aed715 --- /dev/null +++ b/transport/call/common/device.go @@ -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], + } +} diff --git a/transport/call/common/http.go b/transport/call/common/http.go index c3204228..f0d8ffdb 100644 --- a/transport/call/common/http.go +++ b/transport/call/common/http.go @@ -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) } } diff --git a/transport/call/common/ice.go b/transport/call/common/ice.go index 2d3ac6e0..793dfe91 100644 --- a/transport/call/common/ice.go +++ b/transport/call/common/ice.go @@ -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 +} diff --git a/transport/call/common/signaling.go b/transport/call/common/signaling.go index 598ad2b9..53057256 100644 --- a/transport/call/common/signaling.go +++ b/transport/call/common/signaling.go @@ -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) diff --git a/transport/call/common/ws.go b/transport/call/common/ws.go index 1e668732..7567c7f0 100644 --- a/transport/call/common/ws.go +++ b/transport/call/common/ws.go @@ -3,7 +3,7 @@ package common import ( "time" - "github.com/gorilla/websocket" + "github.com/kulikov0/headless-client/websocket" ) func CloseWS(ws *websocket.Conn) { diff --git a/transport/call/config.go b/transport/call/config.go index b91c1bff..29598bb5 100644 --- a/transport/call/config.go +++ b/transport/call/config.go @@ -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 } } diff --git a/transport/call/dion/api.go b/transport/call/dion/api.go index f15b6de0..596ef4a7 100644 --- a/transport/call/dion/api.go +++ b/transport/call/dion/api.go @@ -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 != "" { diff --git a/transport/call/dion/call.go b/transport/call/dion/call.go index 25c52da2..a9486a87 100644 --- a/transport/call/dion/call.go +++ b/transport/call/dion/call.go @@ -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 - } - } -} diff --git a/transport/call/dion/connect.go b/transport/call/dion/connect.go index 39ca7fa9..5cb11151 100644 --- a/transport/call/dion/connect.go +++ b/transport/call/dion/connect.go @@ -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() } } diff --git a/transport/call/dion/cookies.go b/transport/call/dion/cookies.go index ea0e8afc..30fd03b3 100644 --- a/transport/call/dion/cookies.go +++ b/transport/call/dion/cookies.go @@ -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 diff --git a/transport/call/dion/device.go b/transport/call/dion/device.go index 9bf70bf5..3a3bc139 100644 --- a/transport/call/dion/device.go +++ b/transport/call/dion/device.go @@ -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" -} diff --git a/transport/call/dion/joiner.go b/transport/call/dion/joiner.go new file mode 100644 index 00000000..271ba554 --- /dev/null +++ b/transport/call/dion/joiner.go @@ -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), ¶ms); 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 +} diff --git a/transport/call/dion/pion_offer.go b/transport/call/dion/pion_offer.go index ba6fefe7..211cf935 100644 --- a/transport/call/dion/pion_offer.go +++ b/transport/call/dion/pion_offer.go @@ -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 -} diff --git a/transport/call/dion/signaling.go b/transport/call/dion/signaling.go index 7d20eb6f..b77a717f 100644 --- a/transport/call/dion/signaling.go +++ b/transport/call/dion/signaling.go @@ -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) diff --git a/transport/call/headlessapi/webrtc.go b/transport/call/headlessapi/webrtc.go new file mode 100644 index 00000000..1eeed0c3 --- /dev/null +++ b/transport/call/headlessapi/webrtc.go @@ -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 +} diff --git a/transport/call/livekit/client.go b/transport/call/livekit/client.go index 758c13b8..daff8cac 100644 --- a/transport/call/livekit/client.go +++ b/transport/call/livekit/client.go @@ -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)) } } diff --git a/transport/call/livekit/codec.go b/transport/call/livekit/codec.go new file mode 100644 index 00000000..2e329fa3 --- /dev/null +++ b/transport/call/livekit/codec.go @@ -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 } diff --git a/transport/call/livekit/dc_packet.go b/transport/call/livekit/dc_packet.go new file mode 100644 index 00000000..69672759 --- /dev/null +++ b/transport/call/livekit/dc_packet.go @@ -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() } diff --git a/transport/call/livekit/messages.go b/transport/call/livekit/messages.go index 06a49499..b5f00f01 100644 --- a/transport/call/livekit/messages.go +++ b/transport/call/livekit/messages.go @@ -155,6 +155,7 @@ type joinResponse struct { RoomName string ParticipantSID string ParticipantID string + LocalUserID string ServerVersion string ServerRegion string ICEServers []iceServer diff --git a/transport/call/telemost/api.go b/transport/call/telemost/api.go index d80899ac..184c6d99 100644 --- a/transport/call/telemost/api.go +++ b/transport/call/telemost/api.go @@ -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{}, }, } } diff --git a/transport/call/telemost/connect.go b/transport/call/telemost/connect.go index 8f1dbbcb..47a1eda3 100644 --- a/transport/call/telemost/connect.go +++ b/transport/call/telemost/connect.go @@ -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() diff --git a/transport/call/telemost/creator.go b/transport/call/telemost/creator.go index 11b0bc01..b0a0a311 100644 --- a/transport/call/telemost/creator.go +++ b/transport/call/telemost/creator.go @@ -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) } } } diff --git a/transport/call/telemost/joiner.go b/transport/call/telemost/joiner.go index a0c018f6..cb73e535 100644 --- a/transport/call/telemost/joiner.go +++ b/transport/call/telemost/joiner.go @@ -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), ¶ms); 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{}}) } } }() diff --git a/transport/call/telemost/relay.go b/transport/call/telemost/relay.go index 69324cd8..b9f97dcb 100644 --- a/transport/call/telemost/relay.go +++ b/transport/call/telemost/relay.go @@ -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() } diff --git a/transport/call/tunnel/obfuscator.go b/transport/call/tunnel/obfuscator.go index 9fe4da8b..eaeeaf56 100644 --- a/transport/call/tunnel/obfuscator.go +++ b/transport/call/tunnel/obfuscator.go @@ -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 diff --git a/transport/call/tunnel/protocol.go b/transport/call/tunnel/protocol.go index f23937be..eb0cbbd8 100644 --- a/transport/call/tunnel/protocol.go +++ b/transport/call/tunnel/protocol.go @@ -12,6 +12,8 @@ const ( MsgUDPReply byte = 0x07 MsgConfig byte = 0x08 MsgConfigAck byte = 0x09 + + WireHeaderLen = 4 + 1 ) const ControlConnID uint32 = 0 diff --git a/transport/call/tunnel/relay_bridge.go b/transport/call/tunnel/relay_bridge.go index 61d7fe2d..939450ee 100644 --- a/transport/call/tunnel/relay_bridge.go +++ b/transport/call/tunnel/relay_bridge.go @@ -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 { diff --git a/transport/call/tunnel/dctunnel.go b/transport/call/tunnel/rtc/dctunnel.go similarity index 87% rename from transport/call/tunnel/dctunnel.go rename to transport/call/tunnel/rtc/dctunnel.go index f7f93229..c11b37e9 100644 --- a/transport/call/tunnel/dctunnel.go +++ b/transport/call/tunnel/rtc/dctunnel.go @@ -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) diff --git a/transport/call/tunnel/dctunnel_bench_test.go b/transport/call/tunnel/rtc/dctunnel_bench_test.go similarity index 94% rename from transport/call/tunnel/dctunnel_bench_test.go rename to transport/call/tunnel/rtc/dctunnel_bench_test.go index d520d202..e00688ca 100644 --- a/transport/call/tunnel/dctunnel_bench_test.go +++ b/transport/call/tunnel/rtc/dctunnel_bench_test.go @@ -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() diff --git a/transport/call/tunnel/rtc/drain.go b/transport/call/tunnel/rtc/drain.go new file mode 100644 index 00000000..2000e055 --- /dev/null +++ b/transport/call/tunnel/rtc/drain.go @@ -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 + } + } +} diff --git a/transport/call/tunnel/multi_track_kcp_tunnel.go b/transport/call/tunnel/rtc/multi_track_kcp_tunnel.go similarity index 89% rename from transport/call/tunnel/multi_track_kcp_tunnel.go rename to transport/call/tunnel/rtc/multi_track_kcp_tunnel.go index e37f30af..23771330 100644 --- a/transport/call/tunnel/multi_track_kcp_tunnel.go +++ b/transport/call/tunnel/rtc/multi_track_kcp_tunnel.go @@ -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 { diff --git a/transport/call/tunnel/multi_track_tunnel.go b/transport/call/tunnel/rtc/multi_track_tunnel.go similarity index 99% rename from transport/call/tunnel/multi_track_tunnel.go rename to transport/call/tunnel/rtc/multi_track_tunnel.go index 97004f8c..54f1ee70 100644 --- a/transport/call/tunnel/multi_track_tunnel.go +++ b/transport/call/tunnel/rtc/multi_track_tunnel.go @@ -1,4 +1,4 @@ -package tunnel +package rtc import ( "encoding/binary" diff --git a/transport/call/tunnel/screen_tunnel.go b/transport/call/tunnel/rtc/screen_tunnel.go similarity index 90% rename from transport/call/tunnel/screen_tunnel.go rename to transport/call/tunnel/rtc/screen_tunnel.go index 224f1c8a..8fa5b018 100644 --- a/transport/call/tunnel/screen_tunnel.go +++ b/transport/call/tunnel/rtc/screen_tunnel.go @@ -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 diff --git a/transport/call/tunnel/vp8tunnel.go b/transport/call/tunnel/rtc/vp8tunnel.go similarity index 66% rename from transport/call/tunnel/vp8tunnel.go rename to transport/call/tunnel/rtc/vp8tunnel.go index ba63ca24..3e2a80fe 100644 --- a/transport/call/tunnel/vp8tunnel.go +++ b/transport/call/tunnel/rtc/vp8tunnel.go @@ -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() } } diff --git a/transport/call/tunnel/wstunnel.go b/transport/call/tunnel/wstunnel.go new file mode 100644 index 00000000..7e3b8351 --- /dev/null +++ b/transport/call/tunnel/wstunnel.go @@ -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) {} diff --git a/transport/call/vk/api.go b/transport/call/vk/api.go index c0dfba33..d2b57420 100644 --- a/transport/call/vk/api.go +++ b/transport/call/vk/api.go @@ -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) } diff --git a/transport/call/vk/connect.go b/transport/call/vk/connect.go index 5e2a7c27..5bd69880 100644 --- a/transport/call/vk/connect.go +++ b/transport/call/vk/connect.go @@ -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() diff --git a/transport/call/vk/creator.go b/transport/call/vk/creator.go index 21a177b2..bd08dc91 100644 --- a/transport/call/vk/creator.go +++ b/transport/call/vk/creator.go @@ -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)) diff --git a/transport/call/vk/joiner.go b/transport/call/vk/joiner.go index 87faff12..03e6fcbb 100644 --- a/transport/call/vk/joiner.go +++ b/transport/call/vk/joiner.go @@ -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) -} diff --git a/transport/call/vk/p2p.go b/transport/call/vk/p2p.go index 7ab08a8e..18809bb2 100644 --- a/transport/call/vk/p2p.go +++ b/transport/call/vk/p2p.go @@ -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 { diff --git a/transport/call/vk/relay.go b/transport/call/vk/relay.go index 2d01ee02..c86bdb5a 100644 --- a/transport/call/vk/relay.go +++ b/transport/call/vk/relay.go @@ -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 diff --git a/transport/call/vk/screen_datachannels.go b/transport/call/vk/screen_datachannels.go index 8847918b..6d89157d 100644 --- a/transport/call/vk/screen_datachannels.go +++ b/transport/call/vk/screen_datachannels.go @@ -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") diff --git a/transport/call/vk/vk_auth.go b/transport/call/vk/vk_auth.go index 9c0982bd..0f250f9e 100644 --- a/transport/call/vk/vk_auth.go +++ b/transport/call/vk/vk_auth.go @@ -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 diff --git a/transport/call/wbstream/api.go b/transport/call/wbstream/api.go index 115b1a82..34c670f6 100644 --- a/transport/call/wbstream/api.go +++ b/transport/call/wbstream/api.go @@ -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} diff --git a/transport/call/wbstream/connect.go b/transport/call/wbstream/connect.go index 3797a804..ee661771 100644 --- a/transport/call/wbstream/connect.go +++ b/transport/call/wbstream/connect.go @@ -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 diff --git a/transport/call/wbstream/dc_packet.go b/transport/call/wbstream/dc_packet.go deleted file mode 100644 index 6cd88456..00000000 --- a/transport/call/wbstream/dc_packet.go +++ /dev/null @@ -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} -} diff --git a/transport/call/wbstream/joiner.go b/transport/call/wbstream/joiner.go index 9ff0ebff..57e9c6f4 100644 --- a/transport/call/wbstream/joiner.go +++ b/transport/call/wbstream/joiner.go @@ -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()}), + } } diff --git a/transport/call/wbstream/session.go b/transport/call/wbstream/session.go index 68b1e96f..98c33614 100644 --- a/transport/call/wbstream/session.go +++ b/transport/call/wbstream/session.go @@ -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) diff --git a/transport/call/wtsignal/wtsignal.go b/transport/call/wtsignal/wtsignal.go index 5dd29911..3c01316f 100644 --- a/transport/call/wtsignal/wtsignal.go +++ b/transport/call/wtsignal/wtsignal.go @@ -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,