Add call protocol, Rmux. Update AmneziaWG. Fixes and improvements

This commit is contained in:
Shtorm
2026-08-06 14:19:34 +03:00
parent 051e928b01
commit d6b9f693c4
89 changed files with 15671 additions and 132 deletions

284
transport/call/vk/api.go Normal file
View 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)
}

View 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
}

View 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
}

View 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), &params); 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()
}
}

View 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
View 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), &params); err != nil {
h.logger.Error(fmt.Sprintf("vk-joiner: failed to parse auth params: %v", err))
return
}
h.authParams = &params
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
View 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
View 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
}
}

View 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)
})
}

View 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
}