6 changed files with 1056 additions and 725 deletions
@ -0,0 +1,151 @@ |
|||||
|
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||
|
// SPDX-License-Identifier: MIT
|
||||
|
|
||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"log" |
||||
|
"sync" |
||||
|
"sync/atomic" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
// getCredsFunc is the signature for credential retrieval functions
|
||||
|
type getCredsFunc func(context.Context, string, int) (string, string, string, error) |
||||
|
|
||||
|
// TurnCredentials stores cached TURN credentials
|
||||
|
type TurnCredentials struct { |
||||
|
Username string |
||||
|
Password string |
||||
|
ServerAddr string |
||||
|
ExpiresAt time.Time |
||||
|
Link string |
||||
|
} |
||||
|
|
||||
|
// StreamCredentialsCache holds credentials cache for a single stream
|
||||
|
type StreamCredentialsCache struct { |
||||
|
creds TurnCredentials |
||||
|
mutex sync.RWMutex |
||||
|
errorCount atomic.Int32 |
||||
|
lastErrorTime atomic.Int64 |
||||
|
} |
||||
|
|
||||
|
const ( |
||||
|
credentialLifetime = 10 * time.Minute |
||||
|
cacheSafetyMargin = 60 * time.Second |
||||
|
maxCacheErrors = 3 |
||||
|
errorWindow = 10 * time.Second |
||||
|
streamsPerCache = 4 // Number of streams sharing one credentials cache
|
||||
|
) |
||||
|
|
||||
|
// getCacheID returns the shared cache ID for a given stream ID
|
||||
|
func getCacheID(streamID int) int { |
||||
|
return streamID / streamsPerCache |
||||
|
} |
||||
|
|
||||
|
// credentialsStore manages per-stream credentials caches
|
||||
|
var credentialsStore = struct { |
||||
|
mu sync.RWMutex |
||||
|
caches map[int]*StreamCredentialsCache |
||||
|
}{ |
||||
|
caches: make(map[int]*StreamCredentialsCache), |
||||
|
} |
||||
|
|
||||
|
// getStreamCache returns or creates a shared cache for the given stream ID
|
||||
|
func getStreamCache(streamID int) *StreamCredentialsCache { |
||||
|
cacheID := getCacheID(streamID) |
||||
|
|
||||
|
// Try read lock first for fast path
|
||||
|
credentialsStore.mu.RLock() |
||||
|
cache, exists := credentialsStore.caches[cacheID] |
||||
|
credentialsStore.mu.RUnlock() |
||||
|
|
||||
|
if exists { |
||||
|
return cache |
||||
|
} |
||||
|
|
||||
|
// Need to create new cache
|
||||
|
credentialsStore.mu.Lock() |
||||
|
defer credentialsStore.mu.Unlock() |
||||
|
|
||||
|
// Double-check after acquiring write lock
|
||||
|
if cache, exists = credentialsStore.caches[cacheID]; exists { |
||||
|
return cache |
||||
|
} |
||||
|
|
||||
|
cache = &StreamCredentialsCache{} |
||||
|
credentialsStore.caches[cacheID] = cache |
||||
|
return cache |
||||
|
} |
||||
|
|
||||
|
// invalidate invalidates the credentials cache for this stream
|
||||
|
func (c *StreamCredentialsCache) invalidate(streamID int) { |
||||
|
c.mutex.Lock() |
||||
|
c.creds = TurnCredentials{} |
||||
|
c.mutex.Unlock() |
||||
|
|
||||
|
// Reset auth error counter
|
||||
|
c.errorCount.Store(0) |
||||
|
c.lastErrorTime.Store(0) |
||||
|
|
||||
|
log.Printf("[Auth] Credentials cache invalidated for stream %d", streamID) |
||||
|
} |
||||
|
|
||||
|
// fetchMu serializes credential fetching to avoid API rate limiting
|
||||
|
var fetchMu sync.Mutex |
||||
|
|
||||
|
// fetchFunc is the signature for credential retrieval functions (without cache logic)
|
||||
|
type fetchFunc func(ctx context.Context, link string) (string, string, string, error) |
||||
|
|
||||
|
// serializeFetch wraps a fetch call with the global fetchMu to avoid API rate limiting
|
||||
|
func serializeFetch(ctx context.Context, link string, storeFn fetchFunc) (string, string, string, error) { |
||||
|
fetchMu.Lock() |
||||
|
defer fetchMu.Unlock() |
||||
|
return storeFn(ctx, link) |
||||
|
} |
||||
|
|
||||
|
// getCredsCached checks cache before fetching credentials.
|
||||
|
// This is the general entry point for credential retrieval with caching.
|
||||
|
func getCredsCached(ctx context.Context, link string, streamID int, storeFn fetchFunc) (string, string, string, error) { |
||||
|
cache := getStreamCache(streamID) |
||||
|
cacheID := getCacheID(streamID) |
||||
|
|
||||
|
cache.mutex.Lock() |
||||
|
defer cache.mutex.Unlock() |
||||
|
|
||||
|
// Check cache - another stream may have populated it while waiting
|
||||
|
if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) { |
||||
|
expires := time.Until(cache.creds.ExpiresAt) |
||||
|
log.Printf("[Auth] Using cached credentials (cache=%d, expires in %v)", cacheID, expires) |
||||
|
return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil |
||||
|
} |
||||
|
|
||||
|
log.Printf("[Auth] Cache miss (cache=%d), starting credential fetch...", cacheID) |
||||
|
|
||||
|
// Check context before long fetch
|
||||
|
select { |
||||
|
case <-ctx.Done(): |
||||
|
return "", "", "", ctx.Err() |
||||
|
default: |
||||
|
} |
||||
|
|
||||
|
// Fetch credentials with global mutex to avoid API rate limiting
|
||||
|
user, pass, addr, err := serializeFetch(ctx, link, storeFn) |
||||
|
|
||||
|
if err != nil { |
||||
|
return "", "", "", err |
||||
|
} |
||||
|
|
||||
|
// Store in cache
|
||||
|
cache.creds = TurnCredentials{ |
||||
|
Username: user, |
||||
|
Password: pass, |
||||
|
ServerAddr: addr, |
||||
|
ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin), |
||||
|
Link: link, |
||||
|
} |
||||
|
|
||||
|
log.Printf("[Auth] Success! Credentials cached until %v (cache=%d)", cache.creds.ExpiresAt, cacheID) |
||||
|
return user, pass, addr, nil |
||||
|
} |
||||
@ -0,0 +1,258 @@ |
|||||
|
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||
|
// SPDX-License-Identifier: MIT
|
||||
|
|
||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"context" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"log" |
||||
|
"math/rand" |
||||
|
"net/http" |
||||
|
"net/url" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/google/uuid" |
||||
|
) |
||||
|
|
||||
|
const vkClientID = "6287487" |
||||
|
const vkClientSecret = "QbYic1K3lEV5kTGiqlq2" |
||||
|
const vkAPIVersion = "5.275" |
||||
|
|
||||
|
func min(a, b int) int { |
||||
|
if a < b { |
||||
|
return a |
||||
|
} |
||||
|
return b |
||||
|
} |
||||
|
|
||||
|
// vkDelay sleeps for a random duration between minMs and maxMs to avoid bot detection
|
||||
|
func vkDelay(minMs, maxMs int) { |
||||
|
ms := minMs + rand.Intn(maxMs-minMs+1) |
||||
|
time.Sleep(time.Duration(ms) * time.Millisecond) |
||||
|
} |
||||
|
|
||||
|
func vkHTTPPost(ctx context.Context, data string, url string) (map[string]interface{}, error) { |
||||
|
client := &http.Client{ |
||||
|
Timeout: 20 * time.Second, |
||||
|
Transport: &http.Transport{ |
||||
|
MaxIdleConns: 100, |
||||
|
MaxIdleConnsPerHost: 100, |
||||
|
IdleConnTimeout: 90 * time.Second, |
||||
|
}, |
||||
|
} |
||||
|
defer client.CloseIdleConnections() |
||||
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data))) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
// Headers matching HAR capture exactly
|
||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") |
||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
req.Header.Set("Accept", "*/*") |
||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9") |
||||
|
req.Header.Set("Origin", "https://vk.ru") |
||||
|
req.Header.Set("Referer", "https://vk.ru/") |
||||
|
req.Header.Set("sec-ch-ua-platform", `"Windows"`) |
||||
|
req.Header.Set("sec-ch-ua", `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`) |
||||
|
req.Header.Set("sec-ch-ua-mobile", "?0") |
||||
|
req.Header.Set("Sec-Fetch-Site", "same-site") |
||||
|
req.Header.Set("Sec-Fetch-Mode", "cors") |
||||
|
req.Header.Set("Sec-Fetch-Dest", "empty") |
||||
|
req.Header.Set("DNT", "1") |
||||
|
req.Header.Set("Priority", "u=1, i") |
||||
|
|
||||
|
httpResp, err := client.Do(req) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
defer httpResp.Body.Close() |
||||
|
|
||||
|
// Handle HTTP errors (redirects, rate limits, etc.)
|
||||
|
if httpResp.StatusCode >= 400 { |
||||
|
body, _ := io.ReadAll(httpResp.Body) |
||||
|
return nil, fmt.Errorf("HTTP %d from %s: %s", httpResp.StatusCode, req.URL, string(body[:min(len(body), 500)])) |
||||
|
} |
||||
|
|
||||
|
body, err := io.ReadAll(httpResp.Body) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
// Check content type - VK may return HTML instead of JSON (captcha page, redirect, etc.)
|
||||
|
contentType := httpResp.Header.Get("Content-Type") |
||||
|
if contentType != "" && !strings.Contains(contentType, "application/json") && !strings.Contains(contentType, "text/javascript") { |
||||
|
// Log first 500 chars of non-JSON response for debugging
|
||||
|
logPreview := string(body) |
||||
|
if len(logPreview) > 500 { |
||||
|
logPreview = logPreview[:500] + "...(truncated)" |
||||
|
} |
||||
|
return nil, fmt.Errorf("unexpected content-type %s, status %d, body: %s", contentType, httpResp.StatusCode, logPreview) |
||||
|
} |
||||
|
|
||||
|
var resp map[string]interface{} |
||||
|
if err = json.Unmarshal(body, &resp); err != nil { |
||||
|
// Log the raw body for debugging
|
||||
|
logPreview := string(body) |
||||
|
if len(logPreview) > 500 { |
||||
|
logPreview = logPreview[:500] + "...(truncated)" |
||||
|
} |
||||
|
return nil, fmt.Errorf("JSON parse error: %w, body: %s", err, logPreview) |
||||
|
} |
||||
|
return resp, nil |
||||
|
} |
||||
|
|
||||
|
func getVkCreds(ctx context.Context, link string) (string, string, string, error) { |
||||
|
// Token 1 (messages)
|
||||
|
log.Println("[VK Auth] Getting Token 1...") |
||||
|
data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s", |
||||
|
vkClientID, vkClientSecret, vkClientID) |
||||
|
resp, err := vkHTTPPost(ctx, data, "https://login.vk.ru/?act=get_anonym_token") |
||||
|
if err != nil { |
||||
|
return "", "", "", fmt.Errorf("Token 1 request error: %w", err) |
||||
|
} |
||||
|
if errMsg, ok := resp["error"].(map[string]interface{}); ok { |
||||
|
return "", "", "", fmt.Errorf("Token 1 VK error: %v", errMsg) |
||||
|
} |
||||
|
dataObj, ok := resp["data"].(map[string]interface{}) |
||||
|
if !ok { |
||||
|
return "", "", "", fmt.Errorf("invalid Token 1 response: %v", resp) |
||||
|
} |
||||
|
token1, ok := dataObj["access_token"].(string) |
||||
|
if !ok { |
||||
|
return "", "", "", fmt.Errorf("access_token not found in Token 1 response") |
||||
|
} |
||||
|
log.Println("[VK Auth] Token 1 received") |
||||
|
vkDelay(100, 200) // Token 1 → getCallPreview
|
||||
|
|
||||
|
// getCallPreview (optional, like browser)
|
||||
|
log.Println("[VK Auth] Getting call preview...") |
||||
|
cpData := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&fields=photo_200&access_token=%s", |
||||
|
url.QueryEscape(link), token1) |
||||
|
cpURL := fmt.Sprintf("https://api.vk.ru/method/calls.getCallPreview?v=%s&client_id=%s", vkAPIVersion, vkClientID) |
||||
|
_, _ = vkHTTPPost(ctx, cpData, cpURL) // non-critical
|
||||
|
vkDelay(500, 1000) // getCallPreview → Token 2
|
||||
|
|
||||
|
// Token 2 (may require captcha)
|
||||
|
log.Println("[VK Auth] Getting Token 2...") |
||||
|
t2Data := fmt.Sprintf("vk_join_link=https://vk.ru/call/join/%s&name=123&access_token=%s", |
||||
|
url.QueryEscape(link), token1) |
||||
|
t2URL := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=%s&client_id=%s", vkAPIVersion, vkClientID) |
||||
|
resp, err = vkHTTPPost(ctx, t2Data, t2URL) |
||||
|
if err != nil { |
||||
|
return "", "", "", fmt.Errorf("Token 2 request error: %w", err) |
||||
|
} |
||||
|
|
||||
|
// Check for captcha error
|
||||
|
if errMsg, ok := resp["error"].(map[string]interface{}); ok { |
||||
|
captchaData, isCaptcha := ExtractCaptchaData(errMsg) |
||||
|
if !isCaptcha { |
||||
|
return "", "", "", fmt.Errorf("Token 2 VK error: %v", errMsg) |
||||
|
} |
||||
|
|
||||
|
log.Printf("[VK Auth] Captcha detected, solving...") |
||||
|
successToken, solveErr := SolveVkCaptcha(ctx, captchaData) |
||||
|
if solveErr != nil { |
||||
|
return "", "", "", fmt.Errorf("captcha solving failed: %w", solveErr) |
||||
|
} |
||||
|
|
||||
|
// Delay before retry (endSession → Token 2 retry)
|
||||
|
vkDelay(100, 200) |
||||
|
|
||||
|
// Retry Token 2 with captcha solution
|
||||
|
log.Println("[VK Auth] Retrying Token 2 with captcha solution...") |
||||
|
t2Data = fmt.Sprintf( |
||||
|
"vk_join_link=https://vk.ru/call/join/%s&name=123"+ |
||||
|
"&captcha_key=&captcha_sid=%s&is_sound_captcha=0"+ |
||||
|
"&success_token=%s&captcha_ts=%s&captcha_attempt=%s"+ |
||||
|
"&access_token=%s", |
||||
|
url.QueryEscape(link), |
||||
|
captchaData.CaptchaSid, |
||||
|
successToken, |
||||
|
captchaData.CaptchaTs, |
||||
|
captchaData.CaptchaAttempt, |
||||
|
token1, |
||||
|
) |
||||
|
resp, err = vkHTTPPost(ctx, t2Data, t2URL) |
||||
|
if err != nil { |
||||
|
return "", "", "", fmt.Errorf("Token 2 retry request error: %w", err) |
||||
|
} |
||||
|
if errMsg2, ok := resp["error"].(map[string]interface{}); ok { |
||||
|
return "", "", "", fmt.Errorf("Token 2 retry VK error: %v", errMsg2) |
||||
|
} |
||||
|
// Token 2 retry → Token 3
|
||||
|
vkDelay(100, 200) |
||||
|
} |
||||
|
|
||||
|
token2Obj, ok := resp["response"].(map[string]interface{}) |
||||
|
if !ok { |
||||
|
return "", "", "", fmt.Errorf("invalid Token 2 response: %v", resp) |
||||
|
} |
||||
|
token2, ok := token2Obj["token"].(string) |
||||
|
if !ok { |
||||
|
return "", "", "", fmt.Errorf("token not found in Token 2 response") |
||||
|
} |
||||
|
log.Println("[VK Auth] Token 2 received") |
||||
|
// Token 2 → Token 3
|
||||
|
vkDelay(100, 200) |
||||
|
|
||||
|
// Token 3 (OK auth.anonymLogin)
|
||||
|
log.Println("[VK Auth] Getting Token 3...") |
||||
|
sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New()) |
||||
|
t3Data := fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA", |
||||
|
url.QueryEscape(sessionData)) |
||||
|
resp, err = vkHTTPPost(ctx, t3Data, "https://calls.okcdn.ru/fb.do") |
||||
|
if err != nil { |
||||
|
return "", "", "", fmt.Errorf("Token 3 request error: %w", err) |
||||
|
} |
||||
|
if errMsg, ok := resp["error"].(string); ok && errMsg != "" { |
||||
|
return "", "", "", fmt.Errorf("Token 3 API error: %s", errMsg) |
||||
|
} |
||||
|
token3, ok := resp["session_key"].(string) |
||||
|
if !ok { |
||||
|
return "", "", "", fmt.Errorf("session_key not found in Token 3 response") |
||||
|
} |
||||
|
log.Println("[VK Auth] Token 3 received") |
||||
|
// Token 3 → Final (TURN)
|
||||
|
vkDelay(100, 200) |
||||
|
|
||||
|
// Final: vchat.joinConversationByLink (Token 4)
|
||||
|
log.Println("[VK Auth] Getting TURN credentials (Token 4)...") |
||||
|
finalData := fmt.Sprintf( |
||||
|
"joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", |
||||
|
url.QueryEscape(link), token2, token3) |
||||
|
resp, err = vkHTTPPost(ctx, finalData, "https://calls.okcdn.ru/fb.do") |
||||
|
if err != nil { |
||||
|
return "", "", "", fmt.Errorf("Final request error: %w", err) |
||||
|
} |
||||
|
if errMsg, ok := resp["error"].(string); ok && errMsg != "" { |
||||
|
return "", "", "", fmt.Errorf("Final API error: %s", errMsg) |
||||
|
} |
||||
|
|
||||
|
ts, ok := resp["turn_server"].(map[string]interface{}) |
||||
|
if !ok { |
||||
|
return "", "", "", fmt.Errorf("turn_server not found in response: %v", resp) |
||||
|
} |
||||
|
urls, _ := ts["urls"].([]interface{}) |
||||
|
if len(urls) == 0 { |
||||
|
return "", "", "", fmt.Errorf("urls not found in turn_server") |
||||
|
} |
||||
|
urlStr, _ := urls[0].(string) |
||||
|
clean := strings.Split(urlStr, "?")[0] |
||||
|
address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") |
||||
|
|
||||
|
username, _ := ts["username"].(string) |
||||
|
credential, _ := ts["credential"].(string) |
||||
|
|
||||
|
if username == "" || credential == "" { |
||||
|
return "", "", "", fmt.Errorf("username or credential not found in turn_server") |
||||
|
} |
||||
|
|
||||
|
log.Println("[VK Auth] TURN credentials received") |
||||
|
vkDelay(1500, 2500) // Final delay before exit
|
||||
|
return username, credential, address, nil |
||||
|
} |
||||
@ -0,0 +1,358 @@ |
|||||
|
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||
|
// SPDX-License-Identifier: MIT
|
||||
|
|
||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"compress/gzip" |
||||
|
"context" |
||||
|
"crypto/tls" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"io" |
||||
|
"log" |
||||
|
"net/http" |
||||
|
"net/url" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/gorilla/websocket" |
||||
|
) |
||||
|
|
||||
|
const ( |
||||
|
wbBase = "https://stream.wb.ru" |
||||
|
wbUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" |
||||
|
) |
||||
|
|
||||
|
// WbTurnCred stores a single TURN credential
|
||||
|
type WbTurnCred struct { |
||||
|
URL string |
||||
|
Username string |
||||
|
Password string |
||||
|
} |
||||
|
|
||||
|
// wbFetch adapts fetchWbCreds to the fetchFunc signature
|
||||
|
func wbFetch(ctx context.Context, link string) (string, string, string, error) { |
||||
|
_ = link // WB doesn't use link parameter
|
||||
|
creds, err := fetchWbCreds(ctx) |
||||
|
if err != nil { |
||||
|
return "", "", "", err |
||||
|
} |
||||
|
if len(creds) > 0 { |
||||
|
// Clean URL: "turn:host:port?transport=udp" -> "host:port"
|
||||
|
clean := strings.Split(creds[0].URL, "?")[0] |
||||
|
address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") |
||||
|
return creds[0].Username, creds[0].Password, address, nil |
||||
|
} |
||||
|
return "", "", "", fmt.Errorf("no TURN credentials received from WB") |
||||
|
} |
||||
|
|
||||
|
// wbHTTPClient creates a WB HTTP client
|
||||
|
func wbHTTPClient() *http.Client { |
||||
|
return &http.Client{ |
||||
|
Timeout: 15 * time.Second, |
||||
|
Transport: &http.Transport{ |
||||
|
MaxIdleConns: 100, |
||||
|
MaxIdleConnsPerHost: 100, |
||||
|
IdleConnTimeout: 90 * time.Second, |
||||
|
TLSClientConfig: &tls.Config{}, |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// wbReq makes an HTTP request to WB API
|
||||
|
func wbReq(ctx context.Context, client *http.Client, method, ep string, body []byte, tok string) ([]byte, error) { |
||||
|
var rd io.Reader |
||||
|
if body != nil { |
||||
|
rd = bytes.NewReader(body) |
||||
|
} |
||||
|
|
||||
|
rq, err := http.NewRequestWithContext(ctx, method, wbBase+ep, rd) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
rq.Header.Set("User-Agent", wbUA) |
||||
|
rq.Header.Set("Accept", "application/json") |
||||
|
rq.Header.Set("Accept-Language", "en-US,en;q=0.9") |
||||
|
rq.Header.Set("Origin", wbBase) |
||||
|
rq.Header.Set("Referer", wbBase+"/") |
||||
|
if body != nil { |
||||
|
rq.Header.Set("Content-Type", "application/json") |
||||
|
} |
||||
|
if tok != "" { |
||||
|
rq.Header.Set("Authorization", "Bearer "+tok) |
||||
|
} |
||||
|
|
||||
|
rs, err := client.Do(rq) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
defer rs.Body.Close() |
||||
|
|
||||
|
var r io.Reader = rs.Body |
||||
|
if rs.Header.Get("Content-Encoding") == "gzip" { |
||||
|
if g, e := gzip.NewReader(rs.Body); e == nil { |
||||
|
defer g.Close() |
||||
|
r = g |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
b, err := io.ReadAll(r) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
if rs.StatusCode >= 300 { |
||||
|
return nil, fmt.Errorf("HTTP %d: %s", rs.StatusCode, string(b)) |
||||
|
} |
||||
|
|
||||
|
return b, nil |
||||
|
} |
||||
|
|
||||
|
// fetchWbCreds performs the full WB credential acquisition flow
|
||||
|
func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { |
||||
|
client := wbHTTPClient() |
||||
|
defer client.CloseIdleConnections() |
||||
|
|
||||
|
nm := fmt.Sprintf("lh_%d", time.Now().UnixMilli()%100000) |
||||
|
|
||||
|
log.Println("[WB Auth] Step 1: Guest registration...") |
||||
|
rr, err := wbReq(ctx, client, "POST", "/auth/api/v1/auth/user/guest-register", |
||||
|
[]byte(`{"displayName":"`+nm+`"}`), "") |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("guest register: %w", err) |
||||
|
} |
||||
|
|
||||
|
var reg struct { |
||||
|
AccessToken string `json:"accessToken"` |
||||
|
} |
||||
|
if err = json.Unmarshal(rr, ®); err != nil { |
||||
|
return nil, fmt.Errorf("parse register response: %w", err) |
||||
|
} |
||||
|
if reg.AccessToken == "" { |
||||
|
return nil, fmt.Errorf("no access token in response") |
||||
|
} |
||||
|
log.Println("[WB Auth] Guest registered") |
||||
|
|
||||
|
log.Println("[WB Auth] Step 2: Create room...") |
||||
|
rr, err = wbReq(ctx, client, "POST", "/api-room/api/v2/room", |
||||
|
[]byte(`{"roomType":"ROOM_TYPE_ALL_ON_SCREEN","roomPrivacy":"ROOM_PRIVACY_FREE"}`), |
||||
|
reg.AccessToken) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("create room: %w", err) |
||||
|
} |
||||
|
|
||||
|
var room struct { |
||||
|
RoomID string `json:"roomId"` |
||||
|
} |
||||
|
if err = json.Unmarshal(rr, &room); err != nil { |
||||
|
return nil, fmt.Errorf("parse room response: %w", err) |
||||
|
} |
||||
|
if room.RoomID == "" { |
||||
|
return nil, fmt.Errorf("no room ID in response") |
||||
|
} |
||||
|
roomPreview := room.RoomID |
||||
|
if len(roomPreview) > 8 { |
||||
|
roomPreview = roomPreview[:8] |
||||
|
} |
||||
|
log.Printf("[WB Auth] Room created: %s", roomPreview) |
||||
|
|
||||
|
log.Println("[WB Auth] Step 3: Join room...") |
||||
|
_, err = wbReq(ctx, client, "POST", fmt.Sprintf("/api-room/api/v1/room/%s/join", room.RoomID), |
||||
|
[]byte("{}"), reg.AccessToken) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("join room: %w", err) |
||||
|
} |
||||
|
|
||||
|
log.Println("[WB Auth] Step 4: Get room token...") |
||||
|
rr, err = wbReq(ctx, client, "GET", fmt.Sprintf( |
||||
|
"/api-room-manager/api/v1/room/%s/token?deviceType=PARTICIPANT_DEVICE_TYPE_WEB_DESKTOP&displayName=%s", |
||||
|
room.RoomID, url.QueryEscape(nm)), nil, reg.AccessToken) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("get token: %w", err) |
||||
|
} |
||||
|
|
||||
|
var tok struct { |
||||
|
RoomToken string `json:"roomToken"` |
||||
|
} |
||||
|
if err = json.Unmarshal(rr, &tok); err != nil { |
||||
|
return nil, fmt.Errorf("parse token response: %w", err) |
||||
|
} |
||||
|
if tok.RoomToken == "" { |
||||
|
return nil, fmt.Errorf("no room token in response") |
||||
|
} |
||||
|
|
||||
|
log.Println("[WB Auth] Step 5: Negotiating ICE (LiveKit)...") |
||||
|
creds, err := wbLkICE(ctx, tok.RoomToken) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("livekit ICE: %w", err) |
||||
|
} |
||||
|
|
||||
|
for _, c := range creds { |
||||
|
log.Printf("[WB Auth] → %s", c.URL) |
||||
|
} |
||||
|
|
||||
|
return creds, nil |
||||
|
} |
||||
|
|
||||
|
// wbLkICE connects to LiveKit WebSocket and extracts TURN credentials
|
||||
|
func wbLkICE(ctx context.Context, token string) ([]WbTurnCred, error) { |
||||
|
u := "wss://wbstream01-el.wb.ru:7880/rtc?access_token=" + url.QueryEscape(token) + |
||||
|
"&auto_subscribe=1&sdk=js&version=2.15.3&protocol=16&adaptive_stream=1" |
||||
|
|
||||
|
conn, _, err := (&websocket.Dialer{ |
||||
|
TLSClientConfig: &tls.Config{}, |
||||
|
HandshakeTimeout: 10 * time.Second, |
||||
|
}).DialContext(ctx, u, http.Header{ |
||||
|
"User-Agent": {wbUA}, |
||||
|
"Origin": {wbBase}, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
defer conn.Close() |
||||
|
|
||||
|
for i := 0; i < 15; i++ { |
||||
|
conn.SetReadDeadline(time.Now().Add(5 * time.Second)) |
||||
|
_, msg, err := conn.ReadMessage() |
||||
|
if err != nil { |
||||
|
break |
||||
|
} |
||||
|
if creds := wbPbICE(msg); len(creds) > 0 { |
||||
|
return wbDedup(creds), nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil, fmt.Errorf("TURN credentials not found in LiveKit response") |
||||
|
} |
||||
|
|
||||
|
// PbVar reads protobuf varint
|
||||
|
func wbPbVar(d []byte, o int) (uint64, int) { |
||||
|
var v uint64 |
||||
|
for s := 0; o < len(d) && s < 64; s += 7 { |
||||
|
b := d[o] |
||||
|
o++ |
||||
|
v |= uint64(b&0x7f) << s |
||||
|
if b < 0x80 { |
||||
|
return v, o |
||||
|
} |
||||
|
} |
||||
|
return 0, o |
||||
|
} |
||||
|
|
||||
|
// PbAll finds all fields with given tag number in protobuf data
|
||||
|
func wbPbAll(d []byte, f uint64) (r [][]byte) { |
||||
|
for o := 0; o < len(d); { |
||||
|
t, n := wbPbVar(d, o) |
||||
|
if n == o { |
||||
|
break |
||||
|
} |
||||
|
o = n |
||||
|
switch t & 7 { |
||||
|
case 0: |
||||
|
_, o = wbPbVar(d, o) |
||||
|
case 2: |
||||
|
l, n := wbPbVar(d, o) |
||||
|
o = n |
||||
|
e := o + int(l) |
||||
|
if e > len(d) || e < o { |
||||
|
return |
||||
|
} |
||||
|
if t>>3 == f { |
||||
|
r = append(r, d[o:e]) |
||||
|
} |
||||
|
o = e |
||||
|
case 1: |
||||
|
o += 8 |
||||
|
case 5: |
||||
|
o += 4 |
||||
|
default: |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// PbStr extracts string field with given tag number
|
||||
|
func wbPbStr(d []byte, f uint64) string { |
||||
|
if a := wbPbAll(d, f); len(a) > 0 { |
||||
|
return string(a[0]) |
||||
|
} |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
// PbICE extracts TURN/STUN credentials from protobuf message
|
||||
|
func wbPbICE(d []byte) (res []WbTurnCred) { |
||||
|
for o := 0; o < len(d); { |
||||
|
t, n := wbPbVar(d, o) |
||||
|
if n == o { |
||||
|
break |
||||
|
} |
||||
|
o = n |
||||
|
switch t & 7 { |
||||
|
case 0: |
||||
|
_, o = wbPbVar(d, o) |
||||
|
case 2: |
||||
|
l, n := wbPbVar(d, o) |
||||
|
o = n |
||||
|
e := o + int(l) |
||||
|
if e > len(d) || e < o { |
||||
|
return |
||||
|
} |
||||
|
inner := d[o:e] |
||||
|
for _, f := range []uint64{5, 9} { |
||||
|
for _, blk := range wbPbAll(inner, f) { |
||||
|
urls := wbPbAll(blk, 1) |
||||
|
hit := false |
||||
|
for _, u := range urls { |
||||
|
s := string(u) |
||||
|
if strings.HasPrefix(s, "turn") || strings.HasPrefix(s, "stun") { |
||||
|
hit = true |
||||
|
break |
||||
|
} |
||||
|
} |
||||
|
if !hit { |
||||
|
continue |
||||
|
} |
||||
|
un, pw := wbPbStr(blk, 2), wbPbStr(blk, 3) |
||||
|
for _, u := range urls { |
||||
|
res = append(res, WbTurnCred{string(u), un, pw}) |
||||
|
} |
||||
|
for _, blk2 := range wbPbAll(inner, f) { |
||||
|
if len(blk2) > 0 && len(blk) > 0 && &blk2[0] == &blk[0] { |
||||
|
continue |
||||
|
} |
||||
|
u2, p2 := wbPbStr(blk2, 2), wbPbStr(blk2, 3) |
||||
|
for _, u := range wbPbAll(blk2, 1) { |
||||
|
res = append(res, WbTurnCred{string(u), u2, p2}) |
||||
|
} |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
o = e |
||||
|
case 1: |
||||
|
o += 8 |
||||
|
case 5: |
||||
|
o += 4 |
||||
|
default: |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// wbDedup removes duplicate credentials
|
||||
|
func wbDedup(cc []WbTurnCred) (r []WbTurnCred) { |
||||
|
seen := map[string]bool{} |
||||
|
for _, c := range cc { |
||||
|
k := c.URL + "|" + c.Username |
||||
|
if !seen[k] { |
||||
|
seen[k] = true |
||||
|
r = append(r, c) |
||||
|
} |
||||
|
} |
||||
|
return |
||||
|
} |
||||
Loading…
Reference in new issue