mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-08-08 19:45:18 +03:00
Add call protocol, Rmux. Update AmneziaWG. Fixes and improvements
This commit is contained in:
284
transport/call/vk/api.go
Normal file
284
transport/call/vk/api.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
type TurnServer struct {
|
||||
URLs []string `json:"urls"`
|
||||
Username string `json:"username"`
|
||||
Credential string `json:"credential"`
|
||||
}
|
||||
|
||||
type StunServer struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
type CallInfo struct {
|
||||
CallID string
|
||||
JoinLink string
|
||||
ShortLink string
|
||||
OKJoinLink string
|
||||
TurnServer TurnServer
|
||||
StunServer StunServer
|
||||
WSEndpoint string
|
||||
}
|
||||
|
||||
type vkTokenResponse struct {
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type callSettingsResponse struct {
|
||||
Response struct {
|
||||
Settings struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
} `json:"settings"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
type callTokenResponse struct {
|
||||
Response struct {
|
||||
Token string `json:"token"`
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
type okAuthResponse struct {
|
||||
SessionKey string `json:"session_key"`
|
||||
}
|
||||
|
||||
type joinResponse struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
TurnServer TurnServer `json:"turn_server"`
|
||||
StunServer StunServer `json:"stun_server"`
|
||||
}
|
||||
|
||||
func JoinExistingCall(dialer N.Dialer, cookieStr, vkLink string, cfg VKConfig, logger logger.ContextLogger) (*CallInfo, error) {
|
||||
if cfg.AppID == "" || cfg.APIVersion == "" {
|
||||
return nil, fmt.Errorf("config incomplete: app_id=%q api=%q", cfg.AppID, cfg.APIVersion)
|
||||
}
|
||||
token := extractJoinToken(vkLink)
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("could not extract join token from %q", vkLink)
|
||||
}
|
||||
logger.Info(fmt.Sprintf("[auth] Joining existing call token=%s", token))
|
||||
resp, err := authAndJoin(dialer, cookieStr, token, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CallInfo{
|
||||
JoinLink: vkLink,
|
||||
OKJoinLink: token,
|
||||
TurnServer: resp.TurnServer,
|
||||
StunServer: resp.StunServer,
|
||||
WSEndpoint: resp.Endpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func CreateAndJoinCall(dialer N.Dialer, cookieStr, peerId string, cfg VKConfig, logger logger.ContextLogger) (*CallInfo, error) {
|
||||
if cfg.AppID == "" || cfg.APIVersion == "" {
|
||||
return nil, fmt.Errorf("config incomplete: app_id=%q api=%q", cfg.AppID, cfg.APIVersion)
|
||||
}
|
||||
auth := func(bearer string) map[string]string {
|
||||
return map[string]string{"Authorization": "Bearer " + bearer}
|
||||
}
|
||||
logger.Info("[auth] Getting VK token...")
|
||||
r, err := httpPost(dialer, "https://login.vk.com/?act=web_token",
|
||||
url.Values{"version": {"1"}, "app_id": {cfg.AppID}},
|
||||
map[string]string{"Cookie": cookieStr})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web_token: %w", err)
|
||||
}
|
||||
var tok vkTokenResponse
|
||||
json.Unmarshal(r, &tok)
|
||||
vkToken := tok.Data.AccessToken
|
||||
if vkToken == "" {
|
||||
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",
|
||||
url.Values{"v": {cfg.APIVersion}, "peer_id": {peerId}}, auth(vkToken))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calls.start: %w", err)
|
||||
}
|
||||
var call struct {
|
||||
Response struct {
|
||||
CallID string `json:"call_id"`
|
||||
JoinLink string `json:"join_link"`
|
||||
OKJoinLink string `json:"ok_join_link"`
|
||||
ShortCredentials struct {
|
||||
LinkWithPassword string `json:"link_with_password"`
|
||||
} `json:"short_credentials"`
|
||||
} `json:"response"`
|
||||
}
|
||||
json.Unmarshal(r, &call)
|
||||
c := call.Response
|
||||
if c.CallID == "" {
|
||||
return nil, fmt.Errorf("empty call_id, response: %s", string(r))
|
||||
}
|
||||
if c.OKJoinLink == "" {
|
||||
return nil, fmt.Errorf("empty ok_join_link, response: %s", string(r))
|
||||
}
|
||||
logger.Debug(fmt.Sprintf("[auth] call_id: %s", c.CallID))
|
||||
logger.Debug(fmt.Sprintf("[auth] join_link: %s", c.JoinLink))
|
||||
logger.Info("[auth] Joining conversation...")
|
||||
resp, err := authAndJoin(dialer, cookieStr, c.OKJoinLink, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CallInfo{
|
||||
CallID: c.CallID, JoinLink: c.JoinLink, ShortLink: c.ShortCredentials.LinkWithPassword,
|
||||
OKJoinLink: c.OKJoinLink, TurnServer: resp.TurnServer, StunServer: resp.StunServer,
|
||||
WSEndpoint: resp.Endpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func BuildICEServers(callInfo *CallInfo) []ICEServerSpec {
|
||||
var servers []ICEServerSpec
|
||||
if len(callInfo.StunServer.URLs) > 0 {
|
||||
servers = append(servers, ICEServerSpec{URLs: callInfo.StunServer.URLs})
|
||||
}
|
||||
if len(callInfo.TurnServer.URLs) > 0 {
|
||||
urls := append([]string{}, callInfo.TurnServer.URLs...)
|
||||
urls = append(urls, urls[len(urls)-1]+"?transport=tcp")
|
||||
servers = append(servers, ICEServerSpec{
|
||||
URLs: urls, Username: callInfo.TurnServer.Username, Credential: callInfo.TurnServer.Credential,
|
||||
})
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
type ICEServerSpec struct {
|
||||
URLs []string
|
||||
Username string
|
||||
Credential string
|
||||
}
|
||||
|
||||
func authAndJoin(dialer N.Dialer, cookieStr, okJoinLink string, cfg VKConfig) (*joinResponse, error) {
|
||||
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",
|
||||
url.Values{"version": {"1"}, "app_id": {cfg.AppID}},
|
||||
map[string]string{"Cookie": cookieStr})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web_token: %w", err)
|
||||
}
|
||||
var tok vkTokenResponse
|
||||
json.Unmarshal(r, &tok)
|
||||
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",
|
||||
url.Values{"v": {cfg.APIVersion}}, auth(tok.Data.AccessToken))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calls.getSettings: %w", err)
|
||||
}
|
||||
var settings callSettingsResponse
|
||||
json.Unmarshal(r, &settings)
|
||||
appKey := settings.Response.Settings.PublicKey
|
||||
if appKey == "" {
|
||||
return nil, fmt.Errorf("empty public_key, response: %s", string(r))
|
||||
}
|
||||
r, err = httpPost(dialer, "https://api.vk.com/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)
|
||||
}
|
||||
var callToken callTokenResponse
|
||||
json.Unmarshal(r, &callToken)
|
||||
if callToken.Response.Token == "" {
|
||||
return nil, fmt.Errorf("empty call token, response: %s", string(r))
|
||||
}
|
||||
if callToken.Response.APIBaseURL == "" {
|
||||
return nil, fmt.Errorf("empty api_base_url, response: %s", string(r))
|
||||
}
|
||||
apiBaseURL := strings.TrimRight(callToken.Response.APIBaseURL, "/")
|
||||
if !strings.HasSuffix(apiBaseURL, "/fb.do") {
|
||||
apiBaseURL += "/fb.do"
|
||||
}
|
||||
sd, _ := json.Marshal(map[string]interface{}{
|
||||
"device_id": "sing-box-go-1", "client_version": cfg.AppVersion,
|
||||
"client_type": "SDK_JS", "auth_token": callToken.Response.Token, "version": 3,
|
||||
})
|
||||
r, err = httpPost(dialer, apiBaseURL, url.Values{
|
||||
"method": {"auth.anonymLogin"}, "application_key": {appKey},
|
||||
"format": {"json"}, "session_data": {string(sd)},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth.anonymLogin: %w", err)
|
||||
}
|
||||
var okAuth okAuthResponse
|
||||
json.Unmarshal(r, &okAuth)
|
||||
if okAuth.SessionKey == "" {
|
||||
return nil, fmt.Errorf("empty session_key, response: %s", string(r))
|
||||
}
|
||||
ms, _ := json.Marshal(map[string]bool{
|
||||
"isAudioEnabled": false, "isVideoEnabled": true, "isScreenSharingEnabled": false,
|
||||
})
|
||||
r, err = httpPost(dialer, apiBaseURL, url.Values{
|
||||
"method": {"vchat.joinConversationByLink"}, "session_key": {okAuth.SessionKey},
|
||||
"application_key": {appKey}, "format": {"json"}, "joinLink": {okJoinLink},
|
||||
"isVideo": {"true"}, "isAudio": {"false"}, "mediaSettings": {string(ms)},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vchat.joinConversationByLink: %w", err)
|
||||
}
|
||||
var jr joinResponse
|
||||
json.Unmarshal(r, &jr)
|
||||
if jr.Endpoint == "" {
|
||||
return nil, fmt.Errorf("empty WS endpoint, response: %s", string(r))
|
||||
}
|
||||
return &jr, nil
|
||||
}
|
||||
|
||||
func extractJoinToken(link string) string {
|
||||
link = strings.TrimSpace(link)
|
||||
if link == "" {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(link); err == nil && u.Scheme != "" {
|
||||
path := strings.Trim(u.Path, "/")
|
||||
if path != "" {
|
||||
parts := strings.Split(path, "/")
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
if !strings.ContainsAny(link, "/?&=") {
|
||||
return link
|
||||
}
|
||||
parts := strings.Split(strings.TrimRight(link, "/"), "/")
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
func httpPost(dialer N.Dialer, endpoint string, form url.Values, extraHeaders map[string]string) ([]byte, error) {
|
||||
body := form.Encode()
|
||||
req, err := http.NewRequest("POST", endpoint, strings.NewReader(body))
|
||||
if err != nil {
|
||||
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/")
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := common.HttpClient(dialer).Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
375
transport/call/vk/captcha_proxy.go
Normal file
375
transport/call/vk/captcha_proxy.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
var activeCaptchaProxy struct {
|
||||
sync.Mutex
|
||||
listener net.Listener
|
||||
port int
|
||||
keyCh chan string
|
||||
doneCh chan struct{}
|
||||
}
|
||||
|
||||
func StartCaptchaProxy(redirectURI string, dialer N.Dialer) int {
|
||||
StopCaptchaProxy()
|
||||
targetURL, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
localOrigin := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
upstreamOrigin := targetURL.Scheme + "://" + targetURL.Host
|
||||
keyCh := make(chan string, 1)
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ForceAttemptHTTP2: false,
|
||||
}
|
||||
if dialer != nil {
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||
}
|
||||
}
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Transport: transport,
|
||||
Rewrite: func(req *httputil.ProxyRequest) {
|
||||
req.Out.URL.Scheme = targetURL.Scheme
|
||||
req.Out.URL.Host = targetURL.Host
|
||||
if req.Out.URL.Path == "" {
|
||||
req.Out.URL.Path = targetURL.Path
|
||||
}
|
||||
req.Out.Host = targetURL.Host
|
||||
req.Out.Header.Del("Accept-Encoding")
|
||||
req.Out.Header.Del("TE")
|
||||
for _, headerName := range []string{"Origin", "Referer"} {
|
||||
val := req.Out.Header.Get(headerName)
|
||||
if val != "" {
|
||||
req.Out.Header.Set(headerName, strings.ReplaceAll(val, localOrigin, upstreamOrigin))
|
||||
}
|
||||
}
|
||||
},
|
||||
ModifyResponse: func(res *http.Response) error {
|
||||
rewriteProxyCookies(res)
|
||||
if res.StatusCode >= 300 && res.StatusCode < 400 {
|
||||
if loc := res.Header.Get("Location"); loc != "" {
|
||||
res.Header.Set("Location", strings.ReplaceAll(loc, upstreamOrigin, localOrigin))
|
||||
}
|
||||
}
|
||||
contentType := res.Header.Get("Content-Type")
|
||||
shouldInspect := isHTMLLike(contentType) || strings.Contains(res.Request.URL.Path, "captchaNotRobot.check")
|
||||
if !shouldInspect {
|
||||
return nil
|
||||
}
|
||||
reader := res.Body
|
||||
decompressed := false
|
||||
if res.Header.Get("Content-Encoding") == "gzip" {
|
||||
gzReader, err := gzip.NewReader(res.Body)
|
||||
if err == nil {
|
||||
reader = gzReader
|
||||
decompressed = true
|
||||
defer gzReader.Close()
|
||||
}
|
||||
}
|
||||
bodyBytes, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Body.Close()
|
||||
if strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") {
|
||||
token := extractSuccessToken(bodyBytes)
|
||||
if token != "" {
|
||||
select {
|
||||
case keyCh <- token:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
if isHTMLLike(contentType) {
|
||||
for _, h := range []string{
|
||||
"Content-Security-Policy", "Content-Security-Policy-Report-Only",
|
||||
"X-Content-Security-Policy", "X-WebKit-CSP",
|
||||
"Cross-Origin-Opener-Policy", "Cross-Origin-Embedder-Policy",
|
||||
"Cross-Origin-Resource-Policy", "X-Frame-Options",
|
||||
"Strict-Transport-Security", "Alt-Svc",
|
||||
} {
|
||||
res.Header.Del(h)
|
||||
}
|
||||
bodyBytes = []byte(rewriteCaptchaHTML(string(bodyBytes), localOrigin, upstreamOrigin))
|
||||
}
|
||||
if decompressed {
|
||||
res.Header.Del("Content-Encoding")
|
||||
}
|
||||
res.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
res.ContentLength = int64(len(bodyBytes))
|
||||
res.Header.Set("Content-Length", fmt.Sprint(len(bodyBytes)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.FormValue("token")
|
||||
if token != "" {
|
||||
select {
|
||||
case keyCh <- token:
|
||||
default:
|
||||
}
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
fmt.Fprint(w, "ok")
|
||||
})
|
||||
mux.HandleFunc("/generic_proxy", func(w http.ResponseWriter, r *http.Request) {
|
||||
proxyURL := r.URL.Query().Get("proxy_url")
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
http.Error(w, "Bad URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
genericProxy := &httputil.ReverseProxy{
|
||||
Transport: transport,
|
||||
Rewrite: func(req *httputil.ProxyRequest) {
|
||||
req.Out.URL.Scheme = parsed.Scheme
|
||||
req.Out.URL.Host = parsed.Host
|
||||
req.Out.URL.Path = parsed.Path
|
||||
req.Out.URL.RawQuery = parsed.RawQuery
|
||||
req.Out.Host = parsed.Host
|
||||
req.Out.Header.Del("Accept-Encoding")
|
||||
},
|
||||
}
|
||||
genericProxy.ServeHTTP(w, r)
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" {
|
||||
localPath := targetURL.Path
|
||||
if targetURL.RawQuery != "" {
|
||||
localPath += "?" + targetURL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, localPath, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
})
|
||||
activeCaptchaProxy.Lock()
|
||||
activeCaptchaProxy.listener = listener
|
||||
activeCaptchaProxy.port = port
|
||||
activeCaptchaProxy.keyCh = keyCh
|
||||
activeCaptchaProxy.doneCh = make(chan struct{})
|
||||
activeCaptchaProxy.Unlock()
|
||||
go http.Serve(listener, mux)
|
||||
return port
|
||||
}
|
||||
|
||||
func GetCaptchaResult() string {
|
||||
activeCaptchaProxy.Lock()
|
||||
ch := activeCaptchaProxy.keyCh
|
||||
done := activeCaptchaProxy.doneCh
|
||||
activeCaptchaProxy.Unlock()
|
||||
if ch == nil || done == nil {
|
||||
return ""
|
||||
}
|
||||
select {
|
||||
case token := <-ch:
|
||||
return token
|
||||
case <-done:
|
||||
return ""
|
||||
case <-time.After(300 * time.Second):
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func StopCaptchaProxy() {
|
||||
activeCaptchaProxy.Lock()
|
||||
ln := activeCaptchaProxy.listener
|
||||
done := activeCaptchaProxy.doneCh
|
||||
activeCaptchaProxy.listener = nil
|
||||
activeCaptchaProxy.port = 0
|
||||
activeCaptchaProxy.keyCh = nil
|
||||
activeCaptchaProxy.doneCh = nil
|
||||
activeCaptchaProxy.Unlock()
|
||||
if done != nil {
|
||||
close(done)
|
||||
}
|
||||
if ln != nil {
|
||||
ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteProxyCookies(res *http.Response) {
|
||||
cookies := res.Cookies()
|
||||
if len(cookies) == 0 {
|
||||
return
|
||||
}
|
||||
res.Header.Del("Set-Cookie")
|
||||
for _, cookie := range cookies {
|
||||
cookie.Domain = ""
|
||||
cookie.Secure = false
|
||||
cookie.Partitioned = false
|
||||
if cookie.SameSite == http.SameSiteNoneMode || cookie.SameSite == http.SameSiteStrictMode {
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
}
|
||||
res.Header.Add("Set-Cookie", cookie.String())
|
||||
}
|
||||
}
|
||||
|
||||
func isHTMLLike(contentType string) bool {
|
||||
return strings.Contains(contentType, "text/html") ||
|
||||
strings.Contains(contentType, "application/xhtml+xml")
|
||||
}
|
||||
|
||||
func extractSuccessToken(body []byte) string {
|
||||
var payload struct {
|
||||
Response struct {
|
||||
SuccessToken string `json:"success_token"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
return payload.Response.SuccessToken
|
||||
}
|
||||
|
||||
func rewriteCaptchaHTML(html, localOrigin, upstreamOrigin string) string {
|
||||
html = strings.ReplaceAll(html, upstreamOrigin, localOrigin)
|
||||
script := fmt.Sprintf(`
|
||||
<script>
|
||||
(function() {
|
||||
var localOrigin = %q;
|
||||
var upstreamOrigin = %q;
|
||||
|
||||
function rewriteUrl(urlStr) {
|
||||
if (!urlStr || typeof urlStr !== 'string') return urlStr;
|
||||
if (urlStr.indexOf(localOrigin) === 0) return urlStr;
|
||||
if (urlStr.indexOf(upstreamOrigin) === 0) return localOrigin + urlStr.slice(upstreamOrigin.length);
|
||||
if (urlStr.indexOf('//') === 0) {
|
||||
return '/generic_proxy?proxy_url=' + encodeURIComponent(window.location.protocol + urlStr);
|
||||
}
|
||||
if (urlStr.indexOf('http://') === 0 || urlStr.indexOf('https://') === 0) {
|
||||
return '/generic_proxy?proxy_url=' + encodeURIComponent(urlStr);
|
||||
}
|
||||
return urlStr;
|
||||
}
|
||||
|
||||
function rewriteElementAttr(el, attr) {
|
||||
if (!el || !el.getAttribute) return;
|
||||
var value = el.getAttribute(attr);
|
||||
if (!value) return;
|
||||
var rewritten = rewriteUrl(value);
|
||||
if (rewritten !== value) el.setAttribute(attr, rewritten);
|
||||
}
|
||||
|
||||
function rewriteDocument(root) {
|
||||
if (!root || !root.querySelectorAll) return;
|
||||
root.querySelectorAll('[href]').forEach(function(el) { rewriteElementAttr(el, 'href'); });
|
||||
root.querySelectorAll('[src]').forEach(function(el) { rewriteElementAttr(el, 'src'); });
|
||||
root.querySelectorAll('form[action]').forEach(function(el) { rewriteElementAttr(el, 'action'); });
|
||||
}
|
||||
|
||||
function handleSuccessToken(token) {
|
||||
if (!token) return;
|
||||
fetch('/local-captcha-result', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'token=' + encodeURIComponent(token)
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
var origOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function() {
|
||||
if (arguments[1] && typeof arguments[1] === 'string') {
|
||||
this._origUrl = arguments[1];
|
||||
arguments[1] = rewriteUrl(arguments[1]);
|
||||
}
|
||||
return origOpen.apply(this, arguments);
|
||||
};
|
||||
var origSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.send = function() {
|
||||
var xhr = this;
|
||||
if (this._origUrl && this._origUrl.indexOf('captchaNotRobot.check') !== -1) {
|
||||
xhr.addEventListener('load', function() {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
if (data.response && data.response.success_token) handleSuccessToken(data.response.success_token);
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
return origSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
var origFetch = window.fetch;
|
||||
if (origFetch) {
|
||||
window.fetch = function() {
|
||||
var url = arguments[0];
|
||||
var urlStr = (typeof url === 'object' && url && url.url) ? url.url : url;
|
||||
var origUrlStr = urlStr;
|
||||
if (typeof urlStr === 'string') {
|
||||
urlStr = rewriteUrl(urlStr);
|
||||
arguments[0] = urlStr;
|
||||
}
|
||||
var p = origFetch.apply(this, arguments);
|
||||
if (typeof origUrlStr === 'string' && origUrlStr.indexOf('captchaNotRobot.check') !== -1) {
|
||||
p.then(function(r) { return r.clone().json(); }).then(function(data) {
|
||||
if (data.response && data.response.success_token) handleSuccessToken(data.response.success_token);
|
||||
}).catch(function() {});
|
||||
}
|
||||
return p;
|
||||
};
|
||||
}
|
||||
|
||||
var origWindowOpen = window.open;
|
||||
if (origWindowOpen) {
|
||||
window.open = function(url) {
|
||||
if (typeof url === 'string') arguments[0] = rewriteUrl(url);
|
||||
return origWindowOpen.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
rewriteDocument(document);
|
||||
if (document.documentElement && window.MutationObserver) {
|
||||
new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === 'attributes' && mutation.target) {
|
||||
rewriteElementAttr(mutation.target, mutation.attributeName);
|
||||
return;
|
||||
}
|
||||
mutation.addedNodes.forEach(function(node) {
|
||||
if (node.nodeType === 1) rewriteDocument(node);
|
||||
});
|
||||
});
|
||||
}).observe(document.documentElement, {
|
||||
subtree: true, childList: true, attributes: true,
|
||||
attributeFilter: ['href', 'src', 'action']
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
`, localOrigin, upstreamOrigin)
|
||||
if idx := strings.Index(html, "</head>"); idx >= 0 {
|
||||
return html[:idx] + script + html[idx:]
|
||||
}
|
||||
if idx := strings.Index(html, "</body>"); idx >= 0 {
|
||||
return html[:idx] + script + html[idx:]
|
||||
}
|
||||
return html + script
|
||||
}
|
||||
28
transport/call/vk/config.go
Normal file
28
transport/call/vk/config.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
)
|
||||
|
||||
type VKConfig struct {
|
||||
AppID string
|
||||
APIVersion string
|
||||
SDKVersion string
|
||||
AppVersion string
|
||||
ProtocolVersion string
|
||||
}
|
||||
|
||||
func FetchConfig(logger logger.ContextLogger) (VKConfig, error) {
|
||||
cfg := VKConfig{
|
||||
AppID: "6287487",
|
||||
APIVersion: "5.280",
|
||||
SDKVersion: "2.8.6-beta.22",
|
||||
AppVersion: "1.1",
|
||||
ProtocolVersion: "6",
|
||||
}
|
||||
logger.Debug(fmt.Sprintf("[config] app_id=%s api=%s sdk=%s app=%s proto=%s",
|
||||
cfg.AppID, cfg.APIVersion, cfg.SDKVersion, cfg.AppVersion, cfg.ProtocolVersion))
|
||||
return cfg, nil
|
||||
}
|
||||
125
transport/call/vk/connect.go
Normal file
125
transport/call/vk/connect.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
func ConnectCreator(ctx context.Context, cookieStr, joinLink string, readBuf int, dialer N.Dialer, logger logger.ContextLogger) (*tunnel.RelayBridge, string, error) {
|
||||
cfg, err := FetchConfig(logger)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var callInfo *CallInfo
|
||||
if joinLink != "" {
|
||||
callInfo, err = JoinExistingCall(dialer, cookieStr, joinLink, cfg, logger)
|
||||
} else {
|
||||
callInfo, err = CreateAndJoinCall(dialer, cookieStr, "", cfg, logger)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if readBuf <= 0 {
|
||||
readBuf = 32768
|
||||
}
|
||||
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(callInfo.JoinLink))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("vk: obfuscator init: %w", err)
|
||||
}
|
||||
bridge := &Bridge{
|
||||
dialer: dialer,
|
||||
readBuf: readBuf,
|
||||
logger: logger,
|
||||
}
|
||||
bridge.newRelay = func() Relay {
|
||||
ur := NewTunnelRelay(dialer, logger)
|
||||
ur.readBufSize = readBuf
|
||||
ur.SetObfuscator(obf)
|
||||
ur.OnConnected = func(tun tunnel.DataTunnel) {
|
||||
bridgeReadBuf := common.VP8BufSize
|
||||
if _, ok := tun.(*tunnel.DCTunnel); ok {
|
||||
bridgeReadBuf = readBuf
|
||||
}
|
||||
rb := tunnel.NewRelayBridge(tun, "creator", bridgeReadBuf, dialer, logger)
|
||||
rb.MarkReady()
|
||||
if st, ok := tun.(*tunnel.SymmetricScreenTunnel); ok {
|
||||
rb.SetOnPeerConfig(func(fps, batch, trackCount int) {
|
||||
st.SetTrackCount(trackCount)
|
||||
bridge.setScreenSharing(trackCount > 1)
|
||||
})
|
||||
}
|
||||
bridge.mu.Lock()
|
||||
bridge.activeBridge = rb
|
||||
bridge.mu.Unlock()
|
||||
}
|
||||
return ur
|
||||
}
|
||||
go bridge.Run(callInfo, cookieStr, cfg)
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for {
|
||||
bridge.mu.Lock()
|
||||
rb := bridge.activeBridge
|
||||
bridge.mu.Unlock()
|
||||
if rb != nil {
|
||||
return rb, callInfo.JoinLink, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, "", fmt.Errorf("vk: creator tunnel timed out")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, "", ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ConnectJoiner(ctx context.Context, joinLink, displayName string, readBuf int, dialer N.Dialer, dnsRouter adapter.DNSRouter, logger logger.ContextLogger) (tunnel.DataTunnel, error) {
|
||||
if displayName == "" {
|
||||
displayName = "Joiner"
|
||||
}
|
||||
authJSON, err := RunVKAuth(dialer, joinLink, displayName, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vk: auth: %w", err)
|
||||
}
|
||||
var params VKAuthParams
|
||||
if err := json.Unmarshal([]byte(authJSON), ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("vk: decode auth params: %w", err)
|
||||
}
|
||||
params.TunnelMode = "video"
|
||||
paramsJSON, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vk: encode auth params: %w", err)
|
||||
}
|
||||
joiner := NewVKJoiner(
|
||||
logger,
|
||||
nil,
|
||||
common.AddTunnelTracks,
|
||||
common.ReadTrack,
|
||||
dialer,
|
||||
dnsRouter,
|
||||
)
|
||||
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:
|
||||
return tun, nil
|
||||
case <-ctx.Done():
|
||||
joiner.Close()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
318
transport/call/vk/creator.go
Normal file
318
transport/call/vk/creator.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing-box/transport/call/tunnel"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
const topologyDirect = "DIRECT"
|
||||
const maxServerBounces = 5
|
||||
|
||||
type Bridge struct {
|
||||
mu sync.Mutex
|
||||
vkWs *websocket.Conn
|
||||
vkSeq int
|
||||
iceServers []webrtc.ICEServer
|
||||
topology string
|
||||
peers map[int64]struct{}
|
||||
relay Relay
|
||||
newRelay func() Relay
|
||||
p2p *P2PHandler
|
||||
screenSharing bool
|
||||
|
||||
serverBounces int
|
||||
suppressScreenshare bool
|
||||
bouncing bool
|
||||
|
||||
dialer N.Dialer
|
||||
activeBridge *tunnel.RelayBridge
|
||||
readBuf int
|
||||
logger logger.ContextLogger
|
||||
}
|
||||
|
||||
func (b *Bridge) setScreenSharing(enabled bool) {
|
||||
b.mu.Lock()
|
||||
if b.vkWs == 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{}) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.vkWs == nil {
|
||||
return
|
||||
}
|
||||
b.vkSeq++
|
||||
seq := b.vkSeq
|
||||
var out []byte
|
||||
if pid, ok := extra["participantId"]; ok {
|
||||
dataJSON, _ := json.Marshal(extra["data"])
|
||||
out = []byte(fmt.Sprintf(`{"command":%q,"sequence":%d,"participantId":%v,"data":%s}`,
|
||||
command, seq, pid, dataJSON))
|
||||
} else {
|
||||
extra["command"] = command
|
||||
extra["sequence"] = seq
|
||||
out, _ = json.Marshal(extra)
|
||||
}
|
||||
b.vkWs.WriteMessage(websocket.TextMessage, 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{}{
|
||||
"isAudioEnabled": false, "isVideoEnabled": true,
|
||||
"isScreenSharingEnabled": screenSharing, "isFastScreenSharingEnabled": false,
|
||||
"isAudioSharingEnabled": false, "isAnimojiEnabled": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bridge) handleVKMessage(raw []byte) {
|
||||
var msg map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
msgType, _ := msg["type"].(string)
|
||||
switch msgType {
|
||||
case "notification":
|
||||
notif, _ := msg["notification"].(string)
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] <- notification: %s", notif))
|
||||
switch notif {
|
||||
case "connection":
|
||||
b.logger.Debug("[vk-ws] TURN creds received")
|
||||
case "transmitted-data":
|
||||
data, _ := msg["data"].(map[string]interface{})
|
||||
if data != nil && b.topology == topologyDirect && b.p2p != nil {
|
||||
b.p2p.OnTransmittedData(data)
|
||||
}
|
||||
case "registered-peer":
|
||||
pid, _ := msg["participantId"].(float64)
|
||||
if b.topology == topologyDirect && b.p2p != nil {
|
||||
b.p2p.OnRegisteredPeer(int64(pid))
|
||||
}
|
||||
case "topology-changed":
|
||||
topo, _ := msg["topology"].(string)
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] Topology changed to %s", topo))
|
||||
b.topology = topo
|
||||
if topo != topologyDirect {
|
||||
b.bounceForServerTopology("SERVER topology")
|
||||
return
|
||||
}
|
||||
case "participant-joined", "participant-added":
|
||||
if pid, ok := msg["participantId"].(float64); ok {
|
||||
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")
|
||||
return
|
||||
}
|
||||
}
|
||||
case "participant-left":
|
||||
if pid, ok := msg["participantId"].(float64); ok {
|
||||
delete(b.peers, int64(pid))
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] Participant %d left (total: %d)", int64(pid), len(b.peers)))
|
||||
}
|
||||
case "hungup":
|
||||
if pid, ok := msg["participantId"].(float64); ok {
|
||||
delete(b.peers, int64(pid))
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] Participant %d hung up (total: %d)", int64(pid), len(b.peers)))
|
||||
} else {
|
||||
b.logger.Debug("[vk-ws] Participant hung up")
|
||||
}
|
||||
case "closed-conversation":
|
||||
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()
|
||||
}
|
||||
b.mu.Unlock()
|
||||
default:
|
||||
snippet, _ := json.Marshal(msg)
|
||||
if len(snippet) > 1000 {
|
||||
snippet = append(snippet[:1000], '.', '.', '.')
|
||||
}
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] unhandled: %s", string(snippet)))
|
||||
}
|
||||
case "response":
|
||||
seq, _ := msg["sequence"].(float64)
|
||||
snippet, _ := json.Marshal(msg)
|
||||
if len(snippet) > 1000 {
|
||||
snippet = append(snippet[:1000], '.', '.', '.')
|
||||
}
|
||||
b.logger.Debug(fmt.Sprintf("[vk-ws] <- response seq=%d: %s", int(seq), string(snippet)))
|
||||
case "error":
|
||||
errMsg, _ := msg["message"].(string)
|
||||
errCode, _ := msg["error"].(string)
|
||||
b.logger.Warn(fmt.Sprintf("[vk-ws] <- error: %s %s", errCode, errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
vkWs, _, err := vkDialer.Dial(wsURL, vkHeader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.vkWs = vkWs
|
||||
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) initRelay() {
|
||||
if b.relay != nil {
|
||||
b.relay.Close()
|
||||
}
|
||||
b.topology = topologyDirect
|
||||
b.peers = make(map[int64]struct{})
|
||||
b.relay = b.newRelay()
|
||||
b.p2p = NewP2PHandler(b)
|
||||
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) readLoop() error {
|
||||
for {
|
||||
_, msg, err := b.vkWs.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if string(msg) == "ping" {
|
||||
b.mu.Lock()
|
||||
b.vkWs.WriteMessage(websocket.TextMessage, []byte("pong"))
|
||||
b.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
b.handleVKMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
capabilities := "2F7F"
|
||||
makeWSURL := func(ep string) string {
|
||||
return ep +
|
||||
"&platform=WEB" +
|
||||
"&appVersion=" + cfg.AppVersion +
|
||||
"&version=" + cfg.ProtocolVersion +
|
||||
"&device=browser&capabilities=" + capabilities + "&clientType=VK&tgt=join"
|
||||
}
|
||||
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()
|
||||
b.logger.Debug("[vk-ws] Connecting...")
|
||||
if err := b.connectVKWs(makeWSURL(wsEndpoint)); err != nil {
|
||||
b.logger.Warn(fmt.Sprintf("[vk-ws] Connect failed: %s, retrying in 5s...", common.MaskError(err)))
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
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.mu.Unlock()
|
||||
b.logger.Debug("[vk-ws] Rejoining in 3s...")
|
||||
time.Sleep(3 * time.Second)
|
||||
joinResp, rerr := authAndJoin(b.dialer, cookieStr, callInfo.OKJoinLink, cfg)
|
||||
if rerr != nil {
|
||||
b.logger.Warn(fmt.Sprintf("[rejoin] Failed: %v, retrying in 5s...", rerr))
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
wsEndpoint = joinResp.Endpoint
|
||||
callInfo.TurnServer = joinResp.TurnServer
|
||||
callInfo.StunServer = joinResp.StunServer
|
||||
b.iceServers = buildWebRTCICEServers(BuildICEServers(callInfo))
|
||||
}
|
||||
}
|
||||
|
||||
func buildWebRTCICEServers(specs []ICEServerSpec) []webrtc.ICEServer {
|
||||
out := make([]webrtc.ICEServer, len(specs))
|
||||
for i, s := range specs {
|
||||
out[i] = webrtc.ICEServer{URLs: s.URLs, Username: s.Username, Credential: s.Credential}
|
||||
}
|
||||
return out
|
||||
}
|
||||
757
transport/call/vk/joiner.go
Normal file
757
transport/call/vk/joiner.go
Normal file
@@ -0,0 +1,757 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/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"
|
||||
)
|
||||
|
||||
const (
|
||||
vkReconnectInitialDelay = time.Second
|
||||
vkReconnectMaxDelay = 16 * time.Second
|
||||
vkMaxReconnectAttempts = 10
|
||||
)
|
||||
|
||||
const vkTopologyDirect = "DIRECT"
|
||||
|
||||
type vkAuthRottenError struct {
|
||||
Code string
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *vkAuthRottenError) Error() string {
|
||||
if e.Msg != "" {
|
||||
return fmt.Sprintf("auth rotten: %s %s", e.Code, e.Msg)
|
||||
}
|
||||
return fmt.Sprintf("auth rotten: %s", e.Code)
|
||||
}
|
||||
|
||||
type VKAuthParams struct {
|
||||
SessionKey string `json:"sessionKey"`
|
||||
ApplicationKey string `json:"applicationKey"`
|
||||
APIBaseURL string `json:"apiBaseURL"`
|
||||
JoinLink string `json:"joinLink"`
|
||||
AnonymToken string `json:"anonymToken"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
TunnelMode string `json:"tunnelMode"`
|
||||
VP8FPS int `json:"vp8Fps"`
|
||||
VP8Batch int `json:"vp8Batch"`
|
||||
DualTrack bool `json:"dualTrack"`
|
||||
}
|
||||
|
||||
type VKJoinResponse struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
WtEndpoint string `json:"wt_endpoint"`
|
||||
Token string `json:"token"`
|
||||
TurnServer struct {
|
||||
URLs []string `json:"urls"`
|
||||
Username string `json:"username"`
|
||||
Credential string `json:"credential"`
|
||||
} `json:"turn_server"`
|
||||
StunServer struct {
|
||||
URLs []string `json:"urls"`
|
||||
} `json:"stun_server"`
|
||||
}
|
||||
|
||||
type VKJoiner struct {
|
||||
logger logger.ContextLogger
|
||||
OnConnected func(tunnel.DataTunnel)
|
||||
OnRemoteCandidate func(target int, candidateOrSDP string)
|
||||
PCConfig common.PeerConnectionConfigurer
|
||||
AddTracks common.AddTunnelTracksFunc
|
||||
ReadTrackFn common.ReadTrackFunc
|
||||
Dialer N.Dialer
|
||||
DNSRouter adapter.DNSRouter
|
||||
|
||||
authParams *VKAuthParams
|
||||
joinResp *VKJoinResponse
|
||||
sfu *wtsignal.Conn
|
||||
vkMu sync.Mutex
|
||||
vkSeq int
|
||||
remotePeerID *int64
|
||||
|
||||
pc *webrtc.PeerConnection
|
||||
sampleTrack *webrtc.TrackLocalStaticSample
|
||||
dc *webrtc.DataChannel
|
||||
vp8tunnel *tunnel.VP8DataTunnel
|
||||
sym *tunnel.SymmetricScreenTunnel
|
||||
producerScreen screenUplink
|
||||
obf *tunnel.TunnelObfuscator
|
||||
vp8FPS int
|
||||
vp8Batch int
|
||||
dualTrack bool
|
||||
remoteSet bool
|
||||
pendingICE []webrtc.ICECandidateInit
|
||||
|
||||
configAck tunnel.ConfigAckTracker
|
||||
reconnectAttempt atomic.Int32
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func NewVKJoiner(logger logger.ContextLogger, pcConfig common.PeerConnectionConfigurer, addTracks common.AddTunnelTracksFunc, readTrackFn common.ReadTrackFunc, dialer N.Dialer, dnsRouter adapter.DNSRouter) *VKJoiner {
|
||||
return &VKJoiner{
|
||||
logger: logger,
|
||||
PCConfig: pcConfig,
|
||||
AddTracks: addTracks,
|
||||
ReadTrackFn: readTrackFn,
|
||||
Dialer: dialer,
|
||||
DNSRouter: dnsRouter,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VKJoiner) RunWithParams(jsonParams string) {
|
||||
var params VKAuthParams
|
||||
if err := json.Unmarshal([]byte(jsonParams), ¶ms); err != nil {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: failed to parse auth params: %v", err))
|
||||
return
|
||||
}
|
||||
h.authParams = ¶ms
|
||||
obf, err := tunnel.NewTunnelObfuscator(tunnel.DeriveSecretFromJoinLink(params.JoinLink))
|
||||
if err != nil {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: obfuscator init failed: %v", err))
|
||||
return
|
||||
}
|
||||
h.obf = obf
|
||||
h.vp8FPS = params.VP8FPS
|
||||
h.vp8Batch = params.VP8Batch
|
||||
// h.dualTrack = params.DualTrack // temporarily disabled for VK joiners
|
||||
h.logger.Debug("vk-joiner: auth params received")
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: obf key-source=%q localEpoch=0x%08x", params.JoinLink, obf.LocalEpoch()))
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: appVersion=%s protocolVersion=%s vp8Fps=%d vp8Batch=%d",
|
||||
params.AppVersion, params.ProtocolVersion, params.VP8FPS, params.VP8Batch))
|
||||
h.logger.Info("vk-joiner: connecting")
|
||||
if err := h.runOnce(); err != nil {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: %v", err))
|
||||
return
|
||||
}
|
||||
for {
|
||||
if h.isClosed() {
|
||||
return
|
||||
}
|
||||
h.logger.Info("vk-joiner: tunnel lost")
|
||||
if !h.waitBeforeRetry(int(h.reconnectAttempt.Load())) {
|
||||
return
|
||||
}
|
||||
attempt := h.reconnectAttempt.Add(1)
|
||||
if h.isClosed() {
|
||||
return
|
||||
}
|
||||
if int(attempt) > vkMaxReconnectAttempts {
|
||||
h.logger.Warn(fmt.Sprintf("vk-joiner: gave up after %d consecutive reconnect attempts", vkMaxReconnectAttempts))
|
||||
return
|
||||
}
|
||||
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) {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: %v, surrendering", err))
|
||||
return
|
||||
}
|
||||
h.logger.Warn(fmt.Sprintf("vk-joiner: %v, will retry", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VKJoiner) Close() {
|
||||
h.stopOnce.Do(func() { close(h.stopCh) })
|
||||
StopCaptchaProxy()
|
||||
h.vkMu.Lock()
|
||||
sfu := h.sfu
|
||||
h.sfu = nil
|
||||
h.vkMu.Unlock()
|
||||
if sfu != nil {
|
||||
sfu.Close()
|
||||
}
|
||||
if h.vp8tunnel != nil {
|
||||
h.vp8tunnel.Stop()
|
||||
}
|
||||
if h.pc != nil {
|
||||
h.pc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VKJoiner) closeTransport() {
|
||||
h.vkMu.Lock()
|
||||
sfu := h.sfu
|
||||
h.vkMu.Unlock()
|
||||
if sfu != nil {
|
||||
sfu.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VKJoiner) runOnce() error {
|
||||
h.resetSessionState()
|
||||
if err := h.joinCall(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.connectSFU()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *VKJoiner) MarkConfigAcked() { h.configAck.Mark() }
|
||||
|
||||
func (h *VKJoiner) waitBeforeRetry(attempt int) bool {
|
||||
delay := common.BackoffWithJitter(attempt, vkReconnectInitialDelay, vkReconnectMaxDelay)
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: waiting %s before reconnect", delay))
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return !h.isClosed()
|
||||
case <-h.stopCh:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VKJoiner) isClosed() bool {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VKJoiner) resetSessionState() {
|
||||
h.vkMu.Lock()
|
||||
sfu := h.sfu
|
||||
h.sfu = nil
|
||||
h.vkSeq = 0
|
||||
h.vkMu.Unlock()
|
||||
if sfu != nil {
|
||||
sfu.Close()
|
||||
}
|
||||
if h.sym != nil {
|
||||
h.sym.Stop()
|
||||
h.sym = nil
|
||||
}
|
||||
if h.vp8tunnel != nil {
|
||||
h.vp8tunnel.Stop()
|
||||
h.vp8tunnel = nil
|
||||
}
|
||||
h.producerScreen.reset()
|
||||
if h.dc != nil {
|
||||
h.dc.Close()
|
||||
h.dc = nil
|
||||
}
|
||||
if h.pc != nil {
|
||||
h.pc.Close()
|
||||
h.pc = nil
|
||||
}
|
||||
h.sampleTrack = nil
|
||||
h.remoteSet = false
|
||||
h.pendingICE = nil
|
||||
h.remotePeerID = nil
|
||||
h.joinResp = nil
|
||||
}
|
||||
|
||||
func (h *VKJoiner) dialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return h.Dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||
}
|
||||
|
||||
func (h *VKJoiner) resolveHost(host string) (string, error) {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return host, nil
|
||||
}
|
||||
rd, hasRD := h.Dialer.(dialer.ResolveDialer)
|
||||
if h.DNSRouter == nil || !hasRD {
|
||||
return "", fmt.Errorf("no DNS router available to resolve %s", host)
|
||||
}
|
||||
addrs, err := h.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 (h *VKJoiner) joinCall() error {
|
||||
apiURL := h.authParams.APIBaseURL
|
||||
parsed, err := url.Parse(apiURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad apiBaseURL: %w", err)
|
||||
}
|
||||
screenFlag := "false"
|
||||
if h.dualTrack {
|
||||
screenFlag = "true"
|
||||
}
|
||||
body := url.Values{
|
||||
"method": {"vchat.joinConversationByLink"},
|
||||
"session_key": {h.authParams.SessionKey},
|
||||
"application_key": {h.authParams.ApplicationKey},
|
||||
"joinLink": {h.authParams.JoinLink},
|
||||
"anonymToken": {h.authParams.AnonymToken},
|
||||
"isVideo": {"true"},
|
||||
"isAudio": {"false"},
|
||||
"mediaSettings": {`{"isAudioEnabled":false,"isVideoEnabled":true,"isScreenSharingEnabled":` + screenFlag + `}`},
|
||||
"format": {"json"},
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{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)
|
||||
h.logger.Debug("vk-joiner: calling joinConversationByLink...")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("joinConversationByLink: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read join response: %w", err)
|
||||
}
|
||||
var joinResp VKJoinResponse
|
||||
if jsonErr := json.Unmarshal(raw, &joinResp); jsonErr != nil {
|
||||
return fmt.Errorf("decode join response: %w (body: %s)", jsonErr, truncateBody(raw))
|
||||
}
|
||||
if joinResp.Endpoint == "" {
|
||||
if rotten := detectVKAuthRotten(raw); rotten != nil {
|
||||
return rotten
|
||||
}
|
||||
return fmt.Errorf("empty endpoint in join response: %s", truncateBody(raw))
|
||||
}
|
||||
h.joinResp = &joinResp
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: joined, turn=%v", joinResp.TurnServer.URLs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *VKJoiner) connectSFU() {
|
||||
endpoint := h.joinResp.WtEndpoint
|
||||
if endpoint == "" {
|
||||
h.logger.Error("vk-joiner: no wt_endpoint in join response, cannot connect")
|
||||
return
|
||||
}
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: bad endpoint URL: %s", common.MaskError(err)))
|
||||
return
|
||||
}
|
||||
hostname := parsed.Hostname()
|
||||
resolvedIP, err := h.resolveHost(hostname)
|
||||
if err != nil {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: DNS resolve failed: %s", common.MaskError(err)))
|
||||
return
|
||||
}
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: resolved %s -> %s", common.MaskAddr(hostname), common.MaskAddr(resolvedIP)))
|
||||
capabilities := "2F7F"
|
||||
wtURL := endpoint +
|
||||
"&platform=WEB" +
|
||||
"&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)
|
||||
if err != nil {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: WebTransport connect failed: %s", common.MaskError(err)))
|
||||
return
|
||||
}
|
||||
h.vkMu.Lock()
|
||||
h.sfu = sfu
|
||||
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("change-media-settings", map[string]interface{}{
|
||||
"mediaSettings": map[string]interface{}{
|
||||
"isAudioEnabled": false, "isVideoEnabled": true,
|
||||
"isScreenSharingEnabled": h.dualTrack, "isFastScreenSharingEnabled": false,
|
||||
"isAudioSharingEnabled": false, "isAnimojiEnabled": false,
|
||||
},
|
||||
})
|
||||
h.readLoop()
|
||||
}
|
||||
|
||||
func (h *VKJoiner) vkSend(command string, extra map[string]interface{}) {
|
||||
h.vkMu.Lock()
|
||||
defer h.vkMu.Unlock()
|
||||
if h.sfu == nil {
|
||||
return
|
||||
}
|
||||
h.vkSeq++
|
||||
extra["command"] = command
|
||||
extra["sequence"] = h.vkSeq
|
||||
out, _ := json.Marshal(extra)
|
||||
h.sfu.Send(out)
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: -> %s", command))
|
||||
}
|
||||
|
||||
func (h *VKJoiner) vkSendTransmitData(participantId int64, payload map[string]interface{}) {
|
||||
h.vkMu.Lock()
|
||||
defer h.vkMu.Unlock()
|
||||
if h.sfu == nil {
|
||||
return
|
||||
}
|
||||
h.vkSeq++
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
out := fmt.Sprintf(`{"command":"transmit-data","sequence":%d,"participantId":%d,"data":%s}`,
|
||||
h.vkSeq, participantId, payloadJSON)
|
||||
h.sfu.Send([]byte(out))
|
||||
}
|
||||
|
||||
func (h *VKJoiner) readLoop() {
|
||||
h.vkMu.Lock()
|
||||
sfu := h.sfu
|
||||
h.vkMu.Unlock()
|
||||
if sfu == nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
msg, err := sfu.Recv()
|
||||
if err != nil {
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: WebTransport closed: %s", common.MaskError(err)))
|
||||
return
|
||||
}
|
||||
if string(msg) == "ping" {
|
||||
sfu.Send([]byte("pong"))
|
||||
continue
|
||||
}
|
||||
h.handleVKMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VKJoiner) handleVKMessage(raw []byte) {
|
||||
var msg map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
msgType, _ := msg["type"].(string)
|
||||
switch msgType {
|
||||
case "notification":
|
||||
notif, _ := msg["notification"].(string)
|
||||
switch notif {
|
||||
case "connection":
|
||||
h.handleConnection(msg)
|
||||
case "transmitted-data":
|
||||
data, _ := msg["data"].(map[string]interface{})
|
||||
if data != nil {
|
||||
if pid, ok := msg["participantId"].(float64); ok && h.remotePeerID == nil {
|
||||
h.onRegisteredPeer(int64(pid))
|
||||
}
|
||||
h.onTransmittedData(data)
|
||||
}
|
||||
case "registered-peer":
|
||||
if pid, ok := msg["participantId"].(float64); ok {
|
||||
h.onRegisteredPeer(int64(pid))
|
||||
}
|
||||
case "topology-changed":
|
||||
topo, _ := msg["topology"].(string)
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: topology: %s", topo))
|
||||
if topo != "" && topo != vkTopologyDirect {
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: %s topology -> closing transport to reconnect and recover DIRECT", topo))
|
||||
h.closeTransport()
|
||||
}
|
||||
case "participant-joined", "participant-added":
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: <- %s", notif))
|
||||
case "participant-left":
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: <- %s", notif))
|
||||
case "hungup":
|
||||
h.logger.Debug("vk-joiner: peer hungup -> closing transport to reconnect")
|
||||
h.closeTransport()
|
||||
}
|
||||
case "response":
|
||||
seq, _ := msg["sequence"].(float64)
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: <- response seq=%d", int(seq)))
|
||||
case "error":
|
||||
errMsg, _ := msg["message"].(string)
|
||||
errCode, _ := msg["error"].(string)
|
||||
h.logger.Warn(fmt.Sprintf("vk-joiner: ERROR: %s %s", errCode, errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VKJoiner) handleConnection(msg map[string]interface{}) {
|
||||
if conv, ok := msg["conversation"].(map[string]interface{}); ok {
|
||||
topo, _ := conv["topology"].(string)
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: connection topology=%q", topo))
|
||||
}
|
||||
convParams, ok := msg["conversationParams"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
turn, ok := convParams["turn"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
urlsRaw, _ := turn["urls"].([]interface{})
|
||||
var urls []string
|
||||
for _, u := range urlsRaw {
|
||||
if s, ok := u.(string); ok {
|
||||
urls = append(urls, s)
|
||||
}
|
||||
}
|
||||
username, _ := turn["username"].(string)
|
||||
credential, _ := turn["credential"].(string)
|
||||
h.joinResp.TurnServer.URLs = urls
|
||||
h.joinResp.TurnServer.Username = username
|
||||
h.joinResp.TurnServer.Credential = credential
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: TURN from connection: %v", urls))
|
||||
if h.pc == nil {
|
||||
h.initPC()
|
||||
}
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
if len(h.joinResp.TurnServer.URLs) > 0 {
|
||||
iceServers = append(iceServers, webrtc.ICEServer{
|
||||
URLs: h.joinResp.TurnServer.URLs,
|
||||
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)
|
||||
}
|
||||
pc, err := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine)).NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: iceServers,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error(fmt.Sprintf("vk-joiner: failed to create PC: %v", err))
|
||||
return
|
||||
}
|
||||
h.pc = pc
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: tunnel mode: %s", mode))
|
||||
if mode == "video" {
|
||||
h.sampleTrack = h.AddTracks(pc, h.logger, "vk-joiner")
|
||||
}
|
||||
negotiated := true
|
||||
dcID := uint16(2)
|
||||
dc, err := pc.CreateDataChannel("tunnel", &webrtc.DataChannelInit{
|
||||
Negotiated: &negotiated,
|
||||
ID: &dcID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Warn(fmt.Sprintf("vk-joiner: could not create tunnel DC: %v", err))
|
||||
} else {
|
||||
h.dc = dc
|
||||
dc.OnOpen(func() {
|
||||
h.logger.Debug("vk-joiner: tunnel DC open")
|
||||
if mode == "dc" {
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
dc.OnClose(func() {
|
||||
h.logger.Debug("vk-joiner: tunnel DC closed")
|
||||
})
|
||||
}
|
||||
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
|
||||
if candidate == nil {
|
||||
return
|
||||
}
|
||||
h.onLocalICECandidate(candidate)
|
||||
})
|
||||
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: PC state: %s", state.String()))
|
||||
if state == webrtc.PeerConnectionStateFailed || state == webrtc.PeerConnectionStateDisconnected {
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: PC %s, closing transport to trigger reconnect", state.String()))
|
||||
h.closeTransport()
|
||||
}
|
||||
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.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.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.SetTrackCount(2)
|
||||
downlink = h.sym
|
||||
trackCount = 2
|
||||
h.logger.Info("vk-joiner: === SYMMETRIC DUAL-TRACK: camera VP8 + screen DCs ===")
|
||||
}
|
||||
vp8tun := h.vp8tunnel
|
||||
if !h.configAck.Acknowledged() {
|
||||
acked, cancel := h.configAck.Arm()
|
||||
go tunnel.SendVP8ConfigUntilAcked(acked, cancel, h.stopCh, vp8tun,
|
||||
vp8tun.FPS(), vp8tun.Batch(), trackCount, h.logger, "vk-joiner")
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: pushed vp8 config to creator fps=%d batch=%d trackCount=%d", vp8tun.FPS(), vp8tun.Batch(), trackCount))
|
||||
}
|
||||
if h.OnConnected != nil {
|
||||
h.OnConnected(downlink)
|
||||
}
|
||||
}
|
||||
})
|
||||
if mode == "video" {
|
||||
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: remote DataChannel: label=%q id=%v", dc.Label(), dc.ID()))
|
||||
if !h.dualTrack {
|
||||
return
|
||||
}
|
||||
switch dc.Label() {
|
||||
case "consumerScreenShare":
|
||||
readScreenDataChannel(dc, func(frame []byte) {
|
||||
if h.sym != nil {
|
||||
h.sym.HandleScreenFrame(frame)
|
||||
}
|
||||
}, h.logger)
|
||||
case "producerScreenShare":
|
||||
attachScreenWriterDC(dc, h.producerScreen.attach, h.logger)
|
||||
}
|
||||
})
|
||||
pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: remote track: codec=%s ssrc=%d", track.Codec().MimeType, track.SSRC()))
|
||||
go h.ReadTrackFn(track, func(frame []byte) {
|
||||
if h.vp8tunnel != nil {
|
||||
h.vp8tunnel.HandleFrame(frame)
|
||||
}
|
||||
}, h.logger, "vk-joiner")
|
||||
})
|
||||
}
|
||||
h.logger.Debug("vk-joiner: PC ready, waiting for remote offer")
|
||||
}
|
||||
|
||||
func (h *VKJoiner) onRegisteredPeer(pid int64) {
|
||||
h.remotePeerID = &pid
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: peer registered: %d", pid))
|
||||
}
|
||||
|
||||
func (h *VKJoiner) onLocalICECandidate(candidate *webrtc.ICECandidate) {
|
||||
if h.remotePeerID == nil {
|
||||
return
|
||||
}
|
||||
candidateJSON := candidate.ToJSON()
|
||||
raw, _ := json.Marshal(candidateJSON)
|
||||
var parsed interface{}
|
||||
json.Unmarshal(raw, &parsed)
|
||||
h.vkSendTransmitData(*h.remotePeerID, map[string]interface{}{"candidate": parsed})
|
||||
}
|
||||
|
||||
func (h *VKJoiner) onTransmittedData(data map[string]interface{}) {
|
||||
if h.pc == nil {
|
||||
return
|
||||
}
|
||||
if candidate, ok := data["candidate"]; ok {
|
||||
candidateJSON, _ := json.Marshal(candidate)
|
||||
var candidateInit webrtc.ICECandidateInit
|
||||
json.Unmarshal(candidateJSON, &candidateInit)
|
||||
if h.OnRemoteCandidate != nil {
|
||||
h.OnRemoteCandidate(0, candidateInit.Candidate)
|
||||
}
|
||||
if h.remoteSet {
|
||||
h.pc.AddICECandidate(candidateInit)
|
||||
} else {
|
||||
h.pendingICE = append(h.pendingICE, candidateInit)
|
||||
}
|
||||
}
|
||||
if sdp, ok := data["sdp"].(map[string]interface{}); 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" {
|
||||
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" {
|
||||
h.pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: sdpStr})
|
||||
h.remoteSet = true
|
||||
for _, candidate := range h.pendingICE {
|
||||
h.pc.AddICECandidate(candidate)
|
||||
}
|
||||
h.pendingICE = nil
|
||||
answer, err := h.pc.CreateAnswer(nil)
|
||||
if err != nil || h.remotePeerID == nil {
|
||||
h.logger.Warn(fmt.Sprintf("vk-joiner: create answer failed: %v", err))
|
||||
return
|
||||
}
|
||||
h.pc.SetLocalDescription(answer)
|
||||
sdpJSON, _ := json.Marshal(answer.SDP)
|
||||
h.vkMu.Lock()
|
||||
if h.sfu != nil {
|
||||
h.vkSeq++
|
||||
raw := fmt.Sprintf(`{"command":"transmit-data","sequence":%d,"participantId":%d,"data":{"sdp":{"sdp":%s,"type":%q},"animojiVersion":2},"participantType":"USER"}`,
|
||||
h.vkSeq, *h.remotePeerID, sdpJSON, answer.Type.String())
|
||||
h.sfu.Send([]byte(raw))
|
||||
h.logger.Debug(fmt.Sprintf("vk-joiner: -> answer (seq=%d)", h.vkSeq))
|
||||
}
|
||||
h.vkMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func detectVKAuthRotten(raw []byte) *vkAuthRottenError {
|
||||
var generic map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &generic); err != nil {
|
||||
return nil
|
||||
}
|
||||
errCode, _ := generic["error_code"].(string)
|
||||
if errCode == "" {
|
||||
if codeNum, ok := generic["error_code"].(float64); ok {
|
||||
errCode = fmt.Sprintf("%.0f", codeNum)
|
||||
}
|
||||
}
|
||||
if errCode == "" {
|
||||
errCode, _ = generic["errorCode"].(string)
|
||||
}
|
||||
if errCode == "" {
|
||||
return nil
|
||||
}
|
||||
switch errCode {
|
||||
case "SESSION_EXPIRED", "AUTH_LOGIN", "SESSION_NOT_FOUND", "INVALID_SESSION_KEY":
|
||||
errMsg, _ := generic["error_msg"].(string)
|
||||
return &vkAuthRottenError{Code: errCode, Msg: errMsg}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateBody(raw []byte) string {
|
||||
const maxLen = 200
|
||||
if len(raw) > maxLen {
|
||||
return string(raw[:maxLen]) + "..."
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
194
transport/call/vk/p2p.go
Normal file
194
transport/call/vk/p2p.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type P2PHandler struct {
|
||||
bridge *Bridge
|
||||
remotePeerId *int64
|
||||
pendingOffer *webrtc.SessionDescription
|
||||
pendingCandidates []webrtc.ICECandidateInit
|
||||
connected bool
|
||||
}
|
||||
|
||||
func NewP2PHandler(bridge *Bridge) *P2PHandler {
|
||||
return &P2PHandler{bridge: bridge}
|
||||
}
|
||||
|
||||
func (p *P2PHandler) Init() {
|
||||
relay := p.bridge.relay
|
||||
if err := relay.Init(p.bridge.iceServers); err != nil {
|
||||
p.bridge.logger.Error(fmt.Sprintf("[p2p] Init relay failed: %v", err))
|
||||
return
|
||||
}
|
||||
p.setupCallbacks()
|
||||
offer, err := relay.CreateOffer()
|
||||
if err != nil {
|
||||
p.bridge.logger.Error(fmt.Sprintf("[p2p] Create offer failed: %v", err))
|
||||
return
|
||||
}
|
||||
p.pendingOffer = &offer
|
||||
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Offer ready, SDP length: %d", len(offer.SDP)))
|
||||
}
|
||||
|
||||
func (p *P2PHandler) Reset() {
|
||||
p.bridge.logger.Debug("[p2p] Resetting Pion PC...")
|
||||
p.connected = false
|
||||
p.bridge.relay.Close()
|
||||
p.bridge.relay = p.bridge.newRelay()
|
||||
relay := p.bridge.relay
|
||||
if err := relay.Init(p.bridge.iceServers); err != nil {
|
||||
p.bridge.logger.Warn(fmt.Sprintf("[p2p] Reset init failed: %v", err))
|
||||
return
|
||||
}
|
||||
p.setupCallbacks()
|
||||
offer, err := relay.CreateOffer()
|
||||
if err != nil {
|
||||
p.bridge.logger.Warn(fmt.Sprintf("[p2p] Reset create-offer failed: %v", err))
|
||||
return
|
||||
}
|
||||
p.pendingOffer = &offer
|
||||
p.pendingCandidates = nil
|
||||
p.bridge.logger.Debug(fmt.Sprintf("[p2p] New offer ready after reset, SDP length: %d", len(offer.SDP)))
|
||||
}
|
||||
|
||||
func (p *P2PHandler) OnRegisteredPeer(participantId int64) {
|
||||
oldPeer := p.remotePeerId
|
||||
p.remotePeerId = &participantId
|
||||
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Peer registered: %d (connected=%v)", participantId, p.connected))
|
||||
if oldPeer != nil && (p.pendingOffer == nil) {
|
||||
if *oldPeer != participantId {
|
||||
p.bridge.logger.Debug(fmt.Sprintf("[p2p] New peer %d replacing old peer %d, resetting", participantId, *oldPeer))
|
||||
} else {
|
||||
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Same peer %d re-registered, no pending offer, resetting", participantId))
|
||||
}
|
||||
p.Reset()
|
||||
}
|
||||
p.sendOfferToPeer(participantId)
|
||||
}
|
||||
|
||||
func (p *P2PHandler) OnTransmittedData(data map[string]interface{}) {
|
||||
if cand, ok := data["candidate"]; ok {
|
||||
p.bridge.logger.Debug("[p2p] Remote ICE candidate")
|
||||
candJSON, _ := json.Marshal(cand)
|
||||
var candInit webrtc.ICECandidateInit
|
||||
json.Unmarshal(candJSON, &candInit)
|
||||
p.bridge.relay.AddICECandidate(candInit)
|
||||
}
|
||||
if sdp, ok := data["sdp"].(map[string]interface{}); ok {
|
||||
sdpType, _ := sdp["type"].(string)
|
||||
sdpStr, _ := sdp["sdp"].(string)
|
||||
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Remote SDP: %s", sdpType))
|
||||
if sdpType == "answer" {
|
||||
p.bridge.relay.SetRemoteDescription(webrtc.SDPTypeAnswer, sdpStr)
|
||||
} else if sdpType == "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{}{
|
||||
"participantId": *p.remotePeerId,
|
||||
"data": map[string]interface{}{"sdp": map[string]interface{}{
|
||||
"type": answer.Type.String(), "sdp": answer.SDP,
|
||||
}},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *P2PHandler) OnPionICECandidate(data json.RawMessage) {
|
||||
if p.remotePeerId != nil {
|
||||
var cand interface{}
|
||||
json.Unmarshal(data, &cand)
|
||||
p.bridge.vkSend("transmit-data", map[string]interface{}{
|
||||
"participantId": *p.remotePeerId,
|
||||
"data": map[string]interface{}{"candidate": cand},
|
||||
})
|
||||
} else {
|
||||
var candInit webrtc.ICECandidateInit
|
||||
json.Unmarshal(data, &candInit)
|
||||
p.pendingCandidates = append(p.pendingCandidates, candInit)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *P2PHandler) OnConnectionState(state string) {
|
||||
switch state {
|
||||
case "connected":
|
||||
p.connected = true
|
||||
p.bridge.logger.Info("TUNNEL CONNECTED")
|
||||
case "disconnected":
|
||||
p.connected = false
|
||||
p.bridge.logger.Debug("[p2p] Connection disconnected, kicking peer")
|
||||
p.kickRemotePeer()
|
||||
case "failed":
|
||||
p.connected = false
|
||||
p.bridge.logger.Debug("[p2p] Connection failed, removing stale peer")
|
||||
p.kickRemotePeer()
|
||||
case "closed":
|
||||
p.connected = false
|
||||
p.bridge.logger.Debug("[p2p] Connection closed, kicking peer")
|
||||
p.kickRemotePeer()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *P2PHandler) setupCallbacks() {
|
||||
p.bridge.relay.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||
if cand == nil {
|
||||
return
|
||||
}
|
||||
p.bridge.logger.Debug(fmt.Sprintf("[p2p] ICE candidate: type=%s proto=%s", cand.Typ.String(), cand.Protocol.String()))
|
||||
candJSON := cand.ToJSON()
|
||||
raw, _ := json.Marshal(candJSON)
|
||||
p.OnPionICECandidate(raw)
|
||||
})
|
||||
p.bridge.relay.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
p.OnConnectionState(state.String())
|
||||
})
|
||||
}
|
||||
|
||||
func (p *P2PHandler) kickRemotePeer() {
|
||||
if p.remotePeerId != nil {
|
||||
p.bridge.vkSend("remove-participant", map[string]interface{}{
|
||||
"participantId": *p.remotePeerId,
|
||||
"ban": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (p *P2PHandler) sendOfferToPeer(participantId int64) {
|
||||
offer := p.pendingOffer
|
||||
candidates := p.pendingCandidates
|
||||
p.pendingOffer = nil
|
||||
p.pendingCandidates = nil
|
||||
if offer != nil {
|
||||
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Sending offer to peer %d", participantId))
|
||||
sdpStr, _ := json.Marshal(offer.SDP)
|
||||
p.bridge.mu.Lock()
|
||||
p.bridge.vkSeq++
|
||||
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))
|
||||
}
|
||||
p.bridge.mu.Unlock()
|
||||
p.bridge.logger.Debug("[vk-ws] -> transmit-data (offer)")
|
||||
}
|
||||
for _, cand := range candidates {
|
||||
candJSON, _ := json.Marshal(cand)
|
||||
var c interface{}
|
||||
json.Unmarshal(candJSON, &c)
|
||||
p.bridge.vkSend("transmit-data", map[string]interface{}{
|
||||
"participantId": participantId,
|
||||
"data": map[string]interface{}{"candidate": c},
|
||||
})
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
p.bridge.logger.Debug(fmt.Sprintf("[p2p] Flushed %d ICE candidates", len(candidates)))
|
||||
}
|
||||
}
|
||||
468
transport/call/vk/relay.go
Normal file
468
transport/call/vk/relay.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"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/common/logger"
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
type Relay interface {
|
||||
Init(iceServers []webrtc.ICEServer) error
|
||||
CreateOffer() (webrtc.SessionDescription, error)
|
||||
CreateAnswer() (webrtc.SessionDescription, error)
|
||||
SetRemoteDescription(sdpType webrtc.SDPType, sdp string) error
|
||||
AddICECandidate(candidate webrtc.ICECandidateInit) error
|
||||
OnICECandidate(fn func(*webrtc.ICECandidate))
|
||||
OnConnectionStateChange(fn func(webrtc.PeerConnectionState))
|
||||
Close()
|
||||
}
|
||||
|
||||
type dcConn struct {
|
||||
conn net.Conn
|
||||
ch chan []byte
|
||||
}
|
||||
|
||||
type TunnelRelay struct {
|
||||
pc *webrtc.PeerConnection
|
||||
remoteSet bool
|
||||
pending []webrtc.ICECandidateInit
|
||||
externalICE func(*webrtc.ICECandidate)
|
||||
externalCSC func(webrtc.PeerConnectionState)
|
||||
|
||||
dc *webrtc.DataChannel
|
||||
dcMu sync.Mutex
|
||||
conns sync.Map
|
||||
|
||||
sampleTrack *webrtc.TrackLocalStaticSample
|
||||
tun *tunnel.VP8DataTunnel
|
||||
obf *tunnel.TunnelObfuscator
|
||||
OnConnected func(tunnel.DataTunnel)
|
||||
|
||||
screenDC *webrtc.DataChannel
|
||||
producerScreen *webrtc.DataChannel
|
||||
sym *tunnel.SymmetricScreenTunnel
|
||||
|
||||
dialer N.Dialer
|
||||
readBufSize int
|
||||
logger logger.ContextLogger
|
||||
|
||||
mode string
|
||||
modeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewTunnelRelay(dialer N.Dialer, logger logger.ContextLogger) *TunnelRelay {
|
||||
return &TunnelRelay{mode: "unknown", dialer: dialer, logger: logger}
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) SetObfuscator(o *tunnel.TunnelObfuscator) { u.obf = o }
|
||||
|
||||
func (u *TunnelRelay) Init(iceServers []webrtc.ICEServer) error {
|
||||
pc, err := webrtc.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.pc = pc
|
||||
negotiated := true
|
||||
dcID := uint16(2)
|
||||
dc, err := pc.CreateDataChannel("tunnel", &webrtc.DataChannelInit{
|
||||
Negotiated: &negotiated,
|
||||
ID: &dcID,
|
||||
})
|
||||
if err != nil {
|
||||
u.logger.Warn(fmt.Sprintf("[relay] could not create tunnel DC: %v", err))
|
||||
} else {
|
||||
u.dc = dc
|
||||
dc.OnOpen(func() {
|
||||
u.logger.Debug(fmt.Sprintf("[relay] tunnel DC open (readyState=%v)", dc.ReadyState()))
|
||||
})
|
||||
dc.OnClose(func() {
|
||||
u.logger.Debug("[relay] tunnel DC closed")
|
||||
if u.mode == "dc" {
|
||||
u.closeAllConns()
|
||||
}
|
||||
})
|
||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
u.modeOnce.Do(func() {
|
||||
u.mode = "dc"
|
||||
u.logger.Info("[relay] === MODE: DC ===")
|
||||
})
|
||||
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)
|
||||
ordered := true
|
||||
dcNotif, err := pc.CreateDataChannel("producerNotification", &webrtc.DataChannelInit{Ordered: &ordered})
|
||||
if err == nil {
|
||||
dcNotif.OnOpen(func() { u.logger.Debug("[relay] producerNotification DC opened") })
|
||||
dcNotif.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
u.logger.Debug(fmt.Sprintf("[relay] producerNotification msg len=%d", len(msg.Data)))
|
||||
})
|
||||
}
|
||||
dcCmd, err := pc.CreateDataChannel("producerCommand", &webrtc.DataChannelInit{Ordered: &ordered})
|
||||
if err == nil {
|
||||
dcCmd.OnOpen(func() { u.logger.Debug("[relay] producerCommand DC opened") })
|
||||
dcCmd.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
u.logger.Debug(fmt.Sprintf("[relay] producerCommand msg len=%d", len(msg.Data)))
|
||||
})
|
||||
}
|
||||
producerScreen, psErr := pc.CreateDataChannel("producerScreenShare", &webrtc.DataChannelInit{Ordered: &ordered})
|
||||
if psErr == nil {
|
||||
u.producerScreen = producerScreen
|
||||
producerScreen.OnOpen(func() { u.logger.Debug("[relay] producerScreenShare DC open, reading uplink screen") })
|
||||
producerScreen.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
if u.sym != nil {
|
||||
u.sym.HandleScreenFrame(msg.Data)
|
||||
}
|
||||
})
|
||||
}
|
||||
screenDC, scErr := pc.CreateDataChannel("consumerScreenShare", &webrtc.DataChannelInit{Ordered: &ordered})
|
||||
if scErr == nil {
|
||||
u.screenDC = screenDC
|
||||
screenDC.OnOpen(func() { u.logger.Debug("[relay] consumerScreenShare DC open, writing downlink screen") })
|
||||
}
|
||||
pc.OnICECandidate(func(cand *webrtc.ICECandidate) {
|
||||
if cand == nil {
|
||||
return
|
||||
}
|
||||
if u.externalICE != nil {
|
||||
u.externalICE(cand)
|
||||
}
|
||||
})
|
||||
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
||||
u.logger.Debug(fmt.Sprintf("[relay] connection state: %s (mode=%s)", state.String(), u.mode))
|
||||
if u.externalCSC != nil {
|
||||
u.externalCSC(state)
|
||||
}
|
||||
})
|
||||
pc.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||
u.logger.Debug(fmt.Sprintf("[relay] remote track: %s", track.Codec().MimeType))
|
||||
u.modeOnce.Do(func() {
|
||||
u.mode = "video"
|
||||
u.logger.Info("[relay] === MODE: VIDEO ===")
|
||||
u.tun = tunnel.NewVP8DataTunnel(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)
|
||||
dc := u.screenDC
|
||||
writer.SetSend(dc.Send)
|
||||
u.sym = tunnel.NewSymmetricScreenTunnel(u.tun, writer, u.obf, func() bool {
|
||||
return dc.ReadyState() == webrtc.DataChannelStateOpen
|
||||
}, u.logger)
|
||||
downlink = u.sym
|
||||
u.logger.Info("[relay] === MODE: VIDEO (with screenshare) ===")
|
||||
}
|
||||
if u.OnConnected != nil {
|
||||
u.OnConnected(downlink)
|
||||
}
|
||||
})
|
||||
go u.readTrack(track)
|
||||
})
|
||||
u.logger.Debug(fmt.Sprintf("[relay] PC created (%d ICE servers)", len(iceServers)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) CreateOffer() (webrtc.SessionDescription, error) {
|
||||
offer, err := u.pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
return offer, err
|
||||
}
|
||||
u.pc.SetLocalDescription(offer)
|
||||
return offer, nil
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) CreateAnswer() (webrtc.SessionDescription, error) {
|
||||
answer, err := u.pc.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return answer, err
|
||||
}
|
||||
u.pc.SetLocalDescription(answer)
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) SetRemoteDescription(sdpType webrtc.SDPType, sdp string) error {
|
||||
err := u.pc.SetRemoteDescription(webrtc.SessionDescription{Type: sdpType, SDP: sdp})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.remoteSet = true
|
||||
for _, cand := range u.pending {
|
||||
u.pc.AddICECandidate(cand)
|
||||
}
|
||||
u.pending = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) AddICECandidate(candidate webrtc.ICECandidateInit) error {
|
||||
if !u.remoteSet {
|
||||
u.pending = append(u.pending, candidate)
|
||||
return nil
|
||||
}
|
||||
return u.pc.AddICECandidate(candidate)
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) OnICECandidate(fn func(*webrtc.ICECandidate)) {
|
||||
u.externalICE = fn
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) OnConnectionStateChange(fn func(webrtc.PeerConnectionState)) {
|
||||
u.externalCSC = fn
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) Close() {
|
||||
u.closeAllConns()
|
||||
if u.sym != nil {
|
||||
u.sym.Stop()
|
||||
u.sym = nil
|
||||
}
|
||||
if u.tun != nil {
|
||||
u.tun.Stop()
|
||||
u.tun = nil
|
||||
}
|
||||
u.dcMu.Lock()
|
||||
u.dc = nil
|
||||
u.dcMu.Unlock()
|
||||
if u.pc != nil {
|
||||
u.pc.OnConnectionStateChange(nil)
|
||||
u.pc.OnICECandidate(nil)
|
||||
u.pc.OnTrack(nil)
|
||||
oldPC := u.pc
|
||||
u.pc = nil
|
||||
go oldPC.Close()
|
||||
}
|
||||
u.remoteSet = false
|
||||
u.pending = nil
|
||||
u.sampleTrack = nil
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) handleDCMessage(data []byte) {
|
||||
if u.obf != nil {
|
||||
pt, ok := u.obf.DecryptPayload(data)
|
||||
if !ok {
|
||||
u.logger.Debug(fmt.Sprintf("[dc] decrypt failed, dropping %d bytes", len(data)))
|
||||
return
|
||||
}
|
||||
data = pt
|
||||
}
|
||||
if len(data) < 5 {
|
||||
return
|
||||
}
|
||||
connID := binary.BigEndian.Uint32(data[0:4])
|
||||
mt := data[4]
|
||||
payload := data[5:]
|
||||
switch mt {
|
||||
case tunnel.MsgConnect:
|
||||
go u.connectTCP(connID, string(payload))
|
||||
case tunnel.MsgUDP:
|
||||
go u.handleUDP(connID, payload)
|
||||
case tunnel.MsgData:
|
||||
val, ok := u.conns.Load(connID)
|
||||
if ok {
|
||||
dc := val.(*dcConn)
|
||||
cp := make([]byte, len(payload))
|
||||
copy(cp, payload)
|
||||
select {
|
||||
case dc.ch <- cp:
|
||||
default:
|
||||
u.logger.Debug(fmt.Sprintf("[dc] conn %d write queue full, dropping %d bytes", connID, len(payload)))
|
||||
}
|
||||
}
|
||||
case tunnel.MsgClose:
|
||||
val, ok := u.conns.LoadAndDelete(connID)
|
||||
if ok {
|
||||
dc := val.(*dcConn)
|
||||
close(dc.ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) sendDCFrame(connID uint32, mt byte, payload []byte) {
|
||||
u.dcMu.Lock()
|
||||
defer u.dcMu.Unlock()
|
||||
if u.dc == nil {
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 5+len(payload))
|
||||
binary.BigEndian.PutUint32(buf[0:4], connID)
|
||||
buf[4] = mt
|
||||
copy(buf[5:], payload)
|
||||
wire := buf
|
||||
if u.obf != nil {
|
||||
wire = u.obf.EncryptPayload(buf)
|
||||
if wire == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
u.dc.Send(wire)
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) connectTCP(connID uint32, addr string) {
|
||||
u.logger.Debug(fmt.Sprintf("[dc] CONNECT %d -> %s", connID, common.MaskAddr(addr)))
|
||||
conn, err := u.dialTCP(addr)
|
||||
if err != nil {
|
||||
u.logger.Warn(fmt.Sprintf("[dc] CONNECT %d failed: %s", connID, common.MaskError(err)))
|
||||
u.sendDCFrame(connID, tunnel.MsgConnectErr, []byte(common.MaskError(err)))
|
||||
return
|
||||
}
|
||||
dc := &dcConn{conn: conn, ch: make(chan []byte, 256)}
|
||||
u.conns.Store(connID, dc)
|
||||
u.sendDCFrame(connID, tunnel.MsgConnectOK, nil)
|
||||
u.logger.Debug(fmt.Sprintf("[dc] CONNECTED %d -> %s", connID, common.MaskAddr(addr)))
|
||||
go func() {
|
||||
for data := range dc.ch {
|
||||
conn.Write(data)
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
bufSz := u.readBufSize
|
||||
if bufSz <= 0 {
|
||||
bufSz = common.RTPBufSize
|
||||
}
|
||||
buf := make([]byte, bufSz)
|
||||
sent := 0
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
u.sendDCFrame(connID, tunnel.MsgData, buf[:n])
|
||||
sent += n
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
u.logger.Warn(fmt.Sprintf("[dc] conn %d read error: %s", connID, common.MaskError(err)))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
u.logger.Debug(fmt.Sprintf("[dc] conn %d closed, sent %d bytes", connID, sent))
|
||||
u.sendDCFrame(connID, tunnel.MsgClose, nil)
|
||||
u.conns.Delete(connID)
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) handleUDP(connID uint32, payload []byte) {
|
||||
if len(payload) < 2 {
|
||||
return
|
||||
}
|
||||
addrLen := int(payload[0])
|
||||
if len(payload) < 1+addrLen {
|
||||
return
|
||||
}
|
||||
addr := string(payload[1 : 1+addrLen])
|
||||
data := payload[1+addrLen:]
|
||||
conn, err := u.dialUDP(addr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
conn.Write(data)
|
||||
resp := make([]byte, common.UDPBufSize)
|
||||
n, err := conn.Read(resp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
u.sendDCFrame(connID, tunnel.MsgUDPReply, resp[:n])
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) dialTCP(addr string) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return u.dialer.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddr(addr))
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) dialUDP(addr string) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return u.dialer.DialContext(ctx, N.NetworkUDP, M.ParseSocksaddr(addr))
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) closeAllConns() {
|
||||
u.conns.Range(func(key, val any) bool {
|
||||
dc := val.(*dcConn)
|
||||
dc.conn.Close()
|
||||
u.conns.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (u *TunnelRelay) readTrack(track *webrtc.TrackRemote) {
|
||||
if track.Codec().MimeType != webrtc.MimeTypeVP8 {
|
||||
buf := make([]byte, common.UDPBufSize)
|
||||
for {
|
||||
if _, _, err := track.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
var vp8Pkt codecs.VP8Packet
|
||||
var pkt rtp.Packet
|
||||
var frameBuf []byte
|
||||
var lastSeq uint16
|
||||
var haveLastSeq bool
|
||||
frameValid := false
|
||||
var recvCount int
|
||||
buf := make([]byte, common.RTPBufSize)
|
||||
for {
|
||||
n, _, err := track.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if pkt.Unmarshal(buf[:n]) != nil {
|
||||
continue
|
||||
}
|
||||
if haveLastSeq && pkt.SequenceNumber != lastSeq+1 {
|
||||
frameValid = false
|
||||
frameBuf = frameBuf[:0]
|
||||
}
|
||||
lastSeq = pkt.SequenceNumber
|
||||
haveLastSeq = true
|
||||
vp8Payload, err := vp8Pkt.Unmarshal(pkt.Payload)
|
||||
if err != nil {
|
||||
frameValid = false
|
||||
frameBuf = frameBuf[:0]
|
||||
continue
|
||||
}
|
||||
if vp8Pkt.S == 1 {
|
||||
frameBuf = frameBuf[:0]
|
||||
frameValid = true
|
||||
}
|
||||
if !frameValid {
|
||||
continue
|
||||
}
|
||||
frameBuf = append(frameBuf, vp8Payload...)
|
||||
if !pkt.Marker {
|
||||
continue
|
||||
}
|
||||
recvCount++
|
||||
if recvCount <= 3 || recvCount%200 == 0 {
|
||||
u.logger.Debug(fmt.Sprintf("[video] recv vp8 frame #%d %d bytes", recvCount, len(frameBuf)))
|
||||
}
|
||||
if u.tun != nil {
|
||||
u.tun.HandleFrame(frameBuf)
|
||||
}
|
||||
frameBuf = frameBuf[:0]
|
||||
frameValid = false
|
||||
}
|
||||
}
|
||||
101
transport/call/vk/screen_datachannels.go
Normal file
101
transport/call/vk/screen_datachannels.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/datachannel"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
)
|
||||
|
||||
var errScreenNotReady = errors.New("screen DC not ready")
|
||||
|
||||
type screenUplink struct {
|
||||
mu sync.Mutex
|
||||
raw io.WriteCloser
|
||||
}
|
||||
|
||||
func (u *screenUplink) attach(raw io.WriteCloser) {
|
||||
u.mu.Lock()
|
||||
u.raw = raw
|
||||
u.mu.Unlock()
|
||||
}
|
||||
|
||||
func (u *screenUplink) ready() bool {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return u.raw != nil
|
||||
}
|
||||
|
||||
func (u *screenUplink) send(b []byte) error {
|
||||
u.mu.Lock()
|
||||
raw := u.raw
|
||||
u.mu.Unlock()
|
||||
if raw == nil {
|
||||
return errScreenNotReady
|
||||
}
|
||||
_, err := raw.Write(b)
|
||||
if err != nil {
|
||||
u.mu.Lock()
|
||||
if u.raw == raw {
|
||||
u.raw = nil
|
||||
}
|
||||
u.mu.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *screenUplink) reset() {
|
||||
u.mu.Lock()
|
||||
if u.raw != nil {
|
||||
u.raw.Close()
|
||||
u.raw = nil
|
||||
}
|
||||
u.mu.Unlock()
|
||||
}
|
||||
|
||||
func readScreenDataChannel(dc *webrtc.DataChannel, handler func([]byte), logger logger.ContextLogger) {
|
||||
dc.OnOpen(func() {
|
||||
var raw datachannel.ReadWriteCloser
|
||||
raw, err := dc.Detach()
|
||||
if err != nil {
|
||||
logger.Warn("[vk-joiner] screen DC detach failed, using OnMessage")
|
||||
dc.OnMessage(func(m webrtc.DataChannelMessage) {
|
||||
if !m.IsString && len(m.Data) > 0 {
|
||||
frame := make([]byte, len(m.Data))
|
||||
copy(frame, m.Data)
|
||||
handler(frame)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.Debug("[vk-joiner] screen DC attached for reading")
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
n, isString, rerr := raw.ReadDataChannel(buf)
|
||||
if rerr != nil {
|
||||
return
|
||||
}
|
||||
if isString || n == 0 {
|
||||
continue
|
||||
}
|
||||
frame := make([]byte, n)
|
||||
copy(frame, buf[:n])
|
||||
handler(frame)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func attachScreenWriterDC(dc *webrtc.DataChannel, onRaw func(io.WriteCloser), logger logger.ContextLogger) {
|
||||
dc.OnOpen(func() {
|
||||
raw, err := dc.Detach()
|
||||
if err != nil {
|
||||
logger.Warn("[vk-joiner] screen writer DC detach failed")
|
||||
return
|
||||
}
|
||||
logger.Debug("[vk-joiner] screen DC attached for writing")
|
||||
onRaw(raw)
|
||||
})
|
||||
}
|
||||
244
transport/call/vk/vk_auth.go
Normal file
244
transport/call/vk/vk_auth.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package vk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/sagernet/sing-box/transport/call/common"
|
||||
"github.com/sagernet/sing/common/logger"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
type vkAuthConfig struct {
|
||||
AppID string `json:"appID"`
|
||||
ApiVersion string `json:"apiVersion"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
OkJoinLink string `json:"okJoinLink,omitempty"`
|
||||
}
|
||||
|
||||
type vkCaptchaError struct {
|
||||
captchaSid string
|
||||
redirectURI string
|
||||
captchaTs string
|
||||
captchaAttempt string
|
||||
}
|
||||
|
||||
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) {
|
||||
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("Origin", "https://vk.ru")
|
||||
req.Header.Set("Referer", "https://vk.ru/")
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("json: %w (body: %s)", err, string(body[:minInt(len(body), 200)]))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
cfg := &vkAuthConfig{
|
||||
AppID: "6287487",
|
||||
ApiVersion: "5.282",
|
||||
AppVersion: "1.1",
|
||||
ProtocolVersion: "5",
|
||||
}
|
||||
logger.Debug(fmt.Sprintf("vk-auth: appID=%s api=%s appVersion=%s proto=%s", cfg.AppID, cfg.ApiVersion, cfg.AppVersion, cfg.ProtocolVersion))
|
||||
logger.Info("vk-auth: getting anonymous token")
|
||||
anonResp, err := httpPost("https://login.vk.ru/?act=get_anonym_token", url.Values{
|
||||
"client_id": {cfg.AppID},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get_anonym_token: %w", err)
|
||||
}
|
||||
dataMap, _ := anonResp["data"].(map[string]interface{})
|
||||
accessToken, _ := dataMap["access_token"].(string)
|
||||
if accessToken == "" {
|
||||
return "", fmt.Errorf("empty access_token: %v", anonResp)
|
||||
}
|
||||
logger.Debug("vk-auth: anon token OK")
|
||||
auth := map[string]string{"Authorization": "Bearer " + accessToken}
|
||||
logger.Info("vk-auth: getting call settings")
|
||||
settingsResp, err := httpPost("https://api.vk.ru/method/calls.getSettings", url.Values{
|
||||
"v": {cfg.ApiVersion},
|
||||
}, auth)
|
||||
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 pk, ok := settings["public_key"].(string); ok {
|
||||
cfg.PublicKey = pk
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.Debug(fmt.Sprintf("vk-auth: publicKey=%s", cfg.PublicKey))
|
||||
logger.Info("vk-auth: getting call preview")
|
||||
previewResp, err := httpPost("https://api.vk.ru/method/calls.getCallPreview", url.Values{
|
||||
"v": {cfg.ApiVersion},
|
||||
"vk_join_link": {joinLink},
|
||||
}, auth)
|
||||
if err == nil {
|
||||
if respObj, ok := previewResp["response"].(map[string]interface{}); ok {
|
||||
if okLink, ok := respObj["ok_join_link"].(string); ok {
|
||||
cfg.OkJoinLink = okLink
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.Info("vk-auth: getting call token")
|
||||
callParams := url.Values{
|
||||
"v": {cfg.ApiVersion},
|
||||
"vk_join_link": {joinLink},
|
||||
"name": {displayName},
|
||||
}
|
||||
var callToken string
|
||||
var apiBaseURL string
|
||||
var okJoinLink string
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
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 {
|
||||
errCode, _ := errObj["error_code"].(float64)
|
||||
if int(errCode) == 14 {
|
||||
captchaErr := parseVKCaptchaError(errObj)
|
||||
if captchaErr == nil {
|
||||
return "", fmt.Errorf("captcha error missing fields: %v", errObj)
|
||||
}
|
||||
logger.Info("vk-auth: captcha required")
|
||||
proxyPort := StartCaptchaProxy(captchaErr.redirectURI, dialer)
|
||||
if proxyPort == 0 {
|
||||
return "", fmt.Errorf("failed to start captcha proxy")
|
||||
}
|
||||
logger.Notice(fmt.Sprintf("vk-auth: solve the captcha to continue: http://127.0.0.1:%d/", proxyPort))
|
||||
successToken := GetCaptchaResult()
|
||||
StopCaptchaProxy()
|
||||
if successToken == "" {
|
||||
return "", fmt.Errorf("captcha timed out")
|
||||
}
|
||||
logger.Info("vk-auth: captcha solved, retrying")
|
||||
captchaAttempt := captchaErr.captchaAttempt
|
||||
if captchaAttempt == "" || captchaAttempt == "0" {
|
||||
captchaAttempt = "1"
|
||||
}
|
||||
callParams = url.Values{
|
||||
"v": {cfg.ApiVersion},
|
||||
"vk_join_link": {joinLink},
|
||||
"name": {displayName},
|
||||
"captcha_key": {""},
|
||||
"captcha_sid": {captchaErr.captchaSid},
|
||||
"is_sound_captcha": {"0"},
|
||||
"success_token": {successToken},
|
||||
"captcha_ts": {captchaErr.captchaTs},
|
||||
"captcha_attempt": {captchaAttempt},
|
||||
}
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("VK API error: %v", errObj)
|
||||
}
|
||||
respMap, ok := callResp["response"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected response: %v", callResp)
|
||||
}
|
||||
callToken, _ = respMap["token"].(string)
|
||||
apiBaseURL, _ = respMap["api_base_url"].(string)
|
||||
okJoinLink, _ = respMap["ok_join_link"].(string)
|
||||
break
|
||||
}
|
||||
if callToken == "" {
|
||||
return "", fmt.Errorf("failed to get call token")
|
||||
}
|
||||
logger.Info("vk-auth: authenticating with OK.ru")
|
||||
baseURL := strings.TrimRight(apiBaseURL, "/")
|
||||
if !strings.HasSuffix(baseURL, "/fb.do") {
|
||||
baseURL += "/fb.do"
|
||||
}
|
||||
deviceID := fmt.Sprintf("%d", rand.Int63n(9e18))
|
||||
sessionData, _ := json.Marshal(map[string]interface{}{
|
||||
"version": 2,
|
||||
"device_id": deviceID,
|
||||
"client_version": cfg.AppVersion,
|
||||
"client_type": "SDK_JS",
|
||||
})
|
||||
okResp, err := httpPost(baseURL, url.Values{
|
||||
"method": {"auth.anonymLogin"},
|
||||
"session_data": {string(sessionData)},
|
||||
"application_key": {cfg.PublicKey},
|
||||
"format": {"json"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("anonymLogin: %w", err)
|
||||
}
|
||||
sessionKey, _ := okResp["session_key"].(string)
|
||||
if sessionKey == "" {
|
||||
return "", fmt.Errorf("missing session_key: %v", okResp)
|
||||
}
|
||||
logger.Debug("vk-auth: OK.ru session OK")
|
||||
finalJoinLink := okJoinLink
|
||||
if finalJoinLink == "" {
|
||||
finalJoinLink = cfg.OkJoinLink
|
||||
}
|
||||
if finalJoinLink == "" {
|
||||
finalJoinLink = joinLink
|
||||
}
|
||||
result := map[string]string{
|
||||
"sessionKey": sessionKey,
|
||||
"applicationKey": cfg.PublicKey,
|
||||
"apiBaseURL": baseURL,
|
||||
"joinLink": finalJoinLink,
|
||||
"anonymToken": callToken,
|
||||
"appVersion": cfg.AppVersion,
|
||||
"protocolVersion": cfg.ProtocolVersion,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
logger.Debug("vk-auth: done")
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func parseVKCaptchaError(errObj map[string]interface{}) *vkCaptchaError {
|
||||
redirectURI, _ := errObj["redirect_uri"].(string)
|
||||
if redirectURI == "" {
|
||||
return nil
|
||||
}
|
||||
captchaSid := ""
|
||||
if sid, ok := errObj["captcha_sid"].(string); ok {
|
||||
captchaSid = sid
|
||||
} else if sidNum, ok := errObj["captcha_sid"].(float64); ok {
|
||||
captchaSid = fmt.Sprintf("%.0f", sidNum)
|
||||
}
|
||||
captchaTs, _ := errObj["captcha_ts"].(string)
|
||||
captchaAttempt, _ := errObj["captcha_attempt"].(string)
|
||||
return &vkCaptchaError{
|
||||
captchaSid: captchaSid,
|
||||
redirectURI: redirectURI,
|
||||
captchaTs: captchaTs,
|
||||
captchaAttempt: captchaAttempt,
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user