You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
361 lines
8.6 KiB
361 lines
8.6 KiB
// 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"
|
|
|
|
fhttp "github.com/bogdanfinn/fhttp"
|
|
tlsclient "github.com/bogdanfinn/tls-client"
|
|
"github.com/bogdanfinn/tls-client/profiles"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
wbBase = "https://stream.wb.ru"
|
|
)
|
|
|
|
// 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")
|
|
}
|
|
|
|
// wbReq makes an HTTP request to WB API using tls-client
|
|
func wbReq(ctx context.Context, client tlsclient.HttpClient, profile Profile, method, ep string, body []byte, tok string) ([]byte, error) {
|
|
var rd io.Reader
|
|
if body != nil {
|
|
rd = bytes.NewReader(body)
|
|
}
|
|
|
|
rq, err := fhttp.NewRequestWithContext(ctx, method, wbBase+ep, rd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
applyBrowserProfileFhttp(rq, profile)
|
|
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) {
|
|
profile := Profile{
|
|
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
|
SecChUa: `"Not(A:Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"`,
|
|
SecChUaMobile: "?0",
|
|
SecChUaPlatform: `"Windows"`,
|
|
}
|
|
|
|
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(),
|
|
tlsclient.WithTimeoutSeconds(20),
|
|
tlsclient.WithClientProfile(profiles.Chrome_146),
|
|
tlsclient.WithDialer(getCustomNetDialer()),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize tls_client: %w", err)
|
|
}
|
|
|
|
nm := fmt.Sprintf("lh_%d", time.Now().UnixMilli()%100000)
|
|
|
|
log.Println("[WB Auth] Step 1: Guest registration...")
|
|
rr, err := wbReq(ctx, client, profile, "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, profile, "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, profile, "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, profile, "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, profile.UserAgent)
|
|
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, userAgent 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"
|
|
|
|
header := http.Header{}
|
|
header.Set("User-Agent", userAgent)
|
|
header.Set("Origin", wbBase)
|
|
|
|
conn, _, err := (&websocket.Dialer{
|
|
TLSClientConfig: &tls.Config{},
|
|
HandshakeTimeout: 10 * time.Second,
|
|
}).DialContext(ctx, u, header)
|
|
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
|
|
}
|
|
|