mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-09-23 00:16:54 +00:00
call: Update vk, telemost, dion and wbstream implementations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package bitrix
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
|
||||
headless "github.com/kulikov0/headless-client"
|
||||
)
|
||||
|
||||
const authNetBase = "https://auth2.bitrix24.net"
|
||||
|
||||
var (
|
||||
reFlowCfg = regexp.MustCompile(`b24network\.security\.flowtoken"[^>]*>(\{[^<]*\})</script>`)
|
||||
reJunk = regexp.MustCompile(`[!#$%@~]`)
|
||||
reJSRedirect = regexp.MustCompile(`window\.location\.href\s*=\s*['"]([^'"]+)['"]`)
|
||||
)
|
||||
|
||||
func (c *Client) SetCredentials(email, password string) {
|
||||
c.email = strings.TrimSpace(email)
|
||||
c.password = password
|
||||
}
|
||||
|
||||
func (c *Client) loginWithPassword(email, password string) error {
|
||||
if email == "" || password == "" {
|
||||
return fmt.Errorf("login: no email/password")
|
||||
}
|
||||
if err := c.resetJar(); err != nil {
|
||||
return err
|
||||
}
|
||||
flowToken, sessid, currentURI, err := c.bootstrapLogin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Logger.Debug(fmt.Sprintf("[auth] bootstrap: flowToken=%dB sessid=%v authURL=%v", len(flowToken), sessid != "", strings.Contains(currentURI, "auth2.bitrix24.net")))
|
||||
if err := c.authCheckLogin(flowToken, email, sessid, currentURI); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Logger.Debug("[auth] checkLogin OK")
|
||||
if err := c.authCheck(flowToken, email, password, sessid); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Logger.Debug("[auth] check OK")
|
||||
c.email = email
|
||||
c.password = password
|
||||
return c.completeOAuth()
|
||||
}
|
||||
|
||||
func (c *Client) bootstrapLogin() (flowToken, sessid, currentURI string, err error) {
|
||||
req, err := http.NewRequest("GET", c.portal+"/", nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
req.Header = headless.ChromeWindows.Headers(headless.DestDocument)
|
||||
if c.userAgent != "" {
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
page := string(body)
|
||||
currentURI = resp.Request.URL.String()
|
||||
if !strings.Contains(currentURI, "auth2.bitrix24.net") {
|
||||
return "", "", "", fmt.Errorf("portal did not redirect to auth2 (landed on %s)", currentURI)
|
||||
}
|
||||
flowToken = computeFlowToken(page)
|
||||
if m := reSessid.FindStringSubmatch(page); m != nil {
|
||||
sessid = m[1]
|
||||
}
|
||||
return flowToken, sessid, currentURI, nil
|
||||
}
|
||||
|
||||
func computeFlowToken(page string) string {
|
||||
m := reFlowCfg.FindStringSubmatch(page)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
var cfg struct {
|
||||
FTV string `json:"ftv"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(m[1]), &cfg); err != nil {
|
||||
return ""
|
||||
}
|
||||
return reJunk.ReplaceAllString(cfg.FTV, "")
|
||||
}
|
||||
|
||||
func (c *Client) authCheckLogin(flowToken, login, sessid, currentURI string) error {
|
||||
form := url.Values{}
|
||||
form.Set("flow-token-unique-id", flowToken)
|
||||
form.Set("login", login)
|
||||
form.Set("currentUri", currentURI)
|
||||
form.Set("checkSocserv", "Y")
|
||||
body, status, err := c.authDo("b24network.authorize.checkLogin", form, sessid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out struct {
|
||||
Status string `json:"status"`
|
||||
Data []struct {
|
||||
HasAccess bool `json:"HAS_ACCESS"`
|
||||
Captcha any `json:"CAPTCHA"`
|
||||
} `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return fmt.Errorf("checkLogin: %w (status %d, body %s)", err, status, common.BodySnippet(body))
|
||||
}
|
||||
if out.Status != "success" || len(out.Data) == 0 {
|
||||
return fmt.Errorf("login rejected: checkLogin (status %d, errors %+v, body %s)", status, out.Errors, common.BodySnippet(body))
|
||||
}
|
||||
if out.Data[0].Captcha != nil {
|
||||
return fmt.Errorf("login rejected: captcha required at login step")
|
||||
}
|
||||
if !out.Data[0].HasAccess {
|
||||
return fmt.Errorf("login rejected: account has no access")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) authCheck(flowToken, login, password, sessid string) error {
|
||||
form := url.Values{}
|
||||
form.Set("flow-token-unique-id", flowToken)
|
||||
form.Set("login", login)
|
||||
form.Set("password", password)
|
||||
form.Set("remember", "Y")
|
||||
body, status, err := c.authDo("b24network.authorize.check", form, sessid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out struct {
|
||||
Status string `json:"status"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return fmt.Errorf("check: %w (status %d, body %s)", err, status, common.BodySnippet(body))
|
||||
}
|
||||
if out.Status != "success" || len(out.Errors) > 0 {
|
||||
return fmt.Errorf("login rejected: password check (status %d, errors %+v, body %s)", status, out.Errors, common.BodySnippet(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) authDo(action string, form url.Values, sessid string) ([]byte, int, error) {
|
||||
endpoint := authNetBase + "/bitrix/services/main/ajax.php?action=" + action
|
||||
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header = headless.ChromeWindows.Headers(headless.DestEmpty)
|
||||
if c.userAgent != "" {
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("Origin", authNetBase)
|
||||
req.Header.Set("Referer", authNetBase+"/authorization/")
|
||||
req.Header.Set("Sec-Fetch-Site", secFetchSiteFor(endpoint, authNetBase))
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
if sessid != "" {
|
||||
req.Header.Set("X-Bitrix-Csrf-Token", sessid)
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
return body, resp.StatusCode, err
|
||||
}
|
||||
|
||||
func (c *Client) getPage(target string) (body, final string, err error) {
|
||||
req, err := http.NewRequest("GET", target, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header = headless.ChromeWindows.Headers(headless.DestDocument)
|
||||
if c.userAgent != "" {
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return string(raw), resp.Request.URL.String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) completeOAuth() error {
|
||||
target := c.portal + "/"
|
||||
for hop := range 8 {
|
||||
body, final, err := c.getPage(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Logger.Debug(fmt.Sprintf("[auth] oauth hop %d: final=%s len=%d", hop, final, len(body)))
|
||||
if next := reJSRedirect.FindStringSubmatch(body); next != nil {
|
||||
target = next[1]
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(final, c.portal) {
|
||||
if m := reSessid.FindStringSubmatch(body); m != nil {
|
||||
c.sessid = m[1]
|
||||
return nil
|
||||
}
|
||||
}
|
||||
target = c.portal + "/"
|
||||
body, final, err = c.getPage(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(final, c.portal) {
|
||||
return fmt.Errorf("oauth handoff landed outside the portal, final=%s", final)
|
||||
}
|
||||
if m := reSessid.FindStringSubmatch(body); m != nil {
|
||||
c.sessid = m[1]
|
||||
return nil
|
||||
}
|
||||
c.sessid = ""
|
||||
c.Logger.Debug("[auth] no csrf token on the portal page, it will be taken from the first response")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("oauth handoff did not converge")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,7 +3,7 @@ package common
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/kulikov0/headless-client/websocket"
|
||||
)
|
||||
|
||||
func CloseWS(ws *websocket.Conn) {
|
||||
|
||||
+16
-14
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
"github.com/google/uuid"
|
||||
headless "github.com/kulikov0/headless-client"
|
||||
)
|
||||
|
||||
var ErrSessionExpired = errors.New("dion: session expired, re-login required")
|
||||
@@ -94,7 +96,7 @@ type WSSConnectResponse struct {
|
||||
|
||||
type Session struct {
|
||||
HTTPClient *http.Client
|
||||
Device DeviceProfile
|
||||
Device common.DeviceProfile
|
||||
AccessToken string
|
||||
AccessTokenExp time.Time
|
||||
UserID string
|
||||
@@ -119,7 +121,7 @@ func NewSession(dialer N.Dialer) (*Session, error) {
|
||||
}
|
||||
httpClient := common.HttpClient(dialer)
|
||||
httpClient.Jar = jar
|
||||
return &Session{HTTPClient: httpClient, Device: RandomDeviceProfile()}, nil
|
||||
return &Session{HTTPClient: httpClient, Device: common.ChromeWindowsDeviceProfile(headless.ChromeWindows.UserAgent())}, nil
|
||||
}
|
||||
|
||||
func (s *Session) RegisterGuest() (*GuestAuthResponse, error) {
|
||||
@@ -554,8 +556,13 @@ func (s *Session) setBaseHeaders(req *http.Request, accessToken string) {
|
||||
req.Header.Set("Referer", Origin+"/")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Accept-Language", "en")
|
||||
for name, values := range headless.ChromeWindows.Headers(headless.DestEmpty) {
|
||||
if strings.HasPrefix(name, "Sec-Fetch-") {
|
||||
req.Header[name] = values
|
||||
}
|
||||
}
|
||||
req.Header.Set("X-Request-Id", uuid.New().String())
|
||||
for name, value := range s.Device.Headers() {
|
||||
for name, value := range deviceHeaders(s.Device) {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
if accessToken != "" {
|
||||
|
||||
+29
-23
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package headlessapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
headless "github.com/kulikov0/headless-client"
|
||||
"github.com/kulikov0/headless-client/webrtc"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Profile headless.Profile
|
||||
AnswerAsDTLSServer bool
|
||||
Configure func(*webrtc.SettingEngine)
|
||||
}
|
||||
|
||||
func resolveProfile(options Options) headless.Profile {
|
||||
if options.Profile == (headless.Profile{}) {
|
||||
return headless.ChromeWindows
|
||||
}
|
||||
|
||||
return options.Profile
|
||||
}
|
||||
|
||||
func WebRTCSettingEngine(options Options) (webrtc.SettingEngine, error) {
|
||||
profile := resolveProfile(options)
|
||||
|
||||
settingEngine, err := profile.SettingEngine()
|
||||
if err != nil {
|
||||
return webrtc.SettingEngine{}, fmt.Errorf("build setting engine: %w", err)
|
||||
}
|
||||
if options.Configure != nil {
|
||||
options.Configure(&settingEngine)
|
||||
}
|
||||
if options.AnswerAsDTLSServer {
|
||||
if err := settingEngine.SetAnsweringDTLSRole(webrtc.DTLSRoleServer); err != nil {
|
||||
return webrtc.SettingEngine{}, fmt.Errorf("set answering dtls role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return settingEngine, nil
|
||||
}
|
||||
|
||||
func buildMediaEngine(options Options) (*webrtc.MediaEngine, error) {
|
||||
mediaEngine := &webrtc.MediaEngine{}
|
||||
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
|
||||
return nil, fmt.Errorf("register default codecs: %w", err)
|
||||
}
|
||||
if err := resolveProfile(options).RegisterHeaderExtensions(mediaEngine); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mediaEngine, nil
|
||||
}
|
||||
|
||||
func WebRTCAPI(options Options, apiOptions ...func(*webrtc.API)) (*webrtc.API, error) {
|
||||
settingEngine, err := WebRTCSettingEngine(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mediaEngine, err := buildMediaEngine(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allAPIOptions := make([]func(*webrtc.API), 0, len(apiOptions)+2)
|
||||
allAPIOptions = append(allAPIOptions, webrtc.WithMediaEngine(mediaEngine))
|
||||
allAPIOptions = append(allAPIOptions, apiOptions...)
|
||||
allAPIOptions = append(allAPIOptions, webrtc.WithSettingEngine(settingEngine))
|
||||
|
||||
return webrtc.NewAPI(allAPIOptions...), nil
|
||||
}
|
||||
+193
-168
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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() }
|
||||
@@ -155,6 +155,7 @@ type joinResponse struct {
|
||||
RoomName string
|
||||
ParticipantSID string
|
||||
ParticipantID string
|
||||
LocalUserID string
|
||||
ServerVersion string
|
||||
ServerRegion string
|
||||
ICEServers []iceServer
|
||||
|
||||
+104
-62
@@ -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{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -5,20 +5,21 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
"github.com/google/uuid"
|
||||
headless "github.com/kulikov0/headless-client"
|
||||
"github.com/kulikov0/headless-client/webrtc"
|
||||
"github.com/kulikov0/headless-client/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -58,12 +59,15 @@ type Bridge struct {
|
||||
|
||||
setSlotsKey int
|
||||
initBundleSent bool
|
||||
shareMu sync.Mutex
|
||||
shareEnabled bool
|
||||
pendingKicks map[string]chan struct{}
|
||||
boundPeers map[string]bool
|
||||
unboundPeers map[string]bool
|
||||
seenPids map[string]bool
|
||||
}
|
||||
|
||||
func tmRequest(dialer N.Dialer, method, path string, body interface{}, cookieStr string, cfg TMConfig) ([]byte, int, error) {
|
||||
func tmRequest(dialer N.Dialer, method, path string, body any, cookieStr string, cfg TMConfig) ([]byte, int, error) {
|
||||
c := Client{HTTP: common.HttpClient(dialer), Cookie: cookieStr, AppVersion: cfg.AppVersion, InstanceID: clientInstanceID}
|
||||
return c.Do(method, path, body)
|
||||
}
|
||||
@@ -168,7 +172,7 @@ func CreateAndJoinCall(dialer N.Dialer, cookieStr string, cfg TMConfig, logger l
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (b *Bridge) wsSend(msg interface{}) {
|
||||
func (b *Bridge) wsSend(msg any) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.ws == nil {
|
||||
@@ -179,10 +183,10 @@ func (b *Bridge) wsSend(msg interface{}) {
|
||||
}
|
||||
|
||||
func (b *Bridge) ack(uid string) {
|
||||
b.wsSend(map[string]interface{}{
|
||||
b.wsSend(map[string]any{
|
||||
"uid": uid,
|
||||
"ack": map[string]interface{}{
|
||||
"status": map[string]interface{}{"code": "OK", "description": ""},
|
||||
"ack": map[string]any{
|
||||
"status": map[string]any{"code": "OK", "description": ""},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -191,16 +195,16 @@ func (b *Bridge) sendHello() {
|
||||
b.mu.Lock()
|
||||
b.selfName = "Headless"
|
||||
b.mu.Unlock()
|
||||
b.wsSend(map[string]interface{}{
|
||||
b.wsSend(map[string]any{
|
||||
"uid": uuid.New().String(),
|
||||
"hello": map[string]interface{}{
|
||||
"participantMeta": map[string]interface{}{"name": "Headless", "role": "SPEAKER", "description": "", "sendAudio": false, "sendVideo": true},
|
||||
"participantAttributes": map[string]interface{}{"name": "Headless", "role": "SPEAKER", "description": ""},
|
||||
"hello": map[string]any{
|
||||
"participantMeta": map[string]any{"name": "Headless", "role": "SPEAKER", "description": "", "sendAudio": false, "sendVideo": true},
|
||||
"participantAttributes": map[string]any{"name": "Headless", "role": "SPEAKER", "description": ""},
|
||||
"sendAudio": false, "sendVideo": true, "sendSharing": false,
|
||||
"participantId": b.connInfo.PeerID, "roomId": b.connInfo.RoomID,
|
||||
"serviceName": b.connInfo.ServiceName, "credentials": b.connInfo.Credentials,
|
||||
"capabilitiesOffer": CapabilitiesOffer,
|
||||
"sdkInfo": map[string]interface{}{"implementation": "browser", "version": b.config.SDKVersion, "userAgent": common.UserAgent, "hwConcurrency": 8},
|
||||
"sdkInfo": map[string]any{"implementation": "browser", "version": b.config.SDKVersion, "userAgent": headless.ChromeWindows.UserAgent(), "hwConcurrency": 8},
|
||||
"sdkInitializationId": uuid.New().String(),
|
||||
"disablePublisher": false, "disableSubscriber": false, "disableSubscriberAudio": false,
|
||||
},
|
||||
@@ -214,21 +218,68 @@ func (b *Bridge) sendPubOffer() {
|
||||
b.logger.Warn(fmt.Sprintf("[tm-ws] pub offer failed: %v", err))
|
||||
return
|
||||
}
|
||||
audioMid, videoMid := parseMids(offer.SDP)
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] -> publisherSdpOffer pcSeq=%d", b.pubSeq))
|
||||
var tracks []map[string]interface{}
|
||||
if audioMid != "" {
|
||||
tracks = append(tracks, map[string]interface{}{"mid": audioMid, "transceiverMid": audioMid, "kind": "AUDIO", "priority": 0, "label": "", "codecs": map[string]interface{}{}, "groupId": 1, "description": ""})
|
||||
b.sendPublisherOffer(offer, false)
|
||||
}
|
||||
|
||||
func (b *Bridge) sendPublisherOffer(offer webrtc.SessionDescription, includeSharing bool) {
|
||||
audioMids, videoMids := parseMids(offer.SDP)
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] -> publisherSdpOffer pcSeq=%d includeSharing=%v audio=%v video=%v", b.pubSeq, includeSharing, audioMids, videoMids))
|
||||
var tracks []map[string]any
|
||||
if len(audioMids) > 0 {
|
||||
tracks = append(tracks, map[string]any{"mid": audioMids[0], "transceiverMid": audioMids[0], "kind": "AUDIO", "priority": 0, "label": "", "codecs": map[string]any{}, "groupId": 1, "description": ""})
|
||||
}
|
||||
if videoMid != "" {
|
||||
tracks = append(tracks, map[string]interface{}{"mid": videoMid, "transceiverMid": videoMid, "kind": "VIDEO", "priority": 0, "label": "", "codecs": map[string]interface{}{}, "groupId": 2, "description": ""})
|
||||
if len(videoMids) > 0 {
|
||||
tracks = append(tracks, map[string]any{"mid": videoMids[0], "transceiverMid": videoMids[0], "kind": "VIDEO", "priority": 0, "label": "", "codecs": map[string]any{}, "groupId": 1, "description": ""})
|
||||
}
|
||||
b.wsSend(map[string]interface{}{
|
||||
if includeSharing {
|
||||
tracks = append(tracks, DisplayVideoTrack("Screen"))
|
||||
}
|
||||
b.wsSend(map[string]any{
|
||||
"uid": uuid.New().String(),
|
||||
"publisherSdpOffer": map[string]interface{}{"pcSeq": b.pubSeq, "sdp": offer.SDP, "tracks": tracks},
|
||||
"publisherSdpOffer": map[string]any{"pcSeq": b.pubSeq, "sdp": offer.SDP, "tracks": tracks},
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bridge) enableScreenshare() {
|
||||
b.shareMu.Lock()
|
||||
defer b.shareMu.Unlock()
|
||||
if b.shareEnabled {
|
||||
return
|
||||
}
|
||||
b.shareEnabled = true
|
||||
b.logger.Debug("[ss] enabling screenshare: describe DISPLAY_VIDEO -> updateMe -> offer")
|
||||
if err := b.relay.AddSharingDataChannel(); err != nil {
|
||||
b.logger.Warn(fmt.Sprintf("[ss] AddSharingDataChannel: %v", err))
|
||||
return
|
||||
}
|
||||
b.wsSend(UpdatePublisherSharingTrackMessage("Screen"))
|
||||
b.wsSend(UpdateMeMessage(b.selfName, true, true))
|
||||
offer, err := b.relay.CreatePubRenegotiate()
|
||||
if err != nil {
|
||||
b.logger.Warn(fmt.Sprintf("[ss] enable renegotiate failed: %v", err))
|
||||
return
|
||||
}
|
||||
b.sendPublisherOffer(offer, true)
|
||||
}
|
||||
|
||||
func (b *Bridge) disableScreenshare() {
|
||||
b.shareMu.Lock()
|
||||
defer b.shareMu.Unlock()
|
||||
if !b.shareEnabled {
|
||||
return
|
||||
}
|
||||
b.shareEnabled = false
|
||||
b.logger.Debug("[ss] disabling screenshare (peer requested trackCount<2)")
|
||||
b.wsSend(UpdateMeMessage(b.selfName, true, false))
|
||||
b.relay.RemoveSharingDataChannel()
|
||||
offer, err := b.relay.CreatePubRenegotiate()
|
||||
if err != nil {
|
||||
b.logger.Warn(fmt.Sprintf("[ss] disable renegotiate failed: %v", err))
|
||||
return
|
||||
}
|
||||
b.sendPublisherOffer(offer, false)
|
||||
}
|
||||
|
||||
func (b *Bridge) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) {
|
||||
c := cand.ToJSON()
|
||||
mid := ""
|
||||
@@ -239,9 +290,9 @@ func (b *Bridge) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) {
|
||||
if c.SDPMLineIndex != nil {
|
||||
idx = *c.SDPMLineIndex
|
||||
}
|
||||
b.wsSend(map[string]interface{}{
|
||||
b.wsSend(map[string]any{
|
||||
"uid": uuid.New().String(),
|
||||
"webrtcIceCandidate": map[string]interface{}{
|
||||
"webrtcIceCandidate": map[string]any{
|
||||
"candidate": c.Candidate, "sdpMid": mid,
|
||||
"usernameFragment": extractUfrag(c.Candidate),
|
||||
"sdpMlineIndex": idx, "target": target, "pcSeq": pcSeq,
|
||||
@@ -249,31 +300,6 @@ func (b *Bridge) sendICE(cand *webrtc.ICECandidate, target string, pcSeq int) {
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bridge) requestVideoSlots() {
|
||||
b.setSlotsKey++
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] -> setSlots key=%d", b.setSlotsKey))
|
||||
b.wsSend(SetSlotsMessage(b.setSlotsKey))
|
||||
}
|
||||
|
||||
func (b *Bridge) forceReconnect(reason string) {
|
||||
oldPeerID := b.connInfo.PeerID
|
||||
b.logger.Info(fmt.Sprintf("[tm-ws] forcing reconnect: %s", reason))
|
||||
if oldPeerID != "" {
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] kicking self pid=%s to leave call cleanly", oldPeerID))
|
||||
if err := b.kickPeer(oldPeerID); err != nil {
|
||||
b.logger.Warn(fmt.Sprintf("[tm-ws] self-kick failed: %v", err))
|
||||
}
|
||||
}
|
||||
clientInstanceID = uuid.New().String()
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] new instance-id=%s", clientInstanceID))
|
||||
b.mu.Lock()
|
||||
ws := b.ws
|
||||
b.mu.Unlock()
|
||||
if ws != nil {
|
||||
ws.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) sendInitBundle() {
|
||||
if b.initBundleSent {
|
||||
return
|
||||
@@ -286,7 +312,7 @@ func (b *Bridge) sendInitBundle() {
|
||||
}
|
||||
|
||||
func (b *Bridge) sendStartupSlotsRamp() {
|
||||
for i := 0; i < 4; i++ {
|
||||
for i := range 4 {
|
||||
b.setSlotsKey++
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] -> setSlots key=%d (startup %d/4)", b.setSlotsKey, i+1))
|
||||
b.wsSend(StartupSetSlotsMessage(i, b.setSlotsKey))
|
||||
@@ -294,14 +320,14 @@ func (b *Bridge) sendStartupSlotsRamp() {
|
||||
}
|
||||
|
||||
func (b *Bridge) handleMessage(raw []byte) {
|
||||
var msg map[string]interface{}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
uid, _ := msg["uid"].(string)
|
||||
if sh, ok := msg["serverHello"]; ok {
|
||||
b.logger.Debug("[tm-ws] <- serverHello")
|
||||
if shMap, ok := sh.(map[string]interface{}); ok {
|
||||
if shMap, ok := sh.(map[string]any); ok {
|
||||
b.parseICEServers(shMap)
|
||||
}
|
||||
b.ack(uid)
|
||||
@@ -311,7 +337,7 @@ func (b *Bridge) handleMessage(raw []byte) {
|
||||
return
|
||||
}
|
||||
if pa, ok := msg["publisherSdpAnswer"]; ok {
|
||||
paMap, _ := pa.(map[string]interface{})
|
||||
paMap, _ := pa.(map[string]any)
|
||||
sdp, _ := paMap["sdp"].(string)
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] <- publisherSdpAnswer %d bytes", len(sdp)))
|
||||
if err := b.relay.SetPubAnswer(sdp); err != nil {
|
||||
@@ -322,7 +348,7 @@ func (b *Bridge) handleMessage(raw []byte) {
|
||||
return
|
||||
}
|
||||
if so, ok := msg["subscriberSdpOffer"]; ok {
|
||||
soMap, _ := so.(map[string]interface{})
|
||||
soMap, _ := so.(map[string]any)
|
||||
sdp, _ := soMap["sdp"].(string)
|
||||
pcSeq, _ := soMap["pcSeq"].(float64)
|
||||
b.subSeq = int(pcSeq)
|
||||
@@ -334,15 +360,15 @@ func (b *Bridge) handleMessage(raw []byte) {
|
||||
return
|
||||
}
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] -> subscriberSdpAnswer pcSeq=%d", b.subSeq))
|
||||
b.wsSend(map[string]interface{}{
|
||||
b.wsSend(map[string]any{
|
||||
"uid": uuid.New().String(),
|
||||
"subscriberSdpAnswer": map[string]interface{}{"sdp": answer.SDP, "pcSeq": b.subSeq},
|
||||
"subscriberSdpAnswer": map[string]any{"sdp": answer.SDP, "pcSeq": b.subSeq},
|
||||
})
|
||||
b.sendPubOffer()
|
||||
return
|
||||
}
|
||||
if ic, ok := msg["webrtcIceCandidate"]; ok {
|
||||
icMap, _ := ic.(map[string]interface{})
|
||||
icMap, _ := ic.(map[string]any)
|
||||
candidate, _ := icMap["candidate"].(string)
|
||||
sdpMid, _ := icMap["sdpMid"].(string)
|
||||
target, _ := icMap["target"].(string)
|
||||
@@ -358,8 +384,8 @@ func (b *Bridge) handleMessage(raw []byte) {
|
||||
return
|
||||
}
|
||||
if ackData, ok := msg["ack"]; ok {
|
||||
if ackMap, ok := ackData.(map[string]interface{}); ok {
|
||||
if status, ok := ackMap["status"].(map[string]interface{}); ok {
|
||||
if ackMap, ok := ackData.(map[string]any); ok {
|
||||
if status, ok := ackMap["status"].(map[string]any); ok {
|
||||
if code, _ := status["code"].(string); code != "OK" {
|
||||
desc, _ := status["description"].(string)
|
||||
b.logger.Warn(fmt.Sprintf("[tm-ws] <- ack error: %s %s", code, desc))
|
||||
@@ -370,17 +396,17 @@ func (b *Bridge) handleMessage(raw []byte) {
|
||||
}
|
||||
if ud, ok := msg["updateDescription"]; ok {
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] <- updateDescription %s", BriefJSON(ud)))
|
||||
udMap, _ := ud.(map[string]interface{})
|
||||
descs, _ := udMap["description"].([]interface{})
|
||||
udMap, _ := ud.(map[string]any)
|
||||
descs, _ := udMap["description"].([]any)
|
||||
b.applyDescriptionSnapshot(descs)
|
||||
b.ack(uid)
|
||||
return
|
||||
}
|
||||
if ud, ok := msg["upsertDescription"]; ok {
|
||||
udMap, _ := ud.(map[string]interface{})
|
||||
descs, _ := udMap["description"].([]interface{})
|
||||
udMap, _ := ud.(map[string]any)
|
||||
descs, _ := udMap["description"].([]any)
|
||||
for _, d := range descs {
|
||||
dm, _ := d.(map[string]interface{})
|
||||
dm, _ := d.(map[string]any)
|
||||
b.applyDescriptionEntry(dm)
|
||||
}
|
||||
b.kickStaleSelves()
|
||||
@@ -388,8 +414,8 @@ func (b *Bridge) handleMessage(raw []byte) {
|
||||
return
|
||||
}
|
||||
if rd, ok := msg["removeDescription"]; ok {
|
||||
rdMap, _ := rd.(map[string]interface{})
|
||||
ids, _ := rdMap["descriptionId"].([]interface{})
|
||||
rdMap, _ := rd.(map[string]any)
|
||||
ids, _ := rdMap["descriptionId"].([]any)
|
||||
for _, id := range ids {
|
||||
pid, _ := id.(string)
|
||||
b.mu.Lock()
|
||||
@@ -479,7 +505,7 @@ func (b *Bridge) handleMessage(raw []byte) {
|
||||
}
|
||||
b.mu.Unlock()
|
||||
if needRebind {
|
||||
go b.forceReconnect("slot binding killed")
|
||||
b.logger.Debug("[bind] slot kill/vanish observed - ignoring (tunnel data path is independent of slot binding)")
|
||||
}
|
||||
b.ack(uid)
|
||||
return
|
||||
@@ -496,20 +522,20 @@ func (b *Bridge) handleMessage(raw []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) parseICEServers(sh map[string]interface{}) {
|
||||
rtcCfg, ok := sh["rtcConfiguration"].(map[string]interface{})
|
||||
func (b *Bridge) parseICEServers(sh map[string]any) {
|
||||
rtcCfg, ok := sh["rtcConfiguration"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
servers, ok := rtcCfg["iceServers"].([]interface{})
|
||||
servers, ok := rtcCfg["iceServers"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var iceServers []webrtc.ICEServer
|
||||
for _, s := range servers {
|
||||
sm, _ := s.(map[string]interface{})
|
||||
sm, _ := s.(map[string]any)
|
||||
var urls []string
|
||||
if u, ok := sm["urls"].([]interface{}); ok {
|
||||
if u, ok := sm["urls"].([]any); ok {
|
||||
for _, v := range u {
|
||||
if vs, ok := v.(string); ok {
|
||||
urls = append(urls, vs)
|
||||
@@ -532,13 +558,13 @@ func (b *Bridge) requestStates() error {
|
||||
return c.RequestStates(b.connInfo.ConferenceURI, b.connInfo.PeerID)
|
||||
}
|
||||
|
||||
func (b *Bridge) applyDescriptionEntry(dm map[string]interface{}) {
|
||||
func (b *Bridge) applyDescriptionEntry(dm map[string]any) {
|
||||
pid, _ := dm["id"].(string)
|
||||
if pid == "" {
|
||||
return
|
||||
}
|
||||
name := ""
|
||||
if meta, ok := dm["meta"].(map[string]interface{}); ok {
|
||||
if meta, ok := dm["meta"].(map[string]any); ok {
|
||||
name, _ = meta["name"].(string)
|
||||
}
|
||||
if pid == b.connInfo.PeerID {
|
||||
@@ -552,10 +578,15 @@ func (b *Bridge) applyDescriptionEntry(dm map[string]interface{}) {
|
||||
_, disconnected := dm["disconnectedAt"]
|
||||
b.mu.Lock()
|
||||
_, wasKnown := b.peers[pid]
|
||||
newArrival := false
|
||||
if disconnected {
|
||||
delete(b.peers, pid)
|
||||
} else {
|
||||
b.peers[pid] = name
|
||||
if !b.seenPids[pid] {
|
||||
b.seenPids[pid] = true
|
||||
newArrival = true
|
||||
}
|
||||
}
|
||||
total := len(b.peers)
|
||||
b.mu.Unlock()
|
||||
@@ -565,17 +596,36 @@ func (b *Bridge) applyDescriptionEntry(dm map[string]interface{}) {
|
||||
case disconnected:
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] Ghost participant: %s (%s) - kicking", name, pid))
|
||||
go b.kickPeer(pid)
|
||||
case !wasKnown:
|
||||
case newArrival:
|
||||
b.logger.Info(fmt.Sprintf("[tm-ws] Participant joined: %s (%s) total=%d", name, pid, total))
|
||||
b.keepOnly(pid)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) applyDescriptionSnapshot(descs []interface{}) {
|
||||
func (b *Bridge) keepOnly(keepPid string) {
|
||||
b.mu.Lock()
|
||||
victims := make([]string, 0, len(b.peers))
|
||||
for pid := range b.peers {
|
||||
if pid != keepPid {
|
||||
victims = append(victims, pid)
|
||||
}
|
||||
}
|
||||
for _, pid := range victims {
|
||||
delete(b.peers, pid)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
for _, pid := range victims {
|
||||
b.logger.Debug(fmt.Sprintf("[tm-ws] 1:1 evict %s", pid))
|
||||
go b.kickPeer(pid)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) applyDescriptionSnapshot(descs []any) {
|
||||
b.mu.Lock()
|
||||
b.peers = make(map[string]string)
|
||||
b.mu.Unlock()
|
||||
for _, d := range descs {
|
||||
dm, _ := d.(map[string]interface{})
|
||||
dm, _ := d.(map[string]any)
|
||||
b.applyDescriptionEntry(dm)
|
||||
}
|
||||
b.kickStaleSelves()
|
||||
@@ -685,11 +735,18 @@ func (b *Bridge) initRelay() {
|
||||
relay.OnPubReady = func() {
|
||||
b.logger.Debug("[relay] pub PC connected")
|
||||
}
|
||||
relay.OnConnected = func(tun *tunnel.VP8DataTunnel) {
|
||||
relay.OnConnected = func(tun tunnel.DataTunnel) {
|
||||
if b.activeBridge != nil {
|
||||
b.activeBridge.Reset()
|
||||
}
|
||||
b.activeBridge = tunnel.NewRelayBridge(tun, "creator", common.VP8BufSize, b.dialer, b.logger)
|
||||
b.activeBridge.SetOnPeerConfig(func(fps, batch, trackCount int) {
|
||||
if trackCount >= 2 {
|
||||
b.enableScreenshare()
|
||||
} else {
|
||||
b.disableScreenshare()
|
||||
}
|
||||
})
|
||||
b.logger.Debug("[relay] tunnel connected")
|
||||
}
|
||||
relay.OnPeerRestart = func() {
|
||||
@@ -717,14 +774,13 @@ func (b *Bridge) initRelay() {
|
||||
}
|
||||
|
||||
func (b *Bridge) Run() {
|
||||
wsHeader := http.Header{}
|
||||
wsHeader.Set("User-Agent", common.UserAgent)
|
||||
wsHeader := headless.ChromeWindows.Headers(headless.DestWebSocket)
|
||||
wsHeader.Set("Origin", tmOrigin)
|
||||
wsDialer := websocket.Dialer{
|
||||
NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
wsDialer := headless.ChromeWindows.WebSocketDialer(headless.TLSOptions{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return b.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||
},
|
||||
}
|
||||
})
|
||||
for {
|
||||
b.logger.Debug("[tm-ws] Connecting...")
|
||||
ws, _, err := wsDialer.Dial(b.connInfo.MediaServerURL, wsHeader)
|
||||
@@ -761,7 +817,7 @@ func (b *Bridge) Run() {
|
||||
case <-stopPing:
|
||||
return
|
||||
case <-ticker.C:
|
||||
b.wsSend(map[string]interface{}{"uid": uuid.New().String(), "ping": map[string]interface{}{}})
|
||||
b.wsSend(map[string]any{"uid": uuid.New().String(), "ping": map[string]any{}})
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -809,6 +865,13 @@ func (b *Bridge) Run() {
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
if oldPid := b.connInfo.PeerID; oldPid != "" && oldPid != newConn.PeerID {
|
||||
b.mu.Lock()
|
||||
b.seenPids[oldPid] = true
|
||||
delete(b.peers, oldPid)
|
||||
b.mu.Unlock()
|
||||
go b.kickPeer(oldPid)
|
||||
}
|
||||
b.connInfo.PeerID = newConn.PeerID
|
||||
b.connInfo.Credentials = newConn.Credentials
|
||||
b.connInfo.MediaServerURL = newConn.MediaServerURL
|
||||
@@ -817,20 +880,23 @@ func (b *Bridge) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
func parseMids(sdp string) (audioMid, videoMid string) {
|
||||
func parseMids(sdp string) (audioMids, videoMids []string) {
|
||||
var media string
|
||||
for _, line := range strings.Split(sdp, "\r\n") {
|
||||
for line := range strings.SplitSeq(sdp, "\r\n") {
|
||||
if strings.HasPrefix(line, "m=audio") {
|
||||
media = "audio"
|
||||
} else if strings.HasPrefix(line, "m=video") {
|
||||
media = "video"
|
||||
} else if strings.HasPrefix(line, "m=") {
|
||||
media = ""
|
||||
}
|
||||
if strings.HasPrefix(line, "a=mid:") {
|
||||
mid := strings.TrimPrefix(line, "a=mid:")
|
||||
if media == "audio" && audioMid == "" {
|
||||
audioMid = mid
|
||||
} else if media == "video" && videoMid == "" {
|
||||
videoMid = mid
|
||||
if after, ok := strings.CutPrefix(line, "a=mid:"); ok {
|
||||
mid := after
|
||||
switch media {
|
||||
case "audio":
|
||||
audioMids = append(audioMids, mid)
|
||||
case "video":
|
||||
videoMids = append(videoMids, mid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+174
-120
@@ -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{}})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,8 @@ const (
|
||||
MsgUDPReply byte = 0x07
|
||||
MsgConfig byte = 0x08
|
||||
MsgConfigAck byte = 0x09
|
||||
|
||||
WireHeaderLen = 4 + 1
|
||||
)
|
||||
|
||||
const ControlConnID uint32 = 0
|
||||
|
||||
@@ -44,6 +44,9 @@ type RelayBridge struct {
|
||||
|
||||
onPeerConfigMu sync.Mutex
|
||||
onPeerConfig func(fps, batch, trackCount int)
|
||||
|
||||
onConfigAckMu sync.Mutex
|
||||
onConfigAck func()
|
||||
}
|
||||
|
||||
func NewRelayBridge(tunnel DataTunnel, mode string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) *RelayBridge {
|
||||
@@ -78,6 +81,12 @@ func (rb *RelayBridge) SetOnPeerConfig(fn func(fps, batch, trackCount int)) {
|
||||
rb.onPeerConfigMu.Unlock()
|
||||
}
|
||||
|
||||
func (rb *RelayBridge) SetOnConfigAck(fn func()) {
|
||||
rb.onConfigAckMu.Lock()
|
||||
rb.onConfigAck = fn
|
||||
rb.onConfigAckMu.Unlock()
|
||||
}
|
||||
|
||||
func (rb *RelayBridge) DialContext(ctx context.Context, destination string) (net.Conn, error) {
|
||||
if rb.closed.Load() {
|
||||
return nil, fmt.Errorf("relay: bridge already closed")
|
||||
@@ -216,6 +225,14 @@ func (rb *RelayBridge) handleTunnelData(data []byte) {
|
||||
return
|
||||
}
|
||||
if connID == ControlConnID && msgType == MsgConfigAck {
|
||||
if rb.mode == "joiner" {
|
||||
rb.onConfigAckMu.Lock()
|
||||
cb := rb.onConfigAck
|
||||
rb.onConfigAckMu.Unlock()
|
||||
if cb != nil {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
switch rb.mode {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package tunnel
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/pion/datachannel"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"github.com/kulikov0/headless-client/webrtc"
|
||||
"github.com/pion/datachannel"
|
||||
)
|
||||
|
||||
const chunkSize = 994
|
||||
@@ -28,15 +30,15 @@ type DCTunnel struct {
|
||||
logger logger.ContextLogger
|
||||
onData func([]byte)
|
||||
onClose func()
|
||||
obf *TunnelObfuscator
|
||||
obf *tunnel.TunnelObfuscator
|
||||
chunked bool
|
||||
readBuf int
|
||||
|
||||
recvBufs sync.Map
|
||||
sendMsgID uint32
|
||||
sendMsgID atomic.Uint32
|
||||
}
|
||||
|
||||
func NewDCTunnel(dc *webrtc.DataChannel, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||
func NewDCTunnel(dc *webrtc.DataChannel, obf *tunnel.TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||
t := &DCTunnel{dc: dc, obf: obf, readBuf: readBuf, logger: logger}
|
||||
raw, err := dc.Detach()
|
||||
if err != nil {
|
||||
@@ -56,13 +58,13 @@ func NewDCTunnel(dc *webrtc.DataChannel, obf *TunnelObfuscator, readBuf int, log
|
||||
return t
|
||||
}
|
||||
|
||||
func NewDCTunnelFromRaw(dc *webrtc.DataChannel, raw datachannel.ReadWriteCloser, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||
func NewDCTunnelFromRaw(dc *webrtc.DataChannel, raw datachannel.ReadWriteCloser, obf *tunnel.TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||
t := &DCTunnel{dc: dc, raw: raw, obf: obf, readBuf: readBuf, logger: logger}
|
||||
go t.readLoop()
|
||||
return t
|
||||
}
|
||||
|
||||
func NewChunkedDCTunnel(readRaw datachannel.ReadWriteCloser, writeDC *webrtc.DataChannel, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||
func NewChunkedDCTunnel(readRaw datachannel.ReadWriteCloser, writeDC *webrtc.DataChannel, obf *tunnel.TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||
writeRaw, err := writeDC.Detach()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("dctunnel: write DC detach failed: %v", err))
|
||||
@@ -73,7 +75,7 @@ func NewChunkedDCTunnel(readRaw datachannel.ReadWriteCloser, writeDC *webrtc.Dat
|
||||
return t
|
||||
}
|
||||
|
||||
func NewChunkedDCTunnelFromRaw(readRaw, writeRaw datachannel.ReadWriteCloser, obf *TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||
func NewChunkedDCTunnelFromRaw(readRaw, writeRaw datachannel.ReadWriteCloser, obf *tunnel.TunnelObfuscator, readBuf int, logger logger.ContextLogger) *DCTunnel {
|
||||
t := &DCTunnel{raw: readRaw, writeRaw: writeRaw, obf: obf, readBuf: readBuf, logger: logger, chunked: true}
|
||||
go t.readLoop()
|
||||
return t
|
||||
@@ -194,13 +196,10 @@ func (t *DCTunnel) sendChunked(data []byte) {
|
||||
if total == 0 {
|
||||
total = 1
|
||||
}
|
||||
id := uint16(atomic.AddUint32(&t.sendMsgID, 1)) & 0xFFFF
|
||||
id := uint16(t.sendMsgID.Add(1)) & 0xFFFF
|
||||
for i := 0; i < total; i++ {
|
||||
start := i * chunkSize
|
||||
end := start + chunkSize
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
end := min(start+chunkSize, len(data))
|
||||
p := data[start:end]
|
||||
f := make([]byte, 6+len(p))
|
||||
f[0] = byte(id >> 8)
|
||||
+4
-2
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-9
@@ -1,4 +1,4 @@
|
||||
package tunnel
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
@@ -7,19 +7,22 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
kcp "github.com/xtaci/kcp-go/v5"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
kcp "github.com/xtaci/kcp-go/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
kcpConvBase = 0x77627374
|
||||
kcpUpdateInterval = 10 * time.Millisecond
|
||||
kcpConvBase = 0x77627374
|
||||
kcpUpdateInterval = 10 * time.Millisecond
|
||||
kcpIdleUpdateInterval = 500 * time.Millisecond
|
||||
kcpIdleAfterTicks = 50
|
||||
// One KCP segment must ride in a single RTP packet so a dropped packet
|
||||
// loses only its own frame, not a two-packet frame that readVP8Track
|
||||
// would discard whole. 1200 RTP budget - 1 VP8 descriptor - interframe
|
||||
// header - 24 XChaCha20 nonce - 16 Poly1305 tag - 1 channel tag.
|
||||
kcpSegmentMTU = 1200 - 1 - interframeHdrLen - 24 - 16 - 1
|
||||
kcpSegmentMTU = 1200 - 1 - tunnel.InterframeHdrLen - 24 - 16 - 1
|
||||
kcpReceiveBufSize = 128 * 1024
|
||||
kcpStatsEvery = 500
|
||||
|
||||
@@ -147,6 +150,7 @@ type MultiTrackKCPTunnel struct {
|
||||
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
nudge chan struct{}
|
||||
|
||||
currentWindow atomic.Int32
|
||||
|
||||
@@ -166,6 +170,7 @@ func NewMultiTrackKCPTunnel(mt *MultiTrackTunnel, logger logger.ContextLogger) *
|
||||
convMap: make(map[uint32]*trackKCPSession),
|
||||
connPin: make(map[uint32]int),
|
||||
stopCh: make(chan struct{}),
|
||||
nudge: make(chan struct{}, 1),
|
||||
}
|
||||
subs := mt.SubTunnels()
|
||||
window := kcpWindowFloor
|
||||
@@ -195,10 +200,11 @@ func (t *MultiTrackKCPTunnel) SendData(frame []byte) {
|
||||
connID := binary.BigEndian.Uint32(frame[4:8])
|
||||
msgType := frame[8]
|
||||
|
||||
if msgType == MsgUDP || msgType == MsgUDPReply {
|
||||
if msgType == tunnel.MsgUDP || msgType == tunnel.MsgUDPReply {
|
||||
t.sendRaw(connID, frame)
|
||||
return
|
||||
}
|
||||
t.wake()
|
||||
|
||||
t.mu.Lock()
|
||||
if len(t.sessions) == 0 {
|
||||
@@ -213,7 +219,7 @@ func (t *MultiTrackKCPTunnel) SendData(frame []byte) {
|
||||
session := t.sessions[index]
|
||||
t.mu.Unlock()
|
||||
|
||||
if msgType == MsgData {
|
||||
if msgType == tunnel.MsgData {
|
||||
sndCap := int(t.currentWindow.Load()) * kcpWaitSndFactor
|
||||
for session.waitSnd() >= sndCap {
|
||||
select {
|
||||
@@ -227,7 +233,7 @@ func (t *MultiTrackKCPTunnel) SendData(frame []byte) {
|
||||
t.sentMessages.Add(1)
|
||||
session.send(frame)
|
||||
|
||||
if msgType == MsgClose {
|
||||
if msgType == tunnel.MsgClose {
|
||||
t.mu.Lock()
|
||||
delete(t.connPin, connID)
|
||||
t.mu.Unlock()
|
||||
@@ -286,6 +292,7 @@ func (t *MultiTrackKCPTunnel) handleDecodedSegment(payload []byte) {
|
||||
return
|
||||
}
|
||||
t.inputSegments.Add(1)
|
||||
t.wake()
|
||||
messages := session.input(body)
|
||||
if callback == nil {
|
||||
return
|
||||
@@ -369,21 +376,52 @@ func (t *MultiTrackKCPTunnel) handleInnerClose() {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MultiTrackKCPTunnel) wake() {
|
||||
select {
|
||||
case t.nudge <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MultiTrackKCPTunnel) updateLoop() {
|
||||
ticker := time.NewTicker(kcpUpdateInterval)
|
||||
defer ticker.Stop()
|
||||
ticks := 0
|
||||
idleTicks := 0
|
||||
fast := true
|
||||
for {
|
||||
select {
|
||||
case <-t.stopCh:
|
||||
return
|
||||
case <-t.nudge:
|
||||
if !fast {
|
||||
fast = true
|
||||
idleTicks = 0
|
||||
ticker.Reset(kcpUpdateInterval)
|
||||
}
|
||||
case <-ticker.C:
|
||||
t.mu.Lock()
|
||||
sessions := make([]*trackKCPSession, len(t.sessions))
|
||||
copy(sessions, t.sessions)
|
||||
t.mu.Unlock()
|
||||
pending := 0
|
||||
for _, session := range sessions {
|
||||
session.update()
|
||||
pending += session.waitSnd()
|
||||
}
|
||||
if pending > 0 {
|
||||
idleTicks = 0
|
||||
if !fast {
|
||||
fast = true
|
||||
ticker.Reset(kcpUpdateInterval)
|
||||
}
|
||||
} else if fast {
|
||||
idleTicks++
|
||||
if idleTicks >= kcpIdleAfterTicks {
|
||||
fast = false
|
||||
idleTicks = 0
|
||||
ticker.Reset(kcpIdleUpdateInterval)
|
||||
}
|
||||
}
|
||||
ticks++
|
||||
if ticks%kcpStatsEvery == 0 && t.logger != nil {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package tunnel
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
+9
-14
@@ -1,4 +1,4 @@
|
||||
package tunnel
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
)
|
||||
|
||||
@@ -20,7 +21,7 @@ const (
|
||||
)
|
||||
|
||||
type ScreenWriter struct {
|
||||
obf *TunnelObfuscator
|
||||
obf *tunnel.TunnelObfuscator
|
||||
logger logger.ContextLogger
|
||||
label string
|
||||
|
||||
@@ -39,7 +40,7 @@ type ScreenWriter struct {
|
||||
sent atomic.Uint64
|
||||
}
|
||||
|
||||
func NewScreenWriter(obf *TunnelObfuscator, label string, logger logger.ContextLogger) *ScreenWriter {
|
||||
func NewScreenWriter(obf *tunnel.TunnelObfuscator, label string, logger logger.ContextLogger) *ScreenWriter {
|
||||
return &ScreenWriter{
|
||||
obf: obf,
|
||||
logger: logger,
|
||||
@@ -121,10 +122,7 @@ func (w *ScreenWriter) interval() time.Duration {
|
||||
}
|
||||
|
||||
func (w *ScreenWriter) nextKeepalive(sample time.Duration) (ticks, padLen int) {
|
||||
ticks = int(common.DurationInRange(keepaliveIdleMin, keepaliveIdleMax) / sample)
|
||||
if ticks < 1 {
|
||||
ticks = 1
|
||||
}
|
||||
ticks = max(int(common.DurationInRange(keepaliveIdleMin, keepaliveIdleMax)/sample), 1)
|
||||
return ticks, common.IntInRange(0, screenKeepalivePadMax)
|
||||
}
|
||||
|
||||
@@ -184,7 +182,7 @@ func (w *ScreenWriter) writerLoop() {
|
||||
type SymmetricScreenTunnel struct {
|
||||
cam *VP8DataTunnel
|
||||
screen *ScreenWriter
|
||||
obf *TunnelObfuscator
|
||||
obf *tunnel.TunnelObfuscator
|
||||
logger logger.ContextLogger
|
||||
screenReady func() bool
|
||||
|
||||
@@ -194,7 +192,7 @@ type SymmetricScreenTunnel struct {
|
||||
trackCount atomic.Int32
|
||||
}
|
||||
|
||||
func NewSymmetricScreenTunnel(cam *VP8DataTunnel, screen *ScreenWriter, obf *TunnelObfuscator, screenReady func() bool, logger logger.ContextLogger) *SymmetricScreenTunnel {
|
||||
func NewSymmetricScreenTunnel(cam *VP8DataTunnel, screen *ScreenWriter, obf *tunnel.TunnelObfuscator, screenReady func() bool, logger logger.ContextLogger) *SymmetricScreenTunnel {
|
||||
return &SymmetricScreenTunnel{cam: cam, screen: screen, obf: obf, screenReady: screenReady, logger: logger}
|
||||
}
|
||||
|
||||
@@ -219,14 +217,11 @@ func (s *SymmetricScreenTunnel) SendData(data []byte) {
|
||||
if len(data) >= 8 {
|
||||
connID = binary.BigEndian.Uint32(data[4:8])
|
||||
}
|
||||
if connID == ControlConnID {
|
||||
if connID == tunnel.ControlConnID {
|
||||
s.cam.SendData(data)
|
||||
return
|
||||
}
|
||||
tc := uint32(s.trackCount.Load())
|
||||
if tc < 1 {
|
||||
tc = 1
|
||||
}
|
||||
tc := max(uint32(s.trackCount.Load()), 1)
|
||||
if connID%tc == 1 && s.screenUp() {
|
||||
s.screen.SendData(data)
|
||||
return
|
||||
@@ -1,4 +1,4 @@
|
||||
package tunnel
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
|
||||
"github.com/kulikov0/headless-client/webrtc"
|
||||
"github.com/kulikov0/headless-client/webrtc/pkg/media"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,12 +25,14 @@ const (
|
||||
paceBatchFloorPercent = 80
|
||||
paceDriftMin = 5 * time.Second
|
||||
paceDriftMax = 20 * time.Second
|
||||
|
||||
idleSpinTicks = 40
|
||||
)
|
||||
|
||||
type VP8DataTunnel struct {
|
||||
track *webrtc.TrackLocalStaticSample
|
||||
logger logger.ContextLogger
|
||||
obf *TunnelObfuscator
|
||||
obf *tunnel.TunnelObfuscator
|
||||
stopCh chan struct{}
|
||||
sendQueue chan []byte
|
||||
cfgChan chan struct{}
|
||||
@@ -50,17 +54,19 @@ type VP8DataTunnel struct {
|
||||
OnData func([]byte)
|
||||
OnClose func()
|
||||
OnPeerRestart func()
|
||||
|
||||
WriteFrame func([]byte) error
|
||||
}
|
||||
|
||||
func (t *VP8DataTunnel) SetOnData(fn func([]byte)) { t.OnData = fn }
|
||||
func (t *VP8DataTunnel) SetOnClose(fn func()) { t.OnClose = fn }
|
||||
func (t *VP8DataTunnel) SetOnPeerRestart(fn func()) { t.OnPeerRestart = fn }
|
||||
|
||||
func NewVP8DataTunnel(track *webrtc.TrackLocalStaticSample, obf *TunnelObfuscator, logger logger.ContextLogger) *VP8DataTunnel {
|
||||
func NewVP8DataTunnel(track *webrtc.TrackLocalStaticSample, obf *tunnel.TunnelObfuscator, logger logger.ContextLogger) *VP8DataTunnel {
|
||||
return NewVP8DataTunnelWithQueue(track, obf, logger, sendQueueDepth)
|
||||
}
|
||||
|
||||
func NewVP8DataTunnelWithQueue(track *webrtc.TrackLocalStaticSample, obf *TunnelObfuscator, logger logger.ContextLogger, queueDepth int) *VP8DataTunnel {
|
||||
func NewVP8DataTunnelWithQueue(track *webrtc.TrackLocalStaticSample, obf *tunnel.TunnelObfuscator, logger logger.ContextLogger, queueDepth int) *VP8DataTunnel {
|
||||
if queueDepth < sendQueueDepth {
|
||||
queueDepth = sendQueueDepth
|
||||
}
|
||||
@@ -79,30 +85,11 @@ func NewVP8DataTunnelWithQueue(track *webrtc.TrackLocalStaticSample, obf *Tunnel
|
||||
}
|
||||
}
|
||||
|
||||
func (t *VP8DataTunnel) SetKeepaliveShape(minPeriod, maxPeriod time.Duration, padMax int) {
|
||||
t.cfgMu.Lock()
|
||||
if minPeriod > 0 {
|
||||
t.keepaliveMin = minPeriod
|
||||
}
|
||||
if maxPeriod >= t.keepaliveMin {
|
||||
t.keepaliveMax = maxPeriod
|
||||
}
|
||||
if padMax >= 0 {
|
||||
t.keepalivePadMax = padMax
|
||||
}
|
||||
newMin, newMax, newPad := t.keepaliveMin, t.keepaliveMax, t.keepalivePadMax
|
||||
t.cfgMu.Unlock()
|
||||
t.logger.Debug(fmt.Sprintf("vp8tunnel: keepalive shape min=%s max=%s padMax=%d", newMin, newMax, newPad))
|
||||
}
|
||||
|
||||
func (t *VP8DataTunnel) nextKeepalive(sampleInterval time.Duration) (ticks, padLen int) {
|
||||
t.cfgMu.Lock()
|
||||
minPeriod, maxPeriod, padMax := t.keepaliveMin, t.keepaliveMax, t.keepalivePadMax
|
||||
t.cfgMu.Unlock()
|
||||
ticks = int(common.DurationInRange(minPeriod, maxPeriod) / sampleInterval)
|
||||
if ticks < 1 {
|
||||
ticks = 1
|
||||
}
|
||||
ticks = max(int(common.DurationInRange(minPeriod, maxPeriod)/sampleInterval), 1)
|
||||
return ticks, common.IntInRange(0, padMax)
|
||||
}
|
||||
|
||||
@@ -244,10 +231,7 @@ func pacedBatchFor(batch int) int {
|
||||
if batch <= 1 {
|
||||
return batch
|
||||
}
|
||||
floor := batch * paceBatchFloorPercent / 100
|
||||
if floor < 1 {
|
||||
floor = 1
|
||||
}
|
||||
floor := max(batch*paceBatchFloorPercent/100, 1)
|
||||
return common.IntInRange(floor, batch)
|
||||
}
|
||||
|
||||
@@ -262,58 +246,111 @@ func (t *VP8DataTunnel) writerLoop() {
|
||||
|
||||
ticker := time.NewTicker(sampleInterval)
|
||||
drift := time.NewTimer(common.DurationInRange(paceDriftMin, paceDriftMax))
|
||||
idle := time.NewTimer(time.Hour)
|
||||
if !idle.Stop() {
|
||||
<-idle.C
|
||||
}
|
||||
idleTicks := 0
|
||||
spinning := true
|
||||
reconfigure := false
|
||||
|
||||
emit := func(sample []byte, isKeepalive bool) {
|
||||
if sample == nil {
|
||||
return
|
||||
}
|
||||
if t.WriteFrame != nil {
|
||||
if err := t.WriteFrame(sample); err != nil {
|
||||
t.logger.Debug(fmt.Sprintf("vp8tunnel: WriteFrame error: %v", err))
|
||||
return
|
||||
}
|
||||
} else if err := t.track.WriteSample(media.Sample{Data: sample, Duration: sampleInterval}); err != nil {
|
||||
t.logger.Debug(fmt.Sprintf("vp8tunnel: WriteSample error: %v", err))
|
||||
return
|
||||
}
|
||||
n := t.sentFrames.Add(1)
|
||||
if isKeepalive {
|
||||
t.keepaliveFrames.Add(1)
|
||||
}
|
||||
if n <= 5 || n%500 == 0 {
|
||||
keepalives := t.keepaliveFrames.Load()
|
||||
t.logger.Debug(fmt.Sprintf("vp8tunnel: sent frame #%d size=%d data=%d keepalive=%d", n, len(sample), n-keepalives, keepalives))
|
||||
}
|
||||
}
|
||||
|
||||
repace := func() {
|
||||
pacedBatch = pacedBatchFor(batch)
|
||||
sampleInterval = sampleIntervalFor(fps, pacedBatch)
|
||||
if spinning {
|
||||
ticker.Reset(sampleInterval)
|
||||
}
|
||||
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
|
||||
drift.Reset(common.DurationInRange(paceDriftMin, paceDriftMax))
|
||||
t.logger.Debug(fmt.Sprintf("vp8tunnel: pace drift pacedBatch=%d/%d sampleInterval=%s", pacedBatch, batch, sampleInterval))
|
||||
}
|
||||
|
||||
for !reconfigure {
|
||||
if spinning {
|
||||
select {
|
||||
case <-t.stopCh:
|
||||
ticker.Stop()
|
||||
drift.Stop()
|
||||
idle.Stop()
|
||||
return
|
||||
case <-t.cfgChan:
|
||||
reconfigure = true
|
||||
case <-drift.C:
|
||||
repace()
|
||||
case <-ticker.C:
|
||||
select {
|
||||
case data := <-t.sendQueue:
|
||||
emit(t.obf.EncodeData(data), false)
|
||||
idleTicks = 0
|
||||
default:
|
||||
idleTicks++
|
||||
switch {
|
||||
case idleTicks >= keepaliveEvery:
|
||||
idleTicks = 0
|
||||
emit(t.obf.EncodeKeepalive(keepalivePad), true)
|
||||
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
|
||||
case idleTicks >= idleSpinTicks:
|
||||
ticker.Stop()
|
||||
spinning = false
|
||||
idle.Reset(time.Duration(keepaliveEvery-idleTicks) * sampleInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case <-t.stopCh:
|
||||
ticker.Stop()
|
||||
drift.Stop()
|
||||
idle.Stop()
|
||||
return
|
||||
case <-t.cfgChan:
|
||||
reconfigure = true
|
||||
case <-drift.C:
|
||||
pacedBatch = pacedBatchFor(batch)
|
||||
sampleInterval = sampleIntervalFor(fps, pacedBatch)
|
||||
ticker.Reset(sampleInterval)
|
||||
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
|
||||
drift.Reset(common.DurationInRange(paceDriftMin, paceDriftMax))
|
||||
t.logger.Debug(fmt.Sprintf("vp8tunnel: pace drift pacedBatch=%d/%d sampleInterval=%s", pacedBatch, batch, sampleInterval))
|
||||
case <-ticker.C:
|
||||
var sample []byte
|
||||
isKeepalive := false
|
||||
select {
|
||||
case data := <-t.sendQueue:
|
||||
sample = t.obf.EncodeData(data)
|
||||
idleTicks = 0
|
||||
default:
|
||||
idleTicks++
|
||||
if idleTicks < keepaliveEvery {
|
||||
continue
|
||||
repace()
|
||||
case data := <-t.sendQueue:
|
||||
if !idle.Stop() {
|
||||
select {
|
||||
case <-idle.C:
|
||||
default:
|
||||
}
|
||||
idleTicks = 0
|
||||
sample = t.obf.EncodeKeepalive(keepalivePad)
|
||||
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
|
||||
isKeepalive = true
|
||||
}
|
||||
if sample == nil {
|
||||
continue
|
||||
}
|
||||
if err := t.track.WriteSample(media.Sample{Data: sample, Duration: sampleInterval}); err != nil {
|
||||
t.logger.Debug(fmt.Sprintf("vp8tunnel: WriteSample error: %v", err))
|
||||
continue
|
||||
}
|
||||
n := t.sentFrames.Add(1)
|
||||
if isKeepalive {
|
||||
t.keepaliveFrames.Add(1)
|
||||
}
|
||||
if n <= 5 || n%500 == 0 {
|
||||
keepalives := t.keepaliveFrames.Load()
|
||||
t.logger.Debug(fmt.Sprintf("vp8tunnel: sent frame #%d size=%d data=%d keepalive=%d", n, len(sample), n-keepalives, keepalives))
|
||||
}
|
||||
emit(t.obf.EncodeData(data), false)
|
||||
idleTicks = 0
|
||||
spinning = true
|
||||
ticker.Reset(sampleInterval)
|
||||
case <-idle.C:
|
||||
idleTicks = 0
|
||||
emit(t.obf.EncodeKeepalive(keepalivePad), true)
|
||||
keepaliveEvery, keepalivePad = t.nextKeepalive(sampleInterval)
|
||||
idle.Reset(time.Duration(keepaliveEvery) * sampleInterval)
|
||||
}
|
||||
}
|
||||
ticker.Stop()
|
||||
drift.Stop()
|
||||
idle.Stop()
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -5,26 +5,27 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/common/dialer"
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
"github.com/sagernet/sing-box/transport/call/wtsignal"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
"github.com/kulikov0/headless-client/webrtc"
|
||||
)
|
||||
|
||||
const topologyDirect = "DIRECT"
|
||||
const maxServerBounces = 5
|
||||
|
||||
type Bridge struct {
|
||||
mu sync.Mutex
|
||||
vkWs *websocket.Conn
|
||||
sfu *wtsignal.Conn
|
||||
vkSeq int
|
||||
iceServers []webrtc.ICEServer
|
||||
topology string
|
||||
@@ -34,11 +35,8 @@ type Bridge struct {
|
||||
p2p *P2PHandler
|
||||
screenSharing bool
|
||||
|
||||
serverBounces int
|
||||
suppressScreenshare bool
|
||||
bouncing bool
|
||||
|
||||
dialer N.Dialer
|
||||
dnsRouter adapter.DNSRouter
|
||||
activeBridge *tunnel.RelayBridge
|
||||
readBuf int
|
||||
logger logger.ContextLogger
|
||||
@@ -46,25 +44,20 @@ type Bridge struct {
|
||||
|
||||
func (b *Bridge) setScreenSharing(enabled bool) {
|
||||
b.mu.Lock()
|
||||
if b.vkWs == nil || b.screenSharing == enabled {
|
||||
if b.sfu == nil || b.screenSharing == enabled {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if enabled && b.suppressScreenshare {
|
||||
b.mu.Unlock()
|
||||
b.logger.Debug("[vk-ws] screenshare suppressed after SERVER flap, staying single-track DIRECT")
|
||||
return
|
||||
}
|
||||
b.screenSharing = enabled
|
||||
b.mu.Unlock()
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] peer track count change, screenshare=%v", enabled))
|
||||
b.sendMediaSettings(enabled)
|
||||
}
|
||||
|
||||
func (b *Bridge) vkSend(command string, extra map[string]interface{}) {
|
||||
func (b *Bridge) vkSend(command string, extra map[string]any) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.vkWs == nil {
|
||||
if b.sfu == nil {
|
||||
return
|
||||
}
|
||||
b.vkSeq++
|
||||
@@ -79,13 +72,13 @@ func (b *Bridge) vkSend(command string, extra map[string]interface{}) {
|
||||
extra["sequence"] = seq
|
||||
out, _ = json.Marshal(extra)
|
||||
}
|
||||
b.vkWs.WriteMessage(websocket.TextMessage, out)
|
||||
b.sfu.Send(out)
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] -> %s", command))
|
||||
}
|
||||
|
||||
func (b *Bridge) sendMediaSettings(screenSharing bool) {
|
||||
b.vkSend("change-media-settings", map[string]interface{}{
|
||||
"mediaSettings": map[string]interface{}{
|
||||
b.vkSend("change-media-settings", map[string]any{
|
||||
"mediaSettings": map[string]any{
|
||||
"isAudioEnabled": false, "isVideoEnabled": true,
|
||||
"isScreenSharingEnabled": screenSharing, "isFastScreenSharingEnabled": false,
|
||||
"isAudioSharingEnabled": false, "isAnimojiEnabled": false,
|
||||
@@ -94,7 +87,7 @@ func (b *Bridge) sendMediaSettings(screenSharing bool) {
|
||||
}
|
||||
|
||||
func (b *Bridge) handleVKMessage(raw []byte) {
|
||||
var msg map[string]interface{}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -105,9 +98,13 @@ func (b *Bridge) handleVKMessage(raw []byte) {
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] <- notification: %s", notif))
|
||||
switch notif {
|
||||
case "connection":
|
||||
if conv, ok := msg["conversation"].(map[string]any); ok {
|
||||
topo, _ := conv["topology"].(string)
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] connection topology=%q", topo))
|
||||
}
|
||||
b.logger.Debug("[vk-ws] TURN creds received")
|
||||
case "transmitted-data":
|
||||
data, _ := msg["data"].(map[string]interface{})
|
||||
data, _ := msg["data"].(map[string]any)
|
||||
if data != nil && b.topology == topologyDirect && b.p2p != nil {
|
||||
b.p2p.OnTransmittedData(data)
|
||||
}
|
||||
@@ -121,7 +118,7 @@ func (b *Bridge) handleVKMessage(raw []byte) {
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] Topology changed to %s", topo))
|
||||
b.topology = topo
|
||||
if topo != topologyDirect {
|
||||
b.bounceForServerTopology("SERVER topology")
|
||||
b.failForServerTopology("SERVER topology")
|
||||
return
|
||||
}
|
||||
case "participant-joined", "participant-added":
|
||||
@@ -129,7 +126,7 @@ func (b *Bridge) handleVKMessage(raw []byte) {
|
||||
b.peers[int64(pid)] = struct{}{}
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] Participant %d joined (total: %d)", int64(pid), len(b.peers)))
|
||||
if b.topology != topologyDirect {
|
||||
b.bounceForServerTopology("participant joined under SERVER")
|
||||
b.failForServerTopology("participant joined under SERVER")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -149,8 +146,8 @@ func (b *Bridge) handleVKMessage(raw []byte) {
|
||||
reason, _ := msg["reason"].(string)
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] Conversation closed: %s", reason))
|
||||
b.mu.Lock()
|
||||
if b.vkWs != nil {
|
||||
b.vkWs.Close()
|
||||
if b.sfu != nil {
|
||||
b.sfu.Close()
|
||||
}
|
||||
b.mu.Unlock()
|
||||
default:
|
||||
@@ -174,27 +171,43 @@ func (b *Bridge) handleVKMessage(raw []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bridge) connectVKWs(wsURL string) error {
|
||||
vkHeader := http.Header{}
|
||||
vkHeader.Set("User-Agent", common.UserAgent)
|
||||
vkHeader.Set("Origin", "https://vk.com")
|
||||
vkDialer := websocket.Dialer{
|
||||
WriteBufferSize: common.RTPBufSize,
|
||||
NetDialContext: b.dialContext,
|
||||
func (b *Bridge) connectVKWs(wtURL string) error {
|
||||
parsed, err := url.Parse(wtURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vkWs, _, err := vkDialer.Dial(wsURL, vkHeader)
|
||||
host := parsed.Hostname()
|
||||
resolvedIP, err := b.resolveHost(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve %s: %w", host, err)
|
||||
}
|
||||
sfu, err := wtsignal.Dial(wtURL, host, resolvedIP, vkOrigin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.vkWs = vkWs
|
||||
b.sfu = sfu
|
||||
b.vkSeq = 0
|
||||
b.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) dialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return b.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||
func (b *Bridge) resolveHost(host string) (string, error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return host, nil
|
||||
}
|
||||
rd, hasRD := b.dialer.(dialer.ResolveDialer)
|
||||
if b.dnsRouter == nil || !hasRD {
|
||||
return "", fmt.Errorf("no DNS router available to resolve %s", host)
|
||||
}
|
||||
addrs, err := b.dnsRouter.Lookup(context.Background(), host, rd.QueryOptions())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return "", fmt.Errorf("no addresses for %s", host)
|
||||
}
|
||||
return addrs[0].String(), nil
|
||||
}
|
||||
|
||||
func (b *Bridge) initRelay() {
|
||||
@@ -208,41 +221,24 @@ func (b *Bridge) initRelay() {
|
||||
b.p2p.Init()
|
||||
}
|
||||
|
||||
func (b *Bridge) bounceForServerTopology(reason string) {
|
||||
b.mu.Lock()
|
||||
if b.bouncing {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.bouncing = true
|
||||
b.serverBounces++
|
||||
count := b.serverBounces
|
||||
if count > maxServerBounces {
|
||||
b.suppressScreenshare = true
|
||||
}
|
||||
suppress := b.suppressScreenshare
|
||||
ws := b.vkWs
|
||||
b.mu.Unlock()
|
||||
if suppress {
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] %s -> reconnect #%d, suppressing screenshare to settle single-track DIRECT", reason, count))
|
||||
} else {
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] %s -> manual reconnect #%d to recover DIRECT", reason, count))
|
||||
}
|
||||
if ws != nil {
|
||||
ws.Close()
|
||||
}
|
||||
func (b *Bridge) failForServerTopology(reason string) {
|
||||
b.logger.Error(fmt.Sprintf("[vk-ws] %s -> VK moved this call to server topology, it cannot be tunneled, create a new call and connect again", reason))
|
||||
}
|
||||
|
||||
func (b *Bridge) readLoop() error {
|
||||
b.mu.Lock()
|
||||
sfu := b.sfu
|
||||
b.mu.Unlock()
|
||||
if sfu == nil {
|
||||
return fmt.Errorf("no transport")
|
||||
}
|
||||
for {
|
||||
_, msg, err := b.vkWs.ReadMessage()
|
||||
msg, err := sfu.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if string(msg) == "ping" {
|
||||
b.mu.Lock()
|
||||
b.vkWs.WriteMessage(websocket.TextMessage, []byte("pong"))
|
||||
b.mu.Unlock()
|
||||
sfu.Send([]byte("pong"))
|
||||
continue
|
||||
}
|
||||
b.handleVKMessage(msg)
|
||||
@@ -253,32 +249,23 @@ func (b *Bridge) Run(callInfo *CallInfo, cookieStr string, cfg VKConfig) {
|
||||
b.logger.Info(fmt.Sprintf("CALL CREATED join_link=%s turn=%s protocol=v%s sdk=%s",
|
||||
callInfo.JoinLink, strings.Join(callInfo.TurnServer.URLs, ", "), cfg.ProtocolVersion, cfg.SDKVersion))
|
||||
b.iceServers = buildWebRTCICEServers(BuildICEServers(callInfo))
|
||||
wsEndpoint := callInfo.WSEndpoint
|
||||
wtEndpoint := callInfo.WtEndpoint
|
||||
capabilities := "2F7F"
|
||||
makeWSURL := func(ep string) string {
|
||||
makeWtURL := func(ep string) string {
|
||||
return ep +
|
||||
"&platform=WEB" +
|
||||
"&appVersion=" + cfg.AppVersion +
|
||||
"&version=" + cfg.ProtocolVersion +
|
||||
"&device=browser&capabilities=" + capabilities + "&clientType=VK&tgt=join"
|
||||
"&device=browser&capabilities=" + capabilities + "&clientType=VK&tgt=join&compression=deflate-raw"
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(15 * time.Second)
|
||||
b.mu.Lock()
|
||||
ws := b.vkWs
|
||||
b.mu.Unlock()
|
||||
if ws != nil {
|
||||
b.mu.Lock()
|
||||
ws.WriteMessage(websocket.PingMessage, nil)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
b.initRelay()
|
||||
if wtEndpoint == "" {
|
||||
b.logger.Error("[vk-ws] no wt_endpoint in join response")
|
||||
return
|
||||
}
|
||||
b.logger.Debug("[vk-ws] Connecting...")
|
||||
if err := b.connectVKWs(makeWSURL(wsEndpoint)); err != nil {
|
||||
if err := b.connectVKWs(makeWtURL(wtEndpoint)); err != nil {
|
||||
b.logger.Warn(fmt.Sprintf("[vk-ws] Connect failed: %s, retrying in 5s...", common.MaskError(err)))
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
@@ -286,13 +273,12 @@ func (b *Bridge) Run(callInfo *CallInfo, cookieStr string, cfg VKConfig) {
|
||||
b.logger.Debug("[vk-ws] Connected")
|
||||
b.mu.Lock()
|
||||
b.screenSharing = false
|
||||
b.bouncing = false
|
||||
b.mu.Unlock()
|
||||
b.sendMediaSettings(false)
|
||||
err := b.readLoop()
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] Closed: %s", common.MaskError(err)))
|
||||
b.mu.Lock()
|
||||
b.vkWs = nil
|
||||
b.sfu = nil
|
||||
b.mu.Unlock()
|
||||
b.logger.Debug("[vk-ws] Rejoining in 3s...")
|
||||
time.Sleep(3 * time.Second)
|
||||
@@ -302,7 +288,7 @@ func (b *Bridge) Run(callInfo *CallInfo, cookieStr string, cfg VKConfig) {
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
wsEndpoint = joinResp.Endpoint
|
||||
wtEndpoint = joinResp.WtEndpoint
|
||||
callInfo.TurnServer = joinResp.TurnServer
|
||||
callInfo.StunServer = joinResp.StunServer
|
||||
b.iceServers = buildWebRTCICEServers(BuildICEServers(callInfo))
|
||||
|
||||
+62
-54
@@ -2,7 +2,6 @@ package vk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -15,15 +14,19 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/common/dialer"
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing-box/transport/call/headlessapi"
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel/rtc"
|
||||
"github.com/sagernet/sing-box/transport/call/wtsignal"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
headless "github.com/kulikov0/headless-client"
|
||||
"github.com/kulikov0/headless-client/webrtc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,6 +35,8 @@ const (
|
||||
vkMaxReconnectAttempts = 10
|
||||
)
|
||||
|
||||
const vkOrigin = "https://vk.ru"
|
||||
|
||||
const vkTopologyDirect = "DIRECT"
|
||||
|
||||
type vkAuthRottenError struct {
|
||||
@@ -94,8 +99,8 @@ type VKJoiner struct {
|
||||
pc *webrtc.PeerConnection
|
||||
sampleTrack *webrtc.TrackLocalStaticSample
|
||||
dc *webrtc.DataChannel
|
||||
vp8tunnel *tunnel.VP8DataTunnel
|
||||
sym *tunnel.SymmetricScreenTunnel
|
||||
vp8tunnel *rtc.VP8DataTunnel
|
||||
sym *rtc.SymmetricScreenTunnel
|
||||
producerScreen screenUplink
|
||||
obf *tunnel.TunnelObfuscator
|
||||
vp8FPS int
|
||||
@@ -165,8 +170,7 @@ func (h *VKJoiner) RunWithParams(jsonParams string) {
|
||||
}
|
||||
h.logger.Info(fmt.Sprintf("vk-joiner: reconnect attempt #%d", attempt))
|
||||
if err := h.runOnce(); err != nil {
|
||||
var authRotten *vkAuthRottenError
|
||||
if errors.As(err, &authRotten) {
|
||||
if _, ok := errors.AsType[*vkAuthRottenError](err); ok {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: %v, surrendering", err))
|
||||
return
|
||||
}
|
||||
@@ -313,17 +317,17 @@ func (h *VKJoiner) joinCall() error {
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{ServerName: parsed.Hostname()},
|
||||
DialContext: h.dialContext,
|
||||
},
|
||||
Transport: headless.ChromeWindows.Transport(headless.TLSOptions{
|
||||
ServerName: parsed.Hostname(),
|
||||
DialContext: h.dialContext,
|
||||
}),
|
||||
}
|
||||
req, err := http.NewRequest("POST", apiURL, strings.NewReader(body.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", common.UserAgent)
|
||||
req.Header.Set("User-Agent", headless.ChromeWindows.UserAgent())
|
||||
h.logger.Debug("vk-joiner: calling joinConversationByLink...")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -336,13 +340,13 @@ func (h *VKJoiner) joinCall() error {
|
||||
}
|
||||
var joinResp VKJoinResponse
|
||||
if jsonErr := json.Unmarshal(raw, &joinResp); jsonErr != nil {
|
||||
return fmt.Errorf("decode join response: %w (body: %s)", jsonErr, truncateBody(raw))
|
||||
return fmt.Errorf("decode join response: %w (body: %s)", jsonErr, common.BodySnippet(raw))
|
||||
}
|
||||
if joinResp.Endpoint == "" {
|
||||
if rotten := detectVKAuthRotten(raw); rotten != nil {
|
||||
return rotten
|
||||
}
|
||||
return fmt.Errorf("empty endpoint in join response: %s", truncateBody(raw))
|
||||
return fmt.Errorf("empty endpoint in join response: %s", common.BodySnippet(raw))
|
||||
}
|
||||
h.joinResp = &joinResp
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: joined, turn=%v", joinResp.TurnServer.URLs))
|
||||
@@ -373,7 +377,7 @@ func (h *VKJoiner) connectSFU() {
|
||||
"&appVersion=" + h.authParams.AppVersion +
|
||||
"&version=" + h.authParams.ProtocolVersion +
|
||||
"&device=browser&capabilities=" + capabilities + "&clientType=VK&tgt=join&compression=deflate-raw"
|
||||
sfu, err := wtsignal.Dial(wtURL, hostname, resolvedIP)
|
||||
sfu, err := wtsignal.Dial(wtURL, hostname, resolvedIP, vkOrigin)
|
||||
if err != nil {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: WebTransport connect failed: %s", common.MaskError(err)))
|
||||
return
|
||||
@@ -383,11 +387,11 @@ func (h *VKJoiner) connectSFU() {
|
||||
h.vkSeq = 0
|
||||
h.vkMu.Unlock()
|
||||
h.logger.Debug("vk-joiner: WebTransport connected")
|
||||
h.vkSend("update-media-modifiers", map[string]interface{}{
|
||||
"mediaModifiers": map[string]interface{}{"denoise": true, "denoiseAnn": true},
|
||||
h.vkSend("update-media-modifiers", map[string]any{
|
||||
"mediaModifiers": map[string]any{"denoise": true, "denoiseAnn": true},
|
||||
})
|
||||
h.vkSend("change-media-settings", map[string]interface{}{
|
||||
"mediaSettings": map[string]interface{}{
|
||||
h.vkSend("change-media-settings", map[string]any{
|
||||
"mediaSettings": map[string]any{
|
||||
"isAudioEnabled": false, "isVideoEnabled": true,
|
||||
"isScreenSharingEnabled": h.dualTrack, "isFastScreenSharingEnabled": false,
|
||||
"isAudioSharingEnabled": false, "isAnimojiEnabled": false,
|
||||
@@ -396,7 +400,7 @@ func (h *VKJoiner) connectSFU() {
|
||||
h.readLoop()
|
||||
}
|
||||
|
||||
func (h *VKJoiner) vkSend(command string, extra map[string]interface{}) {
|
||||
func (h *VKJoiner) vkSend(command string, extra map[string]any) {
|
||||
h.vkMu.Lock()
|
||||
defer h.vkMu.Unlock()
|
||||
if h.sfu == nil {
|
||||
@@ -410,7 +414,7 @@ func (h *VKJoiner) vkSend(command string, extra map[string]interface{}) {
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: -> %s", command))
|
||||
}
|
||||
|
||||
func (h *VKJoiner) vkSendTransmitData(participantId int64, payload map[string]interface{}) {
|
||||
func (h *VKJoiner) vkSendTransmitData(participantId int64, payload map[string]any) {
|
||||
h.vkMu.Lock()
|
||||
defer h.vkMu.Unlock()
|
||||
if h.sfu == nil {
|
||||
@@ -445,7 +449,7 @@ func (h *VKJoiner) readLoop() {
|
||||
}
|
||||
|
||||
func (h *VKJoiner) handleVKMessage(raw []byte) {
|
||||
var msg map[string]interface{}
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -457,7 +461,7 @@ func (h *VKJoiner) handleVKMessage(raw []byte) {
|
||||
case "connection":
|
||||
h.handleConnection(msg)
|
||||
case "transmitted-data":
|
||||
data, _ := msg["data"].(map[string]interface{})
|
||||
data, _ := msg["data"].(map[string]any)
|
||||
if data != nil {
|
||||
if pid, ok := msg["participantId"].(float64); ok && h.remotePeerID == nil {
|
||||
h.onRegisteredPeer(int64(pid))
|
||||
@@ -493,20 +497,20 @@ func (h *VKJoiner) handleVKMessage(raw []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VKJoiner) handleConnection(msg map[string]interface{}) {
|
||||
if conv, ok := msg["conversation"].(map[string]interface{}); ok {
|
||||
func (h *VKJoiner) handleConnection(msg map[string]any) {
|
||||
if conv, ok := msg["conversation"].(map[string]any); ok {
|
||||
topo, _ := conv["topology"].(string)
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: connection topology=%q", topo))
|
||||
}
|
||||
convParams, ok := msg["conversationParams"].(map[string]interface{})
|
||||
convParams, ok := msg["conversationParams"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
turn, ok := convParams["turn"].(map[string]interface{})
|
||||
turn, ok := convParams["turn"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
urlsRaw, _ := turn["urls"].([]interface{})
|
||||
urlsRaw, _ := turn["urls"].([]any)
|
||||
var urls []string
|
||||
for _, u := range urlsRaw {
|
||||
if s, ok := u.(string); ok {
|
||||
@@ -527,23 +531,34 @@ func (h *VKJoiner) handleConnection(msg map[string]interface{}) {
|
||||
func (h *VKJoiner) initPC() {
|
||||
var iceServers []webrtc.ICEServer
|
||||
if len(h.joinResp.StunServer.URLs) > 0 {
|
||||
iceServers = append(iceServers, webrtc.ICEServer{URLs: h.joinResp.StunServer.URLs})
|
||||
iceServers = append(iceServers, webrtc.ICEServer{
|
||||
URLs: common.ResolveICEHosts(h.joinResp.StunServer.URLs, h.DNSRouter, h.Dialer, h.logger, "vk-joiner"),
|
||||
})
|
||||
}
|
||||
if len(h.joinResp.TurnServer.URLs) > 0 {
|
||||
iceServers = append(iceServers, webrtc.ICEServer{
|
||||
URLs: h.joinResp.TurnServer.URLs,
|
||||
URLs: common.ResolveICEHosts(h.joinResp.TurnServer.URLs, h.DNSRouter, h.Dialer, h.logger, "vk-joiner"),
|
||||
Username: h.joinResp.TurnServer.Username,
|
||||
Credential: h.joinResp.TurnServer.Credential,
|
||||
})
|
||||
}
|
||||
mode := h.authParams.TunnelMode
|
||||
settingEngine := webrtc.SettingEngine{}
|
||||
settingEngine.DisableCloseByDTLS(true)
|
||||
settingEngine.DetachDataChannels()
|
||||
if h.PCConfig != nil {
|
||||
h.PCConfig.ConfigureSettingEngine(&settingEngine)
|
||||
api, err := headlessapi.WebRTCAPI(headlessapi.Options{
|
||||
Profile: headless.ChromeWindows.WithDTLS13Mimicry(),
|
||||
Configure: func(settingEngine *webrtc.SettingEngine) {
|
||||
settingEngine.DisableCloseByDTLS(true)
|
||||
settingEngine.DetachDataChannels()
|
||||
if h.PCConfig != nil {
|
||||
h.PCConfig.ConfigureSettingEngine(settingEngine)
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: failed to build webrtc api: %v", err))
|
||||
return
|
||||
}
|
||||
pc, err := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine)).NewPeerConnection(webrtc.Configuration{
|
||||
|
||||
pc, err := api.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: iceServers,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -571,7 +586,7 @@ func (h *VKJoiner) initPC() {
|
||||
h.reconnectAttempt.Store(0)
|
||||
h.logger.Info("vk-joiner: === DC TUNNEL CONNECTED ===")
|
||||
if h.OnConnected != nil {
|
||||
h.OnConnected(tunnel.NewDCTunnel(dc, h.obf, common.RTPBufSize, h.logger))
|
||||
h.OnConnected(rtc.NewDCTunnel(dc, h.obf, common.RTPBufSize, h.logger))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -594,15 +609,15 @@ func (h *VKJoiner) initPC() {
|
||||
if mode == "video" && state == webrtc.PeerConnectionStateConnected && h.vp8tunnel == nil {
|
||||
h.reconnectAttempt.Store(0)
|
||||
h.logger.Info("vk-joiner: === TUNNEL CONNECTED ===")
|
||||
h.vp8tunnel = tunnel.NewVP8DataTunnel(h.sampleTrack, h.obf, h.logger)
|
||||
h.vp8tunnel = rtc.NewVP8DataTunnel(h.sampleTrack, h.obf, h.logger)
|
||||
h.vp8tunnel.Start(h.vp8FPS, h.vp8Batch)
|
||||
var downlink tunnel.DataTunnel = h.vp8tunnel
|
||||
trackCount := 1
|
||||
if h.dualTrack {
|
||||
writer := tunnel.NewScreenWriter(h.obf, "screen-up", h.logger)
|
||||
writer := rtc.NewScreenWriter(h.obf, "screen-up", h.logger)
|
||||
writer.Reconfigure(h.vp8tunnel.FPS(), h.vp8tunnel.Batch())
|
||||
writer.SetSend(h.producerScreen.send)
|
||||
h.sym = tunnel.NewSymmetricScreenTunnel(h.vp8tunnel, writer, h.obf, h.producerScreen.ready, h.logger)
|
||||
h.sym = rtc.NewSymmetricScreenTunnel(h.vp8tunnel, writer, h.obf, h.producerScreen.ready, h.logger)
|
||||
h.sym.SetTrackCount(2)
|
||||
downlink = h.sym
|
||||
trackCount = 2
|
||||
@@ -660,12 +675,12 @@ func (h *VKJoiner) onLocalICECandidate(candidate *webrtc.ICECandidate) {
|
||||
}
|
||||
candidateJSON := candidate.ToJSON()
|
||||
raw, _ := json.Marshal(candidateJSON)
|
||||
var parsed interface{}
|
||||
var parsed any
|
||||
json.Unmarshal(raw, &parsed)
|
||||
h.vkSendTransmitData(*h.remotePeerID, map[string]interface{}{"candidate": parsed})
|
||||
h.vkSendTransmitData(*h.remotePeerID, map[string]any{"candidate": parsed})
|
||||
}
|
||||
|
||||
func (h *VKJoiner) onTransmittedData(data map[string]interface{}) {
|
||||
func (h *VKJoiner) onTransmittedData(data map[string]any) {
|
||||
if h.pc == nil {
|
||||
return
|
||||
}
|
||||
@@ -682,21 +697,22 @@ func (h *VKJoiner) onTransmittedData(data map[string]interface{}) {
|
||||
h.pendingICE = append(h.pendingICE, candidateInit)
|
||||
}
|
||||
}
|
||||
if sdp, ok := data["sdp"].(map[string]interface{}); ok {
|
||||
if sdp, ok := data["sdp"].(map[string]any); ok {
|
||||
sdpType, _ := sdp["type"].(string)
|
||||
sdpStr, _ := sdp["sdp"].(string)
|
||||
if h.OnRemoteCandidate != nil {
|
||||
h.OnRemoteCandidate(-1, sdpStr)
|
||||
}
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: remote SDP: %s", sdpType))
|
||||
if sdpType == "answer" {
|
||||
switch sdpType {
|
||||
case "answer":
|
||||
h.pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: sdpStr})
|
||||
h.remoteSet = true
|
||||
for _, candidate := range h.pendingICE {
|
||||
h.pc.AddICECandidate(candidate)
|
||||
}
|
||||
h.pendingICE = nil
|
||||
} else if sdpType == "offer" {
|
||||
case "offer":
|
||||
h.pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: sdpStr})
|
||||
h.remoteSet = true
|
||||
for _, candidate := range h.pendingICE {
|
||||
@@ -724,7 +740,7 @@ func (h *VKJoiner) onTransmittedData(data map[string]interface{}) {
|
||||
}
|
||||
|
||||
func detectVKAuthRotten(raw []byte) *vkAuthRottenError {
|
||||
var generic map[string]interface{}
|
||||
var generic map[string]any
|
||||
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -747,11 +763,3 @@ func detectVKAuthRotten(raw []byte) *vkAuthRottenError {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateBody(raw []byte) string {
|
||||
const maxLen = 200
|
||||
if len(raw) > maxLen {
|
||||
return string(raw[:maxLen]) + "..."
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
+17
-17
@@ -4,8 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/kulikov0/headless-client/webrtc"
|
||||
)
|
||||
|
||||
type P2PHandler struct {
|
||||
@@ -72,7 +71,7 @@ func (p *P2PHandler) OnRegisteredPeer(participantId int64) {
|
||||
p.sendOfferToPeer(participantId)
|
||||
}
|
||||
|
||||
func (p *P2PHandler) OnTransmittedData(data map[string]interface{}) {
|
||||
func (p *P2PHandler) OnTransmittedData(data map[string]any) {
|
||||
if cand, ok := data["candidate"]; ok {
|
||||
p.bridge.logger.Debug("[p2p] Remote ICE candidate")
|
||||
candJSON, _ := json.Marshal(cand)
|
||||
@@ -80,19 +79,20 @@ func (p *P2PHandler) OnTransmittedData(data map[string]interface{}) {
|
||||
json.Unmarshal(candJSON, &candInit)
|
||||
p.bridge.relay.AddICECandidate(candInit)
|
||||
}
|
||||
if sdp, ok := data["sdp"].(map[string]interface{}); ok {
|
||||
if sdp, ok := data["sdp"].(map[string]any); ok {
|
||||
sdpType, _ := sdp["type"].(string)
|
||||
sdpStr, _ := sdp["sdp"].(string)
|
||||
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Remote SDP: %s", sdpType))
|
||||
if sdpType == "answer" {
|
||||
switch sdpType {
|
||||
case "answer":
|
||||
p.bridge.relay.SetRemoteDescription(webrtc.SDPTypeAnswer, sdpStr)
|
||||
} else if sdpType == "offer" {
|
||||
case "offer":
|
||||
p.bridge.relay.SetRemoteDescription(webrtc.SDPTypeOffer, sdpStr)
|
||||
answer, err := p.bridge.relay.CreateAnswer()
|
||||
if err == nil && p.remotePeerId != nil {
|
||||
p.bridge.vkSend("transmit-data", map[string]interface{}{
|
||||
p.bridge.vkSend("transmit-data", map[string]any{
|
||||
"participantId": *p.remotePeerId,
|
||||
"data": map[string]interface{}{"sdp": map[string]interface{}{
|
||||
"data": map[string]any{"sdp": map[string]any{
|
||||
"type": answer.Type.String(), "sdp": answer.SDP,
|
||||
}},
|
||||
})
|
||||
@@ -103,11 +103,11 @@ func (p *P2PHandler) OnTransmittedData(data map[string]interface{}) {
|
||||
|
||||
func (p *P2PHandler) OnPionICECandidate(data json.RawMessage) {
|
||||
if p.remotePeerId != nil {
|
||||
var cand interface{}
|
||||
var cand any
|
||||
json.Unmarshal(data, &cand)
|
||||
p.bridge.vkSend("transmit-data", map[string]interface{}{
|
||||
p.bridge.vkSend("transmit-data", map[string]any{
|
||||
"participantId": *p.remotePeerId,
|
||||
"data": map[string]interface{}{"candidate": cand},
|
||||
"data": map[string]any{"candidate": cand},
|
||||
})
|
||||
} else {
|
||||
var candInit webrtc.ICECandidateInit
|
||||
@@ -153,7 +153,7 @@ func (p *P2PHandler) setupCallbacks() {
|
||||
|
||||
func (p *P2PHandler) kickRemotePeer() {
|
||||
if p.remotePeerId != nil {
|
||||
p.bridge.vkSend("remove-participant", map[string]interface{}{
|
||||
p.bridge.vkSend("remove-participant", map[string]any{
|
||||
"participantId": *p.remotePeerId,
|
||||
"ban": false,
|
||||
})
|
||||
@@ -173,19 +173,19 @@ func (p *P2PHandler) sendOfferToPeer(participantId int64) {
|
||||
seq := p.bridge.vkSeq
|
||||
raw := fmt.Sprintf(`{"command":"transmit-data","sequence":%d,"participantId":%d,"data":{"sdp":{"type":%q,"sdp":%s}}}`,
|
||||
seq, participantId, offer.Type.String(), sdpStr)
|
||||
if p.bridge.vkWs != nil {
|
||||
p.bridge.vkWs.WriteMessage(websocket.TextMessage, []byte(raw))
|
||||
if p.bridge.sfu != nil {
|
||||
p.bridge.sfu.Send([]byte(raw))
|
||||
}
|
||||
p.bridge.mu.Unlock()
|
||||
p.bridge.logger.Debug("[vk-ws] -> transmit-data (offer)")
|
||||
}
|
||||
for _, cand := range candidates {
|
||||
candJSON, _ := json.Marshal(cand)
|
||||
var c interface{}
|
||||
var c any
|
||||
json.Unmarshal(candJSON, &c)
|
||||
p.bridge.vkSend("transmit-data", map[string]interface{}{
|
||||
p.bridge.vkSend("transmit-data", map[string]any{
|
||||
"participantId": participantId,
|
||||
"data": map[string]interface{}{"candidate": c},
|
||||
"data": map[string]any{"candidate": c},
|
||||
})
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
|
||||
+11
-19
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
|
||||
headless "github.com/kulikov0/headless-client"
|
||||
)
|
||||
|
||||
type vkAuthConfig struct {
|
||||
@@ -32,10 +34,10 @@ type vkCaptchaError struct {
|
||||
|
||||
func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.ContextLogger) (string, error) {
|
||||
client := common.HttpClient(dialer)
|
||||
httpPost := func(targetURL string, form url.Values, extraHeaders map[string]string) (map[string]interface{}, error) {
|
||||
httpPost := func(targetURL string, form url.Values, extraHeaders map[string]string) (map[string]any, error) {
|
||||
req, _ := http.NewRequest("POST", targetURL, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", common.UserAgent)
|
||||
req.Header.Set("User-Agent", headless.ChromeWindows.UserAgent())
|
||||
req.Header.Set("Origin", "https://vk.ru")
|
||||
req.Header.Set("Referer", "https://vk.ru/")
|
||||
for k, v := range extraHeaders {
|
||||
@@ -50,7 +52,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("json: %w (body: %s)", err, string(body[:minInt(len(body), 200)]))
|
||||
}
|
||||
@@ -70,7 +72,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get_anonym_token: %w", err)
|
||||
}
|
||||
dataMap, _ := anonResp["data"].(map[string]interface{})
|
||||
dataMap, _ := anonResp["data"].(map[string]any)
|
||||
accessToken, _ := dataMap["access_token"].(string)
|
||||
if accessToken == "" {
|
||||
return "", fmt.Errorf("empty access_token: %v", anonResp)
|
||||
@@ -84,8 +86,8 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("calls.getSettings: %w", err)
|
||||
}
|
||||
if respObj, ok := settingsResp["response"].(map[string]interface{}); ok {
|
||||
if settings, ok := respObj["settings"].(map[string]interface{}); ok {
|
||||
if respObj, ok := settingsResp["response"].(map[string]any); ok {
|
||||
if settings, ok := respObj["settings"].(map[string]any); ok {
|
||||
if pk, ok := settings["public_key"].(string); ok {
|
||||
cfg.PublicKey = pk
|
||||
}
|
||||
@@ -98,7 +100,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
|
||||
"vk_join_link": {joinLink},
|
||||
}, auth)
|
||||
if err == nil {
|
||||
if respObj, ok := previewResp["response"].(map[string]interface{}); ok {
|
||||
if respObj, ok := previewResp["response"].(map[string]any); ok {
|
||||
if okLink, ok := respObj["ok_join_link"].(string); ok {
|
||||
cfg.OkJoinLink = okLink
|
||||
}
|
||||
@@ -113,12 +115,12 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
|
||||
var callToken string
|
||||
var apiBaseURL string
|
||||
var okJoinLink string
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
for range 5 {
|
||||
callResp, err := httpPost("https://api.vk.ru/method/calls.getAnonymousToken", callParams, auth)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getAnonymousToken: %w", err)
|
||||
}
|
||||
if errObj, hasErr := callResp["error"].(map[string]interface{}); hasErr {
|
||||
if errObj, hasErr := callResp["error"].(map[string]any); hasErr {
|
||||
errCode, _ := errObj["error_code"].(float64)
|
||||
if int(errCode) == 14 {
|
||||
captchaErr := parseVKCaptchaError(errObj)
|
||||
@@ -156,7 +158,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
|
||||
}
|
||||
return "", fmt.Errorf("VK API error: %v", errObj)
|
||||
}
|
||||
respMap, ok := callResp["response"].(map[string]interface{})
|
||||
respMap, ok := callResp["response"].(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected response: %v", callResp)
|
||||
}
|
||||
@@ -174,7 +176,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
|
||||
baseURL += "/fb.do"
|
||||
}
|
||||
deviceID := fmt.Sprintf("%d", rand.Int63n(9e18))
|
||||
sessionData, _ := json.Marshal(map[string]interface{}{
|
||||
sessionData, _ := json.Marshal(map[string]any{
|
||||
"version": 2,
|
||||
"device_id": deviceID,
|
||||
"client_version": cfg.AppVersion,
|
||||
@@ -215,7 +217,7 @@ func RunVKAuth(dialer N.Dialer, joinLink, displayName string, logger logger.Cont
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func parseVKCaptchaError(errObj map[string]interface{}) *vkCaptchaError {
|
||||
func parseVKCaptchaError(errObj map[string]any) *vkCaptchaError {
|
||||
redirectURI, _ := errObj["redirect_uri"].(string)
|
||||
if redirectURI == "" {
|
||||
return nil
|
||||
|
||||
+116
-28
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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()}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user