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