From b4d5a05556c6b5d064b152dd1c35c6b135af66b2 Mon Sep 17 00:00:00 2001 From: kiper292 Date: Wed, 18 Mar 2026 10:59:41 +0500 Subject: [PATCH 01/11] Add multi-user sessions and session ID support Introduce multi-user proxy sessions: add an English README and update the Russian README. Server: implement SessionManager and UserSession to aggregate DTLS streams by a 16-byte session ID, create a single UDP backend per session, and deliver backend->client traffic using round-robin across active DTLS streams. Simplify per-connection logic (read session ID after DTLS handshake, reuse backend, improved accept/handshake/error handling). Client: add -session-id flag, generate or parse a 16-byte UUID, send the session ID immediately after establishing each DTLS stream, and propagate sessionID through connection loops. These changes improve stability (prevent endpoint thrashing) and enable multiple simultaneous users per proxy server. --- README.en.md | 90 ++++++++++++++++ README.md | 12 +++ client/main.go | 62 ++++++++--- server/main.go | 279 ++++++++++++++++++++++++++++++------------------- 4 files changed, 321 insertions(+), 122 deletions(-) create mode 100644 README.en.md diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..6037f91 --- /dev/null +++ b/README.en.md @@ -0,0 +1,90 @@ +# Good TURN +[Russian version](README.md) + +Tunnels WireGuard/Hysteria traffic through VK Calls or Yandex Telemost TURN servers. Packets are encrypted with DTLS 1.2 and then sent in parallel streams via TCP or UDP to the TURN server using the STUN ChannelData protocol. From there, they are forwarded via UDP to your server, decrypted, and passed to WireGuard. TURN credentials are generated from the meeting link. + +**Update: Multi-user Proxy Server** +The current implementation supports multiple simultaneous users through a single proxy server. +- **Session Identification:** The client generates a unique 16-byte UUID at startup. +- **Stream Aggregation:** The server groups all incoming DTLS connections from a single client by its UUID. +- **Stable Backend:** For each session, exactly one UDP connection is created to the WireGuard server. This prevents the "endpoint thrashing" issue and increases stability. +- **Load Balancing:** Outgoing traffic from the server to the client is distributed among all active DTLS streams of the user (Round-Robin). + +For educational purposes only! + +## Setup +You will need: +1. A link to an active VK call: create your own (requires a VK account) or search for `"https://vk.com/call/join/"`. Links are valid forever unless "end call for all" is clicked. +2. Or a link to a Yandex Telemost call: `"https://telemost.yandex.ru/j/"`. Better not to search for these as conference participants are visible. +3. A VPS with WireGuard installed. +4. For Android: Download Termux from F-Droid. + +### Server +```bash +./server -listen 0.0.0.0:56000 -connect 127.0.0.1: +``` + +### Client +#### Android + +**Recommended method:** +Use the native Android app [vk-turn-proxy-android](https://github.com/MYSOREZ/vk-turn-proxy-android). +- In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280. +- **Add the app to WireGuard exceptions (Excluded Applications). Click "Save".** + +**Alternative method (via Termux):** +- In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280. +- **Add Termux to WireGuard exceptions. Click "Save".** + +In Termux: +```bash +termux-wake-lock +``` +The phone will not enter deep sleep. To disable: +```bash +termux-wake-unlock +``` +Copy the binary to a local folder and grant execution rights: +```bash +cp /sdcard/Download/client-android ./ +chmod 777 ./client-android +``` +Run: +```bash +./client-android -peer :56000 -vk-link -listen 127.0.0.1:9000 +``` +Additional flags: +- `-session-id `: set a fixed session ID (32 hex characters). + +Or: +```bash +./client-android -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000 +``` + +#### Linux +In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280. + +The script will add routes to the necessary IPs: +```bash +./client-linux -peer :56000 -vk-link -listen 127.0.0.1:9000 | sudo routes.sh +``` + +#### Windows +In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280. + +In PowerShell as Administrator (so the script can add routes): +```powershell +./client.exe -peer :56000 -vk-link -listen 127.0.0.1:9000 | routes.ps1 +``` + +### If it doesn't work +Use the `-turn` option to manually specify a TURN server address. +If TCP doesn't work, try adding the `-udp` flag. +Add `-n 1` for a more stable single-stream connection (limited to 5 Mbps for VK). + +## Yandex Telemost +**UPD. TELEMOST IS CLOSED** +Unlike VK, Yandex servers do not limit speed, so the default is `-n 1`. + +## Direct mode +With the `-no-dtls` flag, you can send packets without DTLS obfuscation and connect to regular WireGuard servers. This may result in a ban from VK/Yandex. diff --git a/README.md b/README.md index 8d56e74..e8dd5c2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ # Good TURN +[English version](README.en.md) + Проброс трафика WireGuard/Hysteria через TURN сервера VK звонков или Яндекс телемоста. Пакеты шифруются DTLS 1.2, затем параллельными потоками через TCP или UDP отправляются на TURN сервер по протоколу STUN ChannelData. Оттуда по UDP отправляются на ваш сервер, где расшифровываются и передаются в WireGuard. Логин/пароль от TURN генерируются из ссылки на звонок. +**Обновление: Многопользовательский прокси-сервер** +Текущая реализация поддерживает одновременную работу нескольких пользователей через один прокси-сервер. +- **Идентификация сессий:** Клиент генерирует уникальный 16-байтный UUID при старте. +- **Агрегация потоков:** Сервер группирует все входящие DTLS-соединения от одного клиента по его UUID. +- **Стабильный бэкенд:** Для каждой сессии создается ровно одно UDP-соединение с WireGuard-сервером. Это предотвращает проблему "прыгающих портов" (endpoint thrashing) и повышает стабильность. +- **Балансировка:** Исходящий трафик от сервера к клиенту распределяется между всеми активными DTLS-потоками пользователя (Round-Robin). + Только для учебных целей! ## Настройка Нам понадобится: @@ -41,6 +50,9 @@ chmod 777 ./client-android ``` ./client-android -peer :56000 -vk-link -listen 127.0.0.1:9000 ``` +Дополнительные флаги: +- `-session-id `: установить фиксированный ID сессии (32 символа hex). + Или ``` ./client-android -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000 diff --git a/client/main.go b/client/main.go index e4de0f9..93e831c 100644 --- a/client/main.go +++ b/client/main.go @@ -453,7 +453,7 @@ func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net. return dtlsConn, nil } -func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, c chan<- error) { +func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, c chan<- error, sessionID []byte) { var err error = nil defer func() { c <- err }() dtlsctx, dtlscancel := context.WithCancel(ctx) @@ -481,7 +481,15 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa } log.Printf("Closed DTLS connection\n") }() - log.Printf("Established DTLS connection!\n") + + // Phase 1: Send Session ID + dtlsConn.SetWriteDeadline(time.Now().Add(time.Second * 5)) + if _, err1 = dtlsConn.Write(sessionID); err1 != nil { + err = fmt.Errorf("failed to send session ID: %s", err1) + return + } + + log.Printf("Established DTLS connection and sent session ID!\n") go func() { for { select { @@ -758,14 +766,14 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD conn2.SetDeadline(time.Time{}) } -func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnChan <-chan net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}) { +func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnChan <-chan net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, sessionID []byte) { for { select { case <-ctx.Done(): return case listenConn := <-listenConnChan: c := make(chan error) - go oneDtlsConnection(ctx, peer, listenConn, connchan, okchan, c) + go oneDtlsConnection(ctx, peer, listenConn, connchan, okchan, c, sessionID) if err := <-c; err != nil { log.Printf("%s", err) } @@ -817,6 +825,7 @@ func main() { //nolint:cyclop n := flag.Int("n", 0, "connections to TURN (default 16 for VK, 1 for Yandex)") udp := flag.Bool("udp", false, "connect to TURN with UDP") direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE") + sessionIDFlag := flag.String("session-id", "", "override session ID (hex, 32 chars)") flag.Parse() if *peerAddr == "" { log.Panicf("Need peer address!") @@ -856,6 +865,17 @@ func main() { //nolint:cyclop getCreds, } + var sessionID []byte + if *sessionIDFlag != "" { + sessionID = make([]byte, 16) + if _, err := fmt.Sscanf(*sessionIDFlag, "%x", &sessionID); err != nil { + log.Panicf("Invalid session ID: %v", err) + } + } else { + sessionID, _ = uuid.New().MarshalBinary() + } + log.Printf("Session ID: %x", sessionID) + listenConnChan := make(chan net.PacketConn) listenConn, err := net.ListenPacket("udp", *listen) // nolint: noctx if err != nil { @@ -880,21 +900,27 @@ func main() { //nolint:cyclop t := time.Tick(100 * time.Millisecond) if *direct { for i := 0; i < *n; i++ { - wg1.Go(func() { + wg1.Add(1) + go func() { + defer wg1.Done() oneTurnConnectionLoop(ctx, params, peer, listenConnChan, t) - }) + }() } } else { okchan := make(chan struct{}) connchan := make(chan net.PacketConn) - wg1.Go(func() { - oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, okchan) - }) + wg1.Add(1) + go func() { + defer wg1.Done() + oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, okchan, sessionID) + }() - wg1.Go(func() { + wg1.Add(1) + go func() { + defer wg1.Done() oneTurnConnectionLoop(ctx, params, peer, connchan, t) - }) + }() select { case <-okchan: @@ -902,12 +928,16 @@ func main() { //nolint:cyclop } for i := 0; i < *n-1; i++ { connchan := make(chan net.PacketConn) - wg1.Go(func() { - oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil) - }) - wg1.Go(func() { + wg1.Add(1) + go func() { + defer wg1.Done() + oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil, sessionID) + }() + wg1.Add(1) + go func() { + defer wg1.Done() oneTurnConnectionLoop(ctx, params, peer, connchan, t) - }) + }() } } diff --git a/server/main.go b/server/main.go index 0136b39..5690462 100644 --- a/server/main.go +++ b/server/main.go @@ -5,11 +5,13 @@ import ( "crypto/tls" "flag" "fmt" + "io" "log" "net" "os" "os/signal" "sync" + "sync/atomic" "syscall" "time" @@ -17,6 +19,121 @@ import ( "github.com/pion/dtls/v3/pkg/crypto/selfsign" ) +type UserSession struct { + ID string + Conns []net.Conn + BackendConn net.Conn + LastUsed uint32 + Lock sync.RWMutex + Ctx context.Context + Cancel context.CancelFunc + Manager *SessionManager +} + +type SessionManager struct { + Sessions map[string]*UserSession + Lock sync.RWMutex +} + +func (s *SessionManager) GetOrCreate(ctx context.Context, id string, connectAddr string) (*UserSession, error) { + s.Lock.Lock() + defer s.Lock.Unlock() + + if session, ok := s.Sessions[id]; ok { + return session, nil + } + + backendConn, err := net.Dial("udp", connectAddr) + if err != nil { + return nil, err + } + + sessionCtx, cancel := context.WithCancel(ctx) + session := &UserSession{ + ID: id, + BackendConn: backendConn, + Manager: s, + Ctx: sessionCtx, + Cancel: cancel, + } + + s.Sessions[id] = session + go session.backendReaderLoop() + + return session, nil +} + +func (s *UserSession) backendReaderLoop() { + defer s.Cleanup() + buf := make([]byte, 1600) + for { + select { + case <-s.Ctx.Done(): + return + default: + } + + s.BackendConn.SetReadDeadline(time.Now().Add(time.Minute * 5)) + n, err := s.BackendConn.Read(buf) + if err != nil { + log.Printf("Session %s backend read error: %v", s.ID, err) + return + } + + s.Lock.RLock() + if len(s.Conns) == 0 { + s.Lock.RUnlock() + continue + } + // Round-robin selection of DTLS connection + idx := atomic.AddUint32(&s.LastUsed, 1) % uint32(len(s.Conns)) + conn := s.Conns[idx] + s.Lock.RUnlock() + + conn.SetWriteDeadline(time.Now().Add(time.Second * 10)) + _, err = conn.Write(buf[:n]) + if err != nil { + log.Printf("Session %s DTLS write error: %v", s.ID, err) + // Connection will be removed by its own reader loop + } + } +} + +func (s *UserSession) AddConn(conn net.Conn) { + s.Lock.Lock() + defer s.Lock.Unlock() + s.Conns = append(s.Conns, conn) +} + +func (s *UserSession) RemoveConn(conn net.Conn) { + s.Lock.Lock() + defer s.Lock.Unlock() + for i, c := range s.Conns { + if c == conn { + s.Conns = append(s.Conns[:i], s.Conns[i+1:]...) + break + } + } + // If all connections are gone, we might want to start a timer to cleanup the session + // but for now we'll keep it alive until backendReaderLoop fails or context is cancelled. +} + +func (s *UserSession) Cleanup() { + s.Cancel() + s.BackendConn.Close() + + s.Manager.Lock.Lock() + delete(s.Manager.Sessions, s.ID) + s.Manager.Lock.Unlock() + + s.Lock.Lock() + for _, c := range s.Conns { + c.Close() + } + s.Conns = nil + s.Lock.Unlock() +} + func main() { listen := flag.String("listen", "0.0.0.0:56000", "listen on ip:port") connect := flag.String("connect", "", "connect to ip:port") @@ -41,17 +158,12 @@ func main() { if len(*connect) == 0 { log.Panicf("server address is required") } - // Generate a certificate and private key to secure the connection + certificate, genErr := selfsign.GenerateSelfSigned() if genErr != nil { - panic(err) + panic(genErr) } - // - // Everything below is the pion-DTLS API! Thanks for using it ❤️. - // - - // Prepare the configuration of the DTLS connection config := &dtls.Config{ Certificates: []tls.Certificate{certificate}, ExtendedMasterSecret: dtls.RequireExtendedMasterSecret, @@ -59,131 +171,86 @@ func main() { ConnectionIDGenerator: dtls.RandomCIDGenerator(8), } - // Connect to a DTLS server listener, err := dtls.Listen("udp", addr, config) if err != nil { panic(err) } context.AfterFunc(ctx, func() { - if err = listener.Close(); err != nil { - panic(err) - } + listener.Close() }) - fmt.Println("Listening") + manager := &SessionManager{ + Sessions: make(map[string]*UserSession), + } + + log.Printf("Listening on %s, forwarding to %s", *listen, *connect) - wg1 := sync.WaitGroup{} for { - select { - case <-ctx.Done(): - wg1.Wait() - return - default: - } - // Wait for a connection. conn, err := listener.Accept() if err != nil { - log.Println(err) - continue + select { + case <-ctx.Done(): + return + default: + log.Println("Accept error:", err) + continue + } } - wg1.Add(1) + go func(conn net.Conn) { - defer wg1.Done() - defer conn.Close() // graceful shutdown - var err error = nil - log.Printf("Connection from %s\n", conn.RemoteAddr()) - // `conn` is of type `net.Conn` but may be casted to `dtls.Conn` - // using `dtlsConn := conn.(*dtls.Conn)` in order to to expose - // functions like `ConnectionState` etc. - - // Perform the handshake with a 30-second timeout - ctx1, cancel1 := context.WithTimeout(ctx, 30*time.Second) + defer conn.Close() + dtlsConn, ok := conn.(*dtls.Conn) if !ok { - log.Println("Type error") - cancel1() return } - log.Println("Start handshake") - if err = dtlsConn.HandshakeContext(ctx1); err != nil { - log.Println(err) - cancel1() + + handshakeCtx, hCancel := context.WithTimeout(ctx, 30*time.Second) + defer hCancel() + + if err := dtlsConn.HandshakeContext(handshakeCtx); err != nil { + log.Println("Handshake failed:", err) + return + } + + // Phase 1: Read Session ID (16 bytes) + idBuf := make([]byte, 16) + conn.SetReadDeadline(time.Now().Add(time.Second * 5)) + _, err := io.ReadFull(conn, idBuf) + if err != nil { + log.Println("Failed to read session ID:", err) return } - cancel1() - log.Println("Handshake done") + sessionID := fmt.Sprintf("%x", idBuf) - serverConn, err := net.Dial("udp", *connect) + session, err := manager.GetOrCreate(ctx, sessionID, *connect) if err != nil { - log.Println(err) + log.Println("Failed to get/create session:", err) return } - defer func() { - if err = serverConn.Close(); err != nil { - log.Printf("failed to close outgoing connection: %s", err) + + session.AddConn(conn) + defer session.RemoveConn(conn) + + log.Printf("New stream for session %s from %s", sessionID, conn.RemoteAddr()) + + // Upstream Loop: DTLS -> Backend + buf := make([]byte, 1600) + for { + conn.SetReadDeadline(time.Now().Add(time.Minute * 10)) + n, err := conn.Read(buf) + if err != nil { + log.Printf("Stream %s closed: %v", sessionID, err) return } - }() - - var wg sync.WaitGroup - wg.Add(2) - ctx2, cancel2 := context.WithCancel(ctx) - context.AfterFunc(ctx2, func() { - conn.SetDeadline(time.Now()) - serverConn.SetDeadline(time.Now()) - }) - go func() { - defer wg.Done() - defer cancel2() - buf := make([]byte, 1600) - for { - select { - case <-ctx2.Done(): - return - default: - } - conn.SetReadDeadline(time.Now().Add(time.Minute * 30)) - n, err1 := conn.Read(buf) - if err1 != nil { - log.Printf("Failed: %s", err1) - return - } - - serverConn.SetWriteDeadline(time.Now().Add(time.Minute * 30)) - _, err1 = serverConn.Write(buf[:n]) - if err1 != nil { - log.Printf("Failed: %s", err1) - return - } - } - }() - go func() { - defer wg.Done() - defer cancel2() - buf := make([]byte, 1600) - for { - select { - case <-ctx2.Done(): - return - default: - } - serverConn.SetReadDeadline(time.Now().Add(time.Minute * 30)) - n, err1 := serverConn.Read(buf) - if err1 != nil { - log.Printf("Failed: %s", err1) - return - } - - conn.SetWriteDeadline(time.Now().Add(time.Minute * 30)) - _, err1 = conn.Write(buf[:n]) - if err1 != nil { - log.Printf("Failed: %s", err1) - return - } + + session.BackendConn.SetWriteDeadline(time.Now().Add(time.Second * 5)) + _, err = session.BackendConn.Write(buf[:n]) + if err != nil { + log.Printf("Session %s backend write error: %v", sessionID, err) + return } - }() - wg.Wait() - log.Printf("Connection closed: %s\n", conn.RemoteAddr()) + } }(conn) } } From 86b357403cf5403d0a808915a4c58a35cc629f52 Mon Sep 17 00:00:00 2001 From: kiper292 Date: Wed, 18 Mar 2026 11:24:04 +0500 Subject: [PATCH 02/11] Use wireguard-turn-android as recommended app Replace the recommended Android client in README.md and README.en.md: swap vk-turn-proxy-android for wireguard-turn-android and update the link and brief description (it's a modified WireGuard client with built-in TURN). Remove the previous explicit WireGuard config/exception steps from the recommended method while leaving the Termux alternative unchanged. --- README.en.md | 4 +--- README.md | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/README.en.md b/README.en.md index 6037f91..c303beb 100644 --- a/README.en.md +++ b/README.en.md @@ -28,9 +28,7 @@ You will need: #### Android **Recommended method:** -Use the native Android app [vk-turn-proxy-android](https://github.com/MYSOREZ/vk-turn-proxy-android). -- In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280. -- **Add the app to WireGuard exceptions (Excluded Applications). Click "Save".** +Use the native Android app [wireguard-turn-android](https://github.com/kiper292/wireguard-turn-android). This is a modified WireGuard client with built-in TURN support. **Alternative method (via Termux):** - In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280. diff --git a/README.md b/README.md index e8dd5c2..cad2029 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,7 @@ #### Android **Рекомендуемый способ:** -Использовать нативное Android-приложение [vk-turn-proxy-android](https://github.com/MYSOREZ/vk-turn-proxy-android). -- В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280 -- **Добавляем приложение в исключения WireGuard. Нажимаем "сохранить".** +Использовать нативное Android-приложение [wireguard-turn-android](https://github.com/kiper292/wireguard-turn-android). Это модифицированный WireGuard клиент со встроенным TURN. **Альтернативный способ (через Termux):** - В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280 From a00db4f4a400c11e83554e706cf1ab36984c2548 Mon Sep 17 00:00:00 2001 From: kiper292 Date: Tue, 24 Mar 2026 22:46:23 +0500 Subject: [PATCH 03/11] Add per-stream ID to DTLS sessions Client and server now include a 1-byte stream ID alongside the 16-byte session ID (17 bytes total) so multiple logical streams can be multiplexed per session. Client: propagate streamID through connection helpers and send sessionID+streamID when establishing DTLS connections; default stream 0 for the first connection and subsequent goroutines pass incremental stream IDs. Server: read 17-byte header, split into sessionID and streamID, and track connections as streamEntry {id, conn} rather than a plain conn slice. AddConn/RemoveConn signatures updated to accept stream IDs; new logic evicts existing connection with the same stream ID. backendReaderLoop uses a local round-robin index (removed atomic usage) and closes connections on write errors. Also adjusted per-stream read deadlines and cleaned up connection closing in Cleanup(). --- client/main.go | 23 +++++++++------- server/main.go | 71 ++++++++++++++++++++++++++++++-------------------- 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/client/main.go b/client/main.go index 93e831c..9089931 100644 --- a/client/main.go +++ b/client/main.go @@ -453,7 +453,7 @@ func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net. return dtlsConn, nil } -func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, c chan<- error, sessionID []byte) { +func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, c chan<- error, sessionID []byte, streamID byte) { var err error = nil defer func() { c <- err }() dtlsctx, dtlscancel := context.WithCancel(ctx) @@ -482,14 +482,17 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa log.Printf("Closed DTLS connection\n") }() - // Phase 1: Send Session ID + // Phase 1: Send Session ID + Stream ID (17 bytes) dtlsConn.SetWriteDeadline(time.Now().Add(time.Second * 5)) - if _, err1 = dtlsConn.Write(sessionID); err1 != nil { + idBuf := make([]byte, 17) + copy(idBuf[:16], sessionID) + idBuf[16] = streamID + if _, err1 = dtlsConn.Write(idBuf); err1 != nil { err = fmt.Errorf("failed to send session ID: %s", err1) return } - log.Printf("Established DTLS connection and sent session ID!\n") + log.Printf("Established DTLS connection and sent session ID with stream %d!\n", streamID) go func() { for { select { @@ -766,14 +769,14 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD conn2.SetDeadline(time.Time{}) } -func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnChan <-chan net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, sessionID []byte) { +func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnChan <-chan net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, sessionID []byte, streamID byte) { for { select { case <-ctx.Done(): return case listenConn := <-listenConnChan: c := make(chan error) - go oneDtlsConnection(ctx, peer, listenConn, connchan, okchan, c, sessionID) + go oneDtlsConnection(ctx, peer, listenConn, connchan, okchan, c, sessionID, streamID) if err := <-c; err != nil { log.Printf("%s", err) } @@ -913,7 +916,7 @@ func main() { //nolint:cyclop wg1.Add(1) go func() { defer wg1.Done() - oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, okchan, sessionID) + oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, okchan, sessionID, 0) }() wg1.Add(1) @@ -929,10 +932,10 @@ func main() { //nolint:cyclop for i := 0; i < *n-1; i++ { connchan := make(chan net.PacketConn) wg1.Add(1) - go func() { + go func(streamID byte) { defer wg1.Done() - oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil, sessionID) - }() + oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil, sessionID, streamID) + }(byte(i + 1)) wg1.Add(1) go func() { defer wg1.Done() diff --git a/server/main.go b/server/main.go index 5690462..f8d7026 100644 --- a/server/main.go +++ b/server/main.go @@ -11,7 +11,6 @@ import ( "os" "os/signal" "sync" - "sync/atomic" "syscall" "time" @@ -19,11 +18,15 @@ import ( "github.com/pion/dtls/v3/pkg/crypto/selfsign" ) +type streamEntry struct { + id byte + conn net.Conn +} + type UserSession struct { ID string - Conns []net.Conn + Conns []streamEntry BackendConn net.Conn - LastUsed uint32 Lock sync.RWMutex Ctx context.Context Cancel context.CancelFunc @@ -51,12 +54,12 @@ func (s *SessionManager) GetOrCreate(ctx context.Context, id string, connectAddr sessionCtx, cancel := context.WithCancel(ctx) session := &UserSession{ ID: id, + Conns: make([]streamEntry, 0), BackendConn: backendConn, Manager: s, Ctx: sessionCtx, Cancel: cancel, } - s.Sessions[id] = session go session.backendReaderLoop() @@ -66,6 +69,7 @@ func (s *SessionManager) GetOrCreate(ctx context.Context, id string, connectAddr func (s *UserSession) backendReaderLoop() { defer s.Cleanup() buf := make([]byte, 1600) + var lastUsed uint32 = 0 for { select { case <-s.Ctx.Done(): @@ -81,43 +85,54 @@ func (s *UserSession) backendReaderLoop() { } s.Lock.RLock() - if len(s.Conns) == 0 { + nConns := uint32(len(s.Conns)) + if nConns == 0 { s.Lock.RUnlock() continue } - // Round-robin selection of DTLS connection - idx := atomic.AddUint32(&s.LastUsed, 1) % uint32(len(s.Conns)) - conn := s.Conns[idx] + + // Fast Round-robin selection using local variable + lastUsed = (lastUsed + 1) % nConns + conn := s.Conns[lastUsed].conn s.Lock.RUnlock() conn.SetWriteDeadline(time.Now().Add(time.Second * 10)) + _, err = conn.Write(buf[:n]) if err != nil { log.Printf("Session %s DTLS write error: %v", s.ID, err) - // Connection will be removed by its own reader loop + conn.Close() } - } + } } -func (s *UserSession) AddConn(conn net.Conn) { +func (s *UserSession) AddConn(id byte, conn net.Conn) { s.Lock.Lock() defer s.Lock.Unlock() - s.Conns = append(s.Conns, conn) + + // Evict existing connection with same ID + for i, entry := range s.Conns { + if entry.id == id { + //log.Printf("Session %s: Evicting old stream %d", s.ID, id) + entry.conn.Close() + s.Conns[i].conn = conn + return + } + } + + s.Conns = append(s.Conns, streamEntry{id: id, conn: conn}) } -func (s *UserSession) RemoveConn(conn net.Conn) { +func (s *UserSession) RemoveConn(id byte, conn net.Conn) { s.Lock.Lock() defer s.Lock.Unlock() - for i, c := range s.Conns { - if c == conn { + for i, entry := range s.Conns { + if entry.id == id && entry.conn == conn { s.Conns = append(s.Conns[:i], s.Conns[i+1:]...) break } } - // If all connections are gone, we might want to start a timer to cleanup the session - // but for now we'll keep it alive until backendReaderLoop fails or context is cancelled. } - func (s *UserSession) Cleanup() { s.Cancel() s.BackendConn.Close() @@ -127,13 +142,12 @@ func (s *UserSession) Cleanup() { s.Manager.Lock.Unlock() s.Lock.Lock() - for _, c := range s.Conns { - c.Close() + for _, entry := range s.Conns { + entry.conn.Close() } s.Conns = nil s.Lock.Unlock() } - func main() { listen := flag.String("listen", "0.0.0.0:56000", "listen on ip:port") connect := flag.String("connect", "", "connect to ip:port") @@ -213,15 +227,16 @@ func main() { return } - // Phase 1: Read Session ID (16 bytes) - idBuf := make([]byte, 16) + // Phase 1: Read Session ID + Stream ID (17 bytes) + idBuf := make([]byte, 17) conn.SetReadDeadline(time.Now().Add(time.Second * 5)) _, err := io.ReadFull(conn, idBuf) if err != nil { log.Println("Failed to read session ID:", err) return } - sessionID := fmt.Sprintf("%x", idBuf) + sessionID := fmt.Sprintf("%x", idBuf[:16]) + streamID := idBuf[16] session, err := manager.GetOrCreate(ctx, sessionID, *connect) if err != nil { @@ -229,15 +244,15 @@ func main() { return } - session.AddConn(conn) - defer session.RemoveConn(conn) + session.AddConn(streamID, conn) + defer session.RemoveConn(streamID, conn) - log.Printf("New stream for session %s from %s", sessionID, conn.RemoteAddr()) + log.Printf("New stream %d for session %s from %s", streamID, sessionID, conn.RemoteAddr()) // Upstream Loop: DTLS -> Backend buf := make([]byte, 1600) for { - conn.SetReadDeadline(time.Now().Add(time.Minute * 10)) + conn.SetReadDeadline(time.Now().Add(time.Minute * 5)) n, err := conn.Read(buf) if err != nil { log.Printf("Stream %s closed: %v", sessionID, err) From ee1fd9ebf620cc70d0f8439d0544bee5970b199e Mon Sep 17 00:00:00 2001 From: kiper292 Date: Sat, 28 Mar 2026 13:11:43 +0500 Subject: [PATCH 04/11] fix client --- client/main.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/client/main.go b/client/main.go index 9089931..f381cae 100644 --- a/client/main.go +++ b/client/main.go @@ -77,7 +77,7 @@ func getVkCreds(link string) (string, string, string, error) { log.Panicf("get TURN creds error: %v\n\n", resp) } }() - +/* data := "client_secret=QbYic1K3lEV5kTGiqlq2&client_id=6287487&scopes=audio_anonymous%2Cvideo_anonymous%2Cphotos_anonymous%2Cprofile_anonymous&isApiOauthAnonymEnabled=false&version=1&app_id=6287487" url := "https://login.vk.ru/?act=get_anonym_token" @@ -97,11 +97,12 @@ func getVkCreds(link string) (string, string, string, error) { } token2 := resp["response"].(map[string]interface{})["payload"].(string) +*/ + //data = fmt.Sprintf("client_id=6287487&token_type=messages&payload=%s&client_secret=QbYic1K3lEV5kTGiqlq2&version=1&app_id=6287487", token2) + data := fmt.Sprintf("client_id=6287487&token_type=messages&client_secret=QbYic1K3lEV5kTGiqlq2&version=1&app_id=6287487") + url := "https://login.vk.ru/?act=get_anonym_token" - data = fmt.Sprintf("client_id=6287487&token_type=messages&payload=%s&client_secret=QbYic1K3lEV5kTGiqlq2&version=1&app_id=6287487", token2) - url = "https://login.vk.ru/?act=get_anonym_token" - - resp, err = doRequest(data, url) + resp, err := doRequest(data, url) if err != nil { return "", "", "", fmt.Errorf("request error:%s", err) } @@ -109,7 +110,7 @@ func getVkCreds(link string) (string, string, string, error) { token3 := resp["data"].(map[string]interface{})["access_token"].(string) data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=123&access_token=%s", link, token3) - url = "https://api.vk.ru/method/calls.getAnonymousToken?v=5.264" + url = "https://api.vk.ru/method/calls.getAnonymousToken?v=5.274&client_id=6287487" resp, err = doRequest(data, url) if err != nil { @@ -128,7 +129,7 @@ func getVkCreds(link string) (string, string, string, error) { token5 := resp["session_key"].(string) - data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token4, token5) + data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token4, token5) url = "https://calls.okcdn.ru/fb.do" resp, err = doRequest(data, url) From b656d10de8808b75c9239a3ec7ce7afa745a4ebe Mon Sep 17 00:00:00 2001 From: kiper292 Date: Sat, 4 Apr 2026 02:09:54 +0500 Subject: [PATCH 05/11] captcha fix --- client/main.go | 458 ++++++++++++++++++++++++++++++++++--------- client/vk_captcha.go | 325 ++++++++++++++++++++++++++++++ 2 files changed, 687 insertions(+), 96 deletions(-) create mode 100644 client/vk_captcha.go diff --git a/client/main.go b/client/main.go index f381cae..011c396 100644 --- a/client/main.go +++ b/client/main.go @@ -12,8 +12,10 @@ import ( "fmt" "io" "log" + "math/rand" "net" "net/http" + "net/url" "os" "os/signal" "strings" @@ -31,123 +33,378 @@ import ( "github.com/pion/turn/v5" ) -type getCredsFunc func(string) (string, string, string, error) +type getCredsFunc func(context.Context, string, int) (string, string, string, error) -func getVkCreds(link string) (string, string, string, error) { - doRequest := func(data string, url string) (resp map[string]interface{}, err 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.NewRequest("POST", url, bytes.NewBuffer([]byte(data))) - if err != nil { - return nil, err - } +const vkClientID = "6287487" +const vkClientSecret = "QbYic1K3lEV5kTGiqlq2" +const vkAPIVersion = "5.275" - req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0") - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") +// TurnCredentials stores cached TURN credentials +type TurnCredentials struct { + Username string + Password string + ServerAddr string + ExpiresAt time.Time + Link string +} - httpResp, err := client.Do(req) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() +// StreamCredentialsCache holds credentials cache for a single stream +type StreamCredentialsCache struct { + creds TurnCredentials + mutex sync.RWMutex + errorCount atomic.Int32 + lastErrorTime atomic.Int64 +} - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } +const ( + credentialLifetime = 10 * time.Minute + cacheSafetyMargin = 60 * time.Second + maxCacheErrors = 3 + errorWindow = 10 * time.Second + streamsPerCache = 4 // Number of streams sharing one credentials cache +) - err = json.Unmarshal(body, &resp) - if err != nil { - return nil, err - } +// 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() - return resp, nil + if exists { + return cache } - var resp map[string]interface{} - defer func() { - if r := recover(); r != nil { - log.Panicf("get TURN creds error: %v\n\n", resp) - } - }() -/* - data := "client_secret=QbYic1K3lEV5kTGiqlq2&client_id=6287487&scopes=audio_anonymous%2Cvideo_anonymous%2Cphotos_anonymous%2Cprofile_anonymous&isApiOauthAnonymEnabled=false&version=1&app_id=6287487" - url := "https://login.vk.ru/?act=get_anonym_token" + // Need to create new cache + credentialsStore.mu.Lock() + defer credentialsStore.mu.Unlock() - resp, err := doRequest(data, url) - if err != nil { - return "", "", "", fmt.Errorf("request error:%s", err) + // Double-check after acquiring write lock + if cache, exists = credentialsStore.caches[cacheID]; exists { + return cache } - token1 := resp["data"].(map[string]interface{})["access_token"].(string) + cache = &StreamCredentialsCache{} + credentialsStore.caches[cacheID] = cache + return cache +} - data = fmt.Sprintf("access_token=%s", token1) - url = "https://api.vk.ru/method/calls.getAnonymousAccessTokenPayload?v=5.264&client_id=6287487" +// invalidate invalidates the credentials cache for this stream +func (c *StreamCredentialsCache) invalidate(streamID int) { + c.mutex.Lock() + c.creds = TurnCredentials{} + c.mutex.Unlock() - resp, err = doRequest(data, url) - if err != nil { - return "", "", "", fmt.Errorf("request error:%s", err) + // Reset auth error counter + c.errorCount.Store(0) + c.lastErrorTime.Store(0) + + log.Printf("[VK Auth] Credentials cache invalidated for stream %d", streamID) +} + +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) +} - token2 := resp["response"].(map[string]interface{})["payload"].(string) -*/ - //data = fmt.Sprintf("client_id=6287487&token_type=messages&payload=%s&client_secret=QbYic1K3lEV5kTGiqlq2&version=1&app_id=6287487", token2) - data := fmt.Sprintf("client_id=6287487&token_type=messages&client_secret=QbYic1K3lEV5kTGiqlq2&version=1&app_id=6287487") - url := "https://login.vk.ru/?act=get_anonym_token" +// vkCredsMu serializes VK credential fetching to avoid BOT detection from parallel requests +var vkCredsMu sync.Mutex - resp, err := doRequest(data, url) - if err != nil { - return "", "", "", fmt.Errorf("request error:%s", err) +// getVkCredsCached checks cache before fetching credentials +func getVkCredsCached(ctx context.Context, link string, streamID int) (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("[VK Auth] Using cached credentials (cache=%d, expires in %v)", cacheID, expires) + return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil } - token3 := resp["data"].(map[string]interface{})["access_token"].(string) + log.Printf("[VK Auth] Cache miss (cache=%d), starting credential fetch...", cacheID) - data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=123&access_token=%s", link, token3) - url = "https://api.vk.ru/method/calls.getAnonymousToken?v=5.274&client_id=6287487" + // Check context before long fetch + select { + case <-ctx.Done(): + return "", "", "", ctx.Err() + default: + } - resp, err = doRequest(data, url) + // Fetch credentials with mutex to avoid VK flood control + user, pass, addr, err := getVkCredsSafe(ctx, link, streamID) if err != nil { - return "", "", "", fmt.Errorf("request error:%s", err) + return "", "", "", err } - token4 := resp["response"].(map[string]interface{})["token"].(string) + // Store in cache + cache.creds = TurnCredentials{ + Username: user, + Password: pass, + ServerAddr: addr, + ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin), + Link: link, + } + + log.Printf("[VK Auth] Success! Credentials cached until %v (cache=%d)", cache.creds.ExpiresAt, cacheID) + return user, pass, addr, nil +} - data = fmt.Sprintf("%s%s%s", "session_data=%7B%22version%22%3A2%2C%22device_id%22%3A%22", uuid.New(), "%22%2C%22client_version%22%3A1.1%2C%22client_type%22%3A%22SDK_JS%22%7D&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA") - url = "https://calls.okcdn.ru/fb.do" +// getVkCredsSafe wraps getVkCreds with mutex to avoid VK flood control +func getVkCredsSafe(ctx context.Context, link string, streamID int) (string, string, string, error) { + vkCredsMu.Lock() + defer vkCredsMu.Unlock() + return getVkCreds(ctx, link) +} - resp, err = doRequest(data, url) +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 "", "", "", fmt.Errorf("request error:%s", err) + return nil, err } + defer httpResp.Body.Close() - token5 := resp["session_key"].(string) + // 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)])) + } - data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token4, token5) - url = "https://calls.okcdn.ru/fb.do" + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } - resp, err = doRequest(data, url) + // 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("request error:%s", err) + return "", "", "", fmt.Errorf("Token 2 request error: %w", err) } - user := resp["turn_server"].(map[string]interface{})["username"].(string) - pass := resp["turn_server"].(map[string]interface{})["credential"].(string) - turn := resp["turn_server"].(map[string]interface{})["urls"].([]interface{})[0].(string) + // 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) + } - clean := strings.Split(turn, "?")[0] + 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:") - return user, pass, address, nil + 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 } -func getYandexCreds(link string) (string, string, string, error) { +func getYandexCreds(ctx context.Context, link string, streamID int) (string, string, string, error) { const debug = false const telemostConfHost = "cloud-api.yandex.ru" telemostConfPath := fmt.Sprintf("%s%s%s", "/telemost_front/v2/telemost/conferences/https%3A%2F%2Ftelemost.yandex.ru%2Fj%2F", link, "/connection?next_gen_media_platform_allowed=false") @@ -441,7 +698,8 @@ func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net. CipherSuites: []dtls.CipherSuiteID{dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256}, ConnectionIDGenerator: dtls.OnlySendCIDGenerator(), } - ctx1, cancel := context.WithTimeout(ctx, 30*time.Second) + // Extended timeout to accommodate serialized credential fetching via mutex + ctx1, cancel := context.WithTimeout(ctx, 120*time.Second) defer cancel() dtlsConn, err := dtls.Client(conn, peer, config) if err != nil { @@ -586,13 +844,14 @@ type turnParams struct { port string link string udp bool + streamID int getCreds getCredsFunc } func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, conn2 net.PacketConn, c chan<- error) { var err error = nil defer func() { c <- err }() - user, pass, url, err1 := turnParams.getCreds(turnParams.link) + user, pass, url, err1 := turnParams.getCreds(ctx, turnParams.link, turnParams.streamID) if err1 != nil { err = fmt.Errorf("failed to get TURN credentials: %s", err1) return @@ -785,7 +1044,11 @@ func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnCha } } -func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time) { +func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *net.UDPAddr, connchan <-chan net.PacketConn, t <-chan time.Time, streamID int) { + // Create a copy of turnParams with the streamID + tp := *turnParams + tp.streamID = streamID + for { select { case <-ctx.Done(): @@ -794,7 +1057,7 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne select { case <-t: c := make(chan error) - go oneTurnConnection(ctx, turnParams, peer, conn2, c) + go oneTurnConnection(ctx, &tp, peer, conn2, c) if err := <-c; err != nil { log.Printf("%s", err) } @@ -846,9 +1109,9 @@ func main() { //nolint:cyclop if *vklink != "" { parts := strings.Split(*vklink, "join/") link = parts[len(parts)-1] - getCreds = getVkCreds + getCreds = getVkCredsCached if *n <= 0 { - *n = 16 + *n = 4 } } else { parts := strings.Split(*yalink, "j/") @@ -862,11 +1125,12 @@ func main() { //nolint:cyclop link = link[:idx] } params := &turnParams{ - *host, - *port, - link, - *udp, - getCreds, + host: *host, + port: *port, + link: link, + udp: *udp, + streamID: 0, + getCreds: getCreds, } var sessionID []byte @@ -905,9 +1169,10 @@ func main() { //nolint:cyclop if *direct { for i := 0; i < *n; i++ { wg1.Add(1) + streamID := i go func() { defer wg1.Done() - oneTurnConnectionLoop(ctx, params, peer, listenConnChan, t) + oneTurnConnectionLoop(ctx, params, peer, listenConnChan, t, streamID) }() } } else { @@ -923,7 +1188,7 @@ func main() { //nolint:cyclop wg1.Add(1) go func() { defer wg1.Done() - oneTurnConnectionLoop(ctx, params, peer, connchan, t) + oneTurnConnectionLoop(ctx, params, peer, connchan, t, 0) }() select { @@ -932,15 +1197,16 @@ func main() { //nolint:cyclop } for i := 0; i < *n-1; i++ { connchan := make(chan net.PacketConn) + streamID := i + 1 wg1.Add(1) - go func(streamID byte) { + go func(sID byte) { defer wg1.Done() - oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil, sessionID, streamID) - }(byte(i + 1)) + oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil, sessionID, sID) + }(byte(streamID)) wg1.Add(1) go func() { defer wg1.Done() - oneTurnConnectionLoop(ctx, params, peer, connchan, t) + oneTurnConnectionLoop(ctx, params, peer, connchan, t, streamID) }() } } diff --git a/client/vk_captcha.go b/client/vk_captcha.go new file mode 100644 index 0000000..8e3a348 --- /dev/null +++ b/client/vk_captcha.go @@ -0,0 +1,325 @@ +// SPDX-FileCopyrightText: 2023 The Pion community +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + vkCaptchaAPIVersion = "5.275" + vkCaptchaNotRobotVer = "5.131" + vkDebugInfo = "d44f534ce8deb56ba20be52e05c433309b49ee4d2a70602deeb17a1954257785" +) + +// VkCaptchaData holds captcha challenge data from VK error response +type VkCaptchaData struct { + CaptchaSid string + CaptchaTs string + CaptchaAttempt string + RedirectURI string + SessionToken string +} + +// ExtractCaptchaData parses VK API error response for captcha info +func ExtractCaptchaData(errResp map[string]interface{}) (*VkCaptchaData, bool) { + code, _ := errResp["error_code"].(float64) + if int(code) != 14 { + return nil, false + } + + redirectURI, _ := errResp["redirect_uri"].(string) + if redirectURI == "" { + return nil, false + } + + // Parse session_token from redirect_uri + parsed, err := url.Parse(redirectURI) + if err != nil { + return nil, false + } + sessionToken := parsed.Query().Get("session_token") + if sessionToken == "" { + return nil, false + } + + // Extract captcha_sid + captchaSid, _ := errResp["captcha_sid"].(string) + + // captcha_ts can be float64 or string + var captchaTs string + if tsFloat, ok := errResp["captcha_ts"].(float64); ok { + captchaTs = fmt.Sprintf("%.0f", tsFloat) + } else if tsStr, ok := errResp["captcha_ts"].(string); ok { + captchaTs = tsStr + } + + // captcha_attempt + var captchaAttempt string + if attFloat, ok := errResp["captcha_attempt"].(float64); ok { + captchaAttempt = fmt.Sprintf("%.0f", attFloat) + } else if attStr, ok := errResp["captcha_attempt"].(string); ok { + captchaAttempt = attStr + } + + return &VkCaptchaData{ + CaptchaSid: captchaSid, + CaptchaTs: captchaTs, + CaptchaAttempt: captchaAttempt, + RedirectURI: redirectURI, + SessionToken: sessionToken, + }, true +} + +// SolveVkCaptcha fetches captcha page, solves PoW, and calls captchaNotRobot API +func SolveVkCaptcha(ctx context.Context, captchaData *VkCaptchaData) (string, error) { + log.Printf("[Captcha] Solving Not Robot Captcha...") + + // HAR: Token 2 error → Captcha HTML = 2.72s (browser page load + user perception) + time.Sleep(1500*time.Millisecond + time.Duration(rand.Intn(1000))*time.Millisecond) + + // Fetch captcha HTML (browser redirect) + powInput, difficulty, cookies, err := fetchCaptchaPowInput(ctx, captchaData.RedirectURI) + if err != nil { + return "", fmt.Errorf("failed to fetch powInput: %w", err) + } + log.Printf("[Captcha] PoW input: %s, difficulty: %d", powInput, difficulty) + + // Solve PoW + hash := solveCaptchaPoW(powInput, difficulty) + log.Printf("[Captcha] PoW solved: hash=%s", hash) + + // Call captchaNotRobot API with cookies from captcha page + successToken, err := callCaptchaNotRobotAPI(ctx, captchaData.SessionToken, hash, cookies) + if err != nil { + return "", fmt.Errorf("captchaNotRobot API failed: %w", err) + } + + log.Printf("[Captcha] Success! Got success_token") + return successToken, nil +} + +func fetchCaptchaPowInput(ctx context.Context, redirectURI string) (string, int, string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", redirectURI, nil) + if err != nil { + return "", 0, "", err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + + client := &http.Client{ + Timeout: 20 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + resp, err := client.Do(req) + if err != nil { + return "", 0, "", err + } + defer resp.Body.Close() + + // Capture Set-Cookie headers + var cookieValues []string + for _, setCookie := range resp.Header.Values("Set-Cookie") { + // Extract just the cookie name=value part (before ; expires= or ; path=) + cookieParts := strings.Split(setCookie, ";") + cookieValues = append(cookieValues, strings.TrimSpace(cookieParts[0])) + } + cookies := strings.Join(cookieValues, "; ") + if cookies != "" { + log.Printf("[Captcha] Captcha page set %d cookie(s)", len(cookieValues)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", 0, "", err + } + + html := string(body) + + // Extract powInput: const powInput = "..." + powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) + powInputMatch := powInputRe.FindStringSubmatch(html) + if len(powInputMatch) < 2 { + return "", 0, "", fmt.Errorf("powInput not found in captcha HTML") + } + powInput := powInputMatch[1] + + // Extract difficulty: '0'.repeat(N) + diffRe := regexp.MustCompile(`startsWith\('0'\.repeat\((\d+)\)\)`) + diffMatch := diffRe.FindStringSubmatch(html) + difficulty := 2 // default + if len(diffMatch) >= 2 { + if d, err := strconv.Atoi(diffMatch[1]); err == nil { + difficulty = d + } + } + + return powInput, difficulty, cookies, nil +} + +func solveCaptchaPoW(powInput string, difficulty int) string { + target := strings.Repeat("0", difficulty) + for nonce := 1; nonce <= 10000000; nonce++ { + data := powInput + strconv.Itoa(nonce) + hash := sha256.Sum256([]byte(data)) + hexHash := hex.EncodeToString(hash[:]) + if strings.HasPrefix(hexHash, target) { + return hexHash + } + } + return "" +} + +func callCaptchaNotRobotAPI(ctx context.Context, sessionToken, hash, cookies string) (string, error) { + vkReq := func(method string, postData string) (map[string]interface{}, error) { + requestURL := fmt.Sprintf("https://api.vk.ru/method/%s?v=%s", method, vkCaptchaNotRobotVer) + + req, err := http.NewRequestWithContext(ctx, "POST", requestURL, strings.NewReader(postData)) + 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://id.vk.ru") + req.Header.Set("Referer", "https://id.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") + // Add cookies captured from captcha page + if cookies != "" { + req.Header.Set("Cookie", cookies) + } + + client := &http.Client{ + Timeout: 20 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + + 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, err + } + return result, nil + } + + domain := "vk.com" + baseParams := fmt.Sprintf("session_token=%s&domain=%s&adFp=&access_token=", + url.QueryEscape(sessionToken), url.QueryEscape(domain)) + + // Step 1: settings + log.Printf("[Captcha] Step 1/4: settings") + _, err := vkReq("captchaNotRobot.settings", baseParams) + if err != nil { + return "", fmt.Errorf("settings failed: %w", err) + } + // HAR: settings → componentDone = 0.19s + time.Sleep(100*time.Millisecond + time.Duration(rand.Intn(100))*time.Millisecond) + + // Step 2: componentDone + log.Printf("[Captcha] Step 2/4: componentDone") + browserFp := fmt.Sprintf("%032x", uint64(time.Now().UnixNano())) + deviceJSON := `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1920,"innerHeight":945,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":16,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"denied"}` + componentData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, url.QueryEscape(deviceJSON)) + _, err = vkReq("captchaNotRobot.componentDone", componentData) + if err != nil { + return "", fmt.Errorf("componentDone failed: %w", err) + } + // HAR: componentDone → check ≈ 1.95s + statEvents delay ≈ 3.2s total + time.Sleep(1500*time.Millisecond + time.Duration(rand.Intn(1000))*time.Millisecond) + + // Step 3: check + log.Printf("[Captcha] Step 3/4: check") + cursorJSON := `[{"x":950,"y":500},{"x":945,"y":510},{"x":940,"y":520},{"x":938,"y":525},{"x":938,"y":525}]` + answer := base64.StdEncoding.EncodeToString([]byte("{}")) // e30= + + // Generate random connectionDownlink values (simulating Network Information API) + // HAR shows browser repeats the same value 7 times: [9.8,9.8,9.8,9.8,9.8,9.8,9.8] + baseDownlink := 8.0 + rand.Float64()*4.0 // Random in [8.0, 12.0) for typical WiFi + downlinkStr := fmt.Sprintf("%.1f", baseDownlink) + connectionDownlink := "[" + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "]" + + checkData := baseParams + fmt.Sprintf( + "&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s"+ + "&browser_fp=%s&hash=%s&answer=%s&debug_info=%s", + url.QueryEscape("[]"), // accelerometer + url.QueryEscape("[]"), // gyroscope + url.QueryEscape("[]"), // motion + url.QueryEscape(cursorJSON), // cursor + url.QueryEscape("[]"), // taps + url.QueryEscape("[]"), // connectionRtt + url.QueryEscape(connectionDownlink), + browserFp, // browser_fp + hash, // hash (PoW result) + answer, // answer + vkDebugInfo, // debug_info (static) + ) + + checkResp, err := vkReq("captchaNotRobot.check", checkData) + if err != nil { + return "", fmt.Errorf("check request failed: %w", err) + } + + respObj, ok := checkResp["response"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("invalid check response: %v", checkResp) + } + + status, _ := respObj["status"].(string) + if status != "OK" { + return "", fmt.Errorf("check response status: %s, full response: %v", status, checkResp) + } + + successToken, ok := respObj["success_token"].(string) + if !ok || successToken == "" { + return "", fmt.Errorf("success_token not found in check response: %v", checkResp) + } + // HAR: check → endSession = 0.48s + time.Sleep(200*time.Millisecond + time.Duration(rand.Intn(300))*time.Millisecond) + + // Step 4: endSession + log.Printf("[Captcha] Step 4/4: endSession") + vkReq("captchaNotRobot.endSession", baseParams) + + return successToken, nil +} From fc5edf434a78da90271a65e84752cb27212040ac Mon Sep 17 00:00:00 2001 From: kiper292 Date: Sat, 4 Apr 2026 02:16:02 +0500 Subject: [PATCH 06/11] add mipsle arch --- .github/workflows/ci.yml | 4 ++++ .github/workflows/release.yml | 29 +++++++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e4d7de..ecd517f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,10 @@ jobs: goarch: arm64 cgo: 1 api: 21 + - goos: android + goarch: arm + cgo: 1 + api: 21 # you can add more (android/arm, windows, etc.) later steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4247cd..306a2c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,21 @@ jobs: goarch: arm goarm: '7' cgo: 0 + - goos: linux + goarch: mipsle + gomips: softfloat + cgo: 0 + - goos: linux + goarch: mips + gomips: softfloat + cgo: 0 + - goos: linux + goarch: mips64le + gomips: softfloat + cgo: 0 + - goos: linux + goarch: riscv64 + cgo: 0 - goos: darwin goarch: amd64 cgo: 0 @@ -36,6 +51,9 @@ jobs: - goos: windows goarch: amd64 cgo: 0 + - goos: windows + goarch: arm64 + cgo: 0 - goos: windows goarch: 386 cgo: 0 @@ -43,6 +61,10 @@ jobs: goarch: arm64 cgo: 1 api: 21 + - goos: android + goarch: arm + cgo: 1 + api: 21 - goos: freebsd goarch: amd64 cgo: 0 @@ -69,6 +91,7 @@ jobs: env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} + GOMIPS: ${{ matrix.gomips }} GOARM: ${{ matrix.goarm }} CGO_ENABLED: ${{ matrix.cgo }} ANDROID_API: ${{ matrix.api }} @@ -87,6 +110,8 @@ jobs: if [ "$GOARCH" = "arm64" ]; then export CC="$TOOLCHAIN_BIN/aarch64-linux-android${ANDROID_API}-clang" + elif [ "$GOARCH" = "arm" ]; then + export CC="$TOOLCHAIN_BIN/armv7a-linux-androideabi${ANDROID_API}-clang" else echo "Unsupported ANDROID GOARCH=$GOARCH" exit 1 @@ -104,12 +129,12 @@ jobs: EXT= fi - GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM \ + GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM GOMIPS=$GOMIPS \ go build -ldflags "-s -w -checklinkname=0" -trimpath \ -o "dist/client-${GOOS}-${GOARCH}${EXT}" \ ./client - GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM \ + GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM GOMIPS=$GOMIPS \ go build -ldflags "-s -w -checklinkname=0" -trimpath \ -o "dist/server-${GOOS}-${GOARCH}${EXT}" \ ./server From b01249e8be0a7bf7ad12bd490c5110752ee00162 Mon Sep 17 00:00:00 2001 From: kiper292 Date: Sat, 4 Apr 2026 03:06:08 +0500 Subject: [PATCH 07/11] wb mode --- README.en.md | 210 ++++++++++++- README.md | 113 ++++--- client/credentials.go | 151 +++++++++ client/main.go | 691 ++---------------------------------------- client/vk.go | 258 ++++++++++++++++ client/wb.go | 358 ++++++++++++++++++++++ 6 files changed, 1056 insertions(+), 725 deletions(-) create mode 100644 client/credentials.go create mode 100644 client/vk.go create mode 100644 client/wb.go diff --git a/README.en.md b/README.en.md index c303beb..fc6fa03 100644 --- a/README.en.md +++ b/README.en.md @@ -1,7 +1,15 @@ -# Good TURN +# VK/WB TURN Proxy [Russian version](README.md) -Tunnels WireGuard/Hysteria traffic through VK Calls or Yandex Telemost TURN servers. Packets are encrypted with DTLS 1.2 and then sent in parallel streams via TCP or UDP to the TURN server using the STUN ChannelData protocol. From there, they are forwarded via UDP to your server, decrypted, and passed to WireGuard. TURN credentials are generated from the meeting link. +Tunnels WireGuard/Hysteria traffic through VK Calls or WB Stream TURN servers. Packets are encrypted with DTLS 1.2 and then sent in parallel streams via TCP or UDP to the TURN server using the STUN ChannelData protocol. From there, they are forwarded via UDP to your server, decrypted, and passed to WireGuard. TURN credentials are generated from the meeting link. + +## Features + +- **VK Calls** — TURN credentials from VK API with automatic captcha solving (Not Robot) +- **WB Stream** — TURN credentials from WB Stream API (LiveKit ICE) +- **Caching** — 10 minute TTL with shared cache across 4 streams +- **DTLS obfuscation** — DPI bypass via DTLS tunnel +- **Multiple connections** — up to N parallel connections to TURN **Update: Multi-user Proxy Server** The current implementation supports multiple simultaneous users through a single proxy server. @@ -12,19 +20,32 @@ The current implementation supports multiple simultaneous users through a single For educational purposes only! +## Client Structure + +``` +client/ +├── credentials.go # Shared credentials cache, request serialization +├── vk.go # VK API: Token 1→4 chain, HTTP requests +├── vk_captcha.go # VK Captcha: PoW solving, Not Robot flow +├── wb.go # WB Stream: guest register → room → LiveKit ICE +└── main.go # DTLS/TURN connections, CLI, main loop +``` + ## Setup + You will need: 1. A link to an active VK call: create your own (requires a VK account) or search for `"https://vk.com/call/join/"`. Links are valid forever unless "end call for all" is clicked. -2. Or a link to a Yandex Telemost call: `"https://telemost.yandex.ru/j/"`. Better not to search for these as conference participants are visible. -3. A VPS with WireGuard installed. -4. For Android: Download Termux from F-Droid. +2. A VPS with WireGuard installed. +3. For Android: Download Termux from F-Droid. ### Server + ```bash ./server -listen 0.0.0.0:56000 -connect 127.0.0.1: ``` ### Client + #### Android **Recommended method:** @@ -47,42 +68,201 @@ Copy the binary to a local folder and grant execution rights: cp /sdcard/Download/client-android ./ chmod 777 ./client-android ``` -Run: + +**VK mode:** ```bash ./client-android -peer :56000 -vk-link -listen 127.0.0.1:9000 ``` -Additional flags: -- `-session-id `: set a fixed session ID (32 hex characters). -Or: +**WB mode:** ```bash -./client-android -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000 +./client-android -wb -peer :56000 -listen 127.0.0.1:9000 ``` +Additional flags: +- `-session-id `: set a fixed session ID (32 hex characters). +- `-n `: number of connections to TURN (default 4). +- `-udp`: use UDP for TURN (default TCP). +- `-turn `: override TURN server address. +- `-port `: override TURN server port. +- `-no-dtls`: without DTLS obfuscation (may result in a ban). + #### Linux + In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280. The script will add routes to the necessary IPs: + ```bash ./client-linux -peer :56000 -vk-link -listen 127.0.0.1:9000 | sudo routes.sh ``` +```bash +./client-linux -wb -peer :56000 -listen 127.0.0.1:9000 | sudo routes.sh +``` + +⚠️ Do not enable the VPN until the program has established a connection! Unlike Android, some requests will go through the VPN here (DNS and TURN connection requests). + #### Windows + In the WireGuard client config, change the server address to `127.0.0.1:9000` and set MTU to 1280. In PowerShell as Administrator (so the script can add routes): + ```powershell ./client.exe -peer :56000 -vk-link -listen 127.0.0.1:9000 | routes.ps1 ``` +```powershell +./client.exe -wb -peer :56000 -listen 127.0.0.1:9000 | routes.ps1 +``` + +⚠️ Do not enable the VPN until the program has established a connection! Unlike Android, some requests will go through the VPN here (DNS and TURN connection requests). + ### If it doesn't work -Use the `-turn` option to manually specify a TURN server address. + +Use the `-turn` option to manually specify a TURN server address. This should be a VK, Max, or Odnoklassniki server (VK link) or WB Stream (WB mode). + If TCP doesn't work, try adding the `-udp` flag. + Add `-n 1` for a more stable single-stream connection (limited to 5 Mbps for VK). -## Yandex Telemost -**UPD. TELEMOST IS CLOSED** -Unlike VK, Yandex servers do not limit speed, so the default is `-n 1`. +## VK Auth Flow + +1. **Token 1** — anonymous token (`login.vk.ru`) +2. **getCallPreview** — call preview (optional) +3. **Token 2** — anonymous token for the call (`api.vk.ru`) + - On captcha → PoW solving → retry +4. **Token 3** — OK session key (`calls.okcdn.ru`) +5. **Token 4** — TURN credentials (`calls.okcdn.ru`) + +## WB Auth Flow + +1. **Guest register** — guest registration (`stream.wb.ru`) +2. **Create room** — create a room +3. **Join room** — join the room +4. **Get token** — get roomToken +5. **LiveKit ICE** — WebSocket to LiveKit, protobuf TURN parsing + +## Caching + +- TTL: **10 minutes** (safety margin 60 seconds) +- One cache per **4 streams** (`streamID / 4`) +- Fast path via `RLock` +- Fetch serialization via global `fetchMu` + +## v2ray + +Instead of WireGuard, you can use any V2Ray core that supports it (e.g., xray or sing-box) and any V2Ray client that uses this core (e.g., v2rayN or v2rayNG). This allows you to add more inbound interfaces (e.g., SOCKS) and implement fine-grained routing. + +Example configs: + +
+ + +Client + + +```json +{ + "inbounds": [ + { + "protocol": "socks", + "listen": "127.0.0.1", + "port": 1080, + "settings": { + "udp": true + }, + "sniffing": { + "enabled": true, + "destOverride": [ + "http", + "tls" + ] + } + }, + { + "protocol": "http", + "listen": "127.0.0.1", + "port": 8080, + "sniffing": { + "enabled": true, + "destOverride": [ + "http", + "tls" + ] + } + } + ], + "outbounds": [ + { + "protocol": "wireguard", + "settings": { + "secretKey": "", + "peers": [ + { + "endpoint": "127.0.0.1:9000", + "publicKey": "" + } + ], + "domainStrategy": "ForceIPv4", + "mtu": 1280 + } + } + ] +} +``` + +
+ +
+ + +Server + + +```json +{ + "inbounds": [ + { + "protocol": "wireguard", + "listen": "0.0.0.0", + "port": 51820, + "settings": { + "secretKey": "", + "peers": [ + { + "publicKey": "" + } + ], + "mtu": 1280 + }, + "sniffing": { + "enabled": true, + "destOverride": [ + "http", + "tls" + ] + } + } + ], + "outbounds": [ + { + "protocol": "freedom", + "settings": { + "domainStrategy": "UseIPv4" + } + } + ] +} +``` + +
## Direct mode -With the `-no-dtls` flag, you can send packets without DTLS obfuscation and connect to regular WireGuard servers. This may result in a ban from VK/Yandex. + +With the `-no-dtls` flag, you can send packets without DTLS obfuscation and connect to regular WireGuard servers. This may result in a ban from VK/WB. + +Thanks to https://github.com/KillTheCensorship/Turnel for part of the code :) + +WB Stream functionality is based on https://github.com/jaykaiperson/lionheart diff --git a/README.md b/README.md index cad2029..f1aeca5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,15 @@ -# Good TURN +# VK/WB TURN Proxy [English version](README.en.md) -Проброс трафика WireGuard/Hysteria через TURN сервера VK звонков или Яндекс телемоста. Пакеты шифруются DTLS 1.2, затем параллельными потоками через TCP или UDP отправляются на TURN сервер по протоколу STUN ChannelData. Оттуда по UDP отправляются на ваш сервер, где расшифровываются и передаются в WireGuard. Логин/пароль от TURN генерируются из ссылки на звонок. +Проброс трафика WireGuard/Hysteria через TURN сервера VK звонков или WB Stream. Пакеты шифруются DTLS 1.2, затем параллельными потоками через TCP или UDP отправляются на TURN сервер по протоколу STUN ChannelData. Оттуда по UDP отправляются на ваш сервер, где расшифровываются и передаются в WireGuard. Логин/пароль от TURN генерируются из ссылки на звонок. + +## Возможности + +- **VK Calls** — TURN credentials от VK API с автоматическим решением капчи (Not Robot) +- **WB Stream** — TURN credentials от WB Stream API (LiveKit ICE) +- **Кеширование** — 10 минут с общим кешем на 4 потока +- **DTLS-обфускация** — обход DPI через DTLS-туннель +- **Множественные соединения** — до N параллельных подключений к TURN **Обновление: Многопользовательский прокси-сервер** Текущая реализация поддерживает одновременную работу нескольких пользователей через один прокси-сервер. @@ -11,18 +19,34 @@ - **Балансировка:** Исходящий трафик от сервера к клиенту распределяется между всеми активными DTLS-потоками пользователя (Round-Robin). Только для учебных целей! + +## Структура клиента + +``` +client/ +├── credentials.go # Общий кеш credentials, сериализация запросов +├── vk.go # VK API: Token 1→4 chain, HTTP-запросы +├── vk_captcha.go # VK Captcha: PoW solving, Not Robot flow +├── wb.go # WB Stream: guest register → room → LiveKit ICE +└── main.go # DTLS/TURN соединения, CLI, основной цикл +``` + ## Настройка + Нам понадобится: 1. Ссылка на действующий ВК звонок: создаём свой (нужен аккаунт вк), или гуглим `"https://vk.com/call/join/"`. -Ссылка действительна вечно, если не нажимать "завершить звонок для всех" -2. Или ссыска на звонок Яндекс телемоста: `"https://telemost.yandex.ru/j/"`. Её лучше не гуглить, так как видно подключение к конференции -3. VPS с установленным WireGuard -4. Для андроида: скачать Termux из F-Droid + Ссылка действительна вечно, если не нажимать "завершить звонок для всех" +2. VPS с установленным WireGuard +3. Для андроида: скачать Termux из F-Droid + ### Сервер + ``` ./server -listen 0.0.0.0:56000 -connect 127.0.0.1:<порт wg> ``` + ### Клиент + #### Android **Рекомендуемый способ:** @@ -30,7 +54,8 @@ **Альтернативный способ (через Termux):** - В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280 -- **Добавляем Termux в исключения WireGuard. Нажимаем "сохранить".** +- **Добавляем Termux в исключения WireGuard. Нажимаем "сохранить".** + В Termux: ``` termux-wake-lock @@ -44,20 +69,27 @@ termux-wake-unlock cp /sdcard/Download/client-android ./ chmod 777 ./client-android ``` -Запускаем: + +**VK режим:** ``` ./client-android -peer :56000 -vk-link -listen 127.0.0.1:9000 ``` -Дополнительные флаги: -- `-session-id `: установить фиксированный ID сессии (32 символа hex). -Или +**WB режим:** ``` -./client-android -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000 +./client-android -wb -peer :56000 -listen 127.0.0.1:9000 ``` -**Если после включения VPN в терминале вылезают ошибки DNS, попробуйте в Wireguard включить VPN только для нужных приложений.** +Дополнительные флаги: +- `-session-id `: установить фиксированный ID сессии (32 символа hex). +- `-n <число>`: количество подключений к TURN (по умолчанию 4). +- `-udp`: использовать UDP для TURN (по умолчанию TCP). +- `-turn `: переопределить адрес TURN сервера. +- `-port `: переопределить порт TURN сервера. +- `-no-dtls`: без DTLS-обфускации (может привести к бану). + #### Linux + В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280 Скрипт будет добавлять маршруты к нужным ip: @@ -67,11 +99,13 @@ chmod 777 ./client-android ``` ``` -./client-linux -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000 | sudo routes.sh +./client-linux -wb -peer :56000 -listen 127.0.0.1:9000 | sudo routes.sh ``` Не включайте впн, пока программа не установит соединение! В отличие от андроида, здесь часть запросов будет идти через впн (dns и запрос подключения к turn) + #### Windows + В клиентском конфиге WireGuard меняем адрес сервера на `127.0.0.1:9000`, ставим MTU 1280 В PowerShell от Администратора (чтобы скрипт прописывал маршруты): @@ -81,40 +115,42 @@ chmod 777 ./client-android ``` ``` -./client.exe -udp -turn 5.255.211.241 -peer :56000 -yandex-link -listen 127.0.0.1:9000 | routes.ps1 +./client.exe -wb -peer :56000 -listen 127.0.0.1:9000 | routes.ps1 ``` -Не включайте впн, пока программа не установит соединение! В отличие от андроида, здесь часть запросов будет идти через впн (dns и запрос подключения к turn) +Не включайте впн, пока программа не установит соединение! В отличие от андродида, здесь часть запросов будет идти через впн (dns и запрос подключения к turn) + ### Если не работает -С помощью опции `-turn` можно указать адрес TURN сервера вручную. Это должен быть сервер ВК, Макса или Одноклассников (ссылка вк) или Яндекса (ссылка яндекса). Возможно потом составлю список. + +С помощью опции `-turn` можно указать адрес TURN сервера вручную. Это должен быть сервер ВК, Макса или Одноклассников (ссылка вк) или WB Stream (режим -wb). Если не работает TCP, попробуйте добавить флаг `-udp`. Добавьте флаг `-n 1` для более стабильного подключения в 1 поток (ограничение 5 Мбит/с для ВК) -## Яндекс телемост -**UPD. ТЕЛЕМОСТ ЗАКРЫЛИ** +## VK Auth Flow -В отличие от ВК, сервера яндекса не ограничивают скорость, так что по умолчанию стоит `-n 1`. Увеличение этого числа может привести к временной блокировке по IP из-за переполнения конференции фейковыми участниками. +1. **Token 1** — анонимный токен (`login.vk.ru`) +2. **getCallPreview** — превью звонка (опционально) +3. **Token 2** — анонимный токен для звонка (`api.vk.ru`) + - При капче → PoW solving → retry +4. **Token 3** — OK session key (`calls.okcdn.ru`) +5. **Token 4** — TURN credentials (`calls.okcdn.ru`) -В режиме `-udp` скорость обычно больше +## WB Auth Flow -Большинство диапазонов IP TURN серверов Яндекса не работают, указывайте вручную через `-turn` -
- - Рабочие IP - +1. **Guest register** — регистрация гостя (`stream.wb.ru`) +2. **Create room** — создание комнаты +3. **Join room** — подключение к комнате +4. **Get token** — получение roomToken +5. **LiveKit ICE** — WebSocket к LiveKit, protobuf парсинг TURN +## Кеширование - 5.255.211.241 - 5.255.211.242 - 5.255.211.243 - 5.255.211.245 - 5.255.211.246 - - -
-Спасибо https://github.com/KillTheCensorship/Turnel за часть кода :) +- TTL: **10 минут** (safety margin 60 секунд) +- Один кеш на **4 потока** (`streamID / 4`) +- Fast path через `RLock` +- Сериализация fetch через глобальный `fetchMu` ## v2ray @@ -225,4 +261,9 @@ chmod 777 ./client-android ## Direct mode -С флагом `-no-dtls` можно отправлять пакеты без обфускации DTLS и подключаться к обычным серверам Wireguard. Может привести к бану от вк/яндекса. + +С флагом `-no-dtls` можно отправлять пакеты без обфускации DTLS и подключаться к обычным серверам Wireguard. Может привести к бану от ВК/WB. + +Спасибо https://github.com/KillTheCensorship/Turnel за часть кода :) + +Функционал WB Stream основан на проекте https://github.com/jaykaiperson/lionheart diff --git a/client/credentials.go b/client/credentials.go new file mode 100644 index 0000000..9b0a2e2 --- /dev/null +++ b/client/credentials.go @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2023 The Pion community +// 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 +} diff --git a/client/main.go b/client/main.go index 011c396..3e19922 100644 --- a/client/main.go +++ b/client/main.go @@ -4,18 +4,12 @@ package main import ( - "bytes" "context" "crypto/tls" - "encoding/json" "flag" "fmt" - "io" "log" - "math/rand" "net" - "net/http" - "net/url" "os" "os/signal" "strings" @@ -26,666 +20,12 @@ import ( "github.com/cbeuw/connutil" "github.com/google/uuid" - "github.com/gorilla/websocket" "github.com/pion/dtls/v3" "github.com/pion/dtls/v3/pkg/crypto/selfsign" "github.com/pion/logging" "github.com/pion/turn/v5" ) -type getCredsFunc func(context.Context, string, int) (string, string, string, error) - -const vkClientID = "6287487" -const vkClientSecret = "QbYic1K3lEV5kTGiqlq2" -const vkAPIVersion = "5.275" - -// 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("[VK Auth] Credentials cache invalidated for stream %d", streamID) -} - -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) -} - -// vkCredsMu serializes VK credential fetching to avoid BOT detection from parallel requests -var vkCredsMu sync.Mutex - -// getVkCredsCached checks cache before fetching credentials -func getVkCredsCached(ctx context.Context, link string, streamID int) (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("[VK Auth] Using cached credentials (cache=%d, expires in %v)", cacheID, expires) - return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil - } - - log.Printf("[VK 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 mutex to avoid VK flood control - user, pass, addr, err := getVkCredsSafe(ctx, link, streamID) - 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("[VK Auth] Success! Credentials cached until %v (cache=%d)", cache.creds.ExpiresAt, cacheID) - return user, pass, addr, nil -} - -// getVkCredsSafe wraps getVkCreds with mutex to avoid VK flood control -func getVkCredsSafe(ctx context.Context, link string, streamID int) (string, string, string, error) { - vkCredsMu.Lock() - defer vkCredsMu.Unlock() - return getVkCreds(ctx, link) -} - -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 -} - -func getYandexCreds(ctx context.Context, link string, streamID int) (string, string, string, error) { - const debug = false - const telemostConfHost = "cloud-api.yandex.ru" - telemostConfPath := fmt.Sprintf("%s%s%s", "/telemost_front/v2/telemost/conferences/https%3A%2F%2Ftelemost.yandex.ru%2Fj%2F", link, "/connection?next_gen_media_platform_allowed=false") - const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0" - - type ConferenceResponse struct { - URI string `json:"uri"` - RoomID string `json:"room_id"` - PeerID string `json:"peer_id"` - ClientConfiguration struct { - MediaServerURL string `json:"media_server_url"` - } `json:"client_configuration"` - Credentials string `json:"credentials"` - } - - type PartMeta struct { - Name string `json:"name"` - Role string `json:"role"` - Description string `json:"description"` - SendAudio bool `json:"sendAudio"` - SendVideo bool `json:"sendVideo"` - } - - type PartAttrs struct { - Name string `json:"name"` - Role string `json:"role"` - Description string `json:"description"` - } - - type SdkInfo struct { - Implementation string `json:"implementation"` - Version string `json:"version"` - UserAgent string `json:"userAgent"` - HwConcurrency int `json:"hwConcurrency"` - } - - type Capabilities struct { - OfferAnswerMode []string `json:"offerAnswerMode"` - InitialSubscriberOffer []string `json:"initialSubscriberOffer"` - SlotsMode []string `json:"slotsMode"` - SimulcastMode []string `json:"simulcastMode"` - SelfVadStatus []string `json:"selfVadStatus"` - DataChannelSharing []string `json:"dataChannelSharing"` - VideoEncoderConfig []string `json:"videoEncoderConfig"` - DataChannelVideoCodec []string `json:"dataChannelVideoCodec"` - BandwidthLimitationReason []string `json:"bandwidthLimitationReason"` - SdkDefaultDeviceManagement []string `json:"sdkDefaultDeviceManagement"` - JoinOrderLayout []string `json:"joinOrderLayout"` - PinLayout []string `json:"pinLayout"` - SendSelfViewVideoSlot []string `json:"sendSelfViewVideoSlot"` - ServerLayoutTransition []string `json:"serverLayoutTransition"` - SdkPublisherOptimizeBitrate []string `json:"sdkPublisherOptimizeBitrate"` - SdkNetworkLostDetection []string `json:"sdkNetworkLostDetection"` - SdkNetworkPathMonitor []string `json:"sdkNetworkPathMonitor"` - PublisherVp9 []string `json:"publisherVp9"` - SvcMode []string `json:"svcMode"` - SubscriberOfferAsyncAck []string `json:"subscriberOfferAsyncAck"` - SvcModes []string `json:"svcModes"` - ReportTelemetryModes []string `json:"reportTelemetryModes"` - KeepDefaultDevicesModes []string `json:"keepDefaultDevicesModes"` - } - - type HelloPayload struct { - ParticipantMeta PartMeta `json:"participantMeta"` - ParticipantAttributes PartAttrs `json:"participantAttributes"` - SendAudio bool `json:"sendAudio"` - SendVideo bool `json:"sendVideo"` - SendSharing bool `json:"sendSharing"` - ParticipantID string `json:"participantId"` - RoomID string `json:"roomId"` - ServiceName string `json:"serviceName"` - Credentials string `json:"credentials"` - CapabilitiesOffer Capabilities `json:"capabilitiesOffer"` - SdkInfo SdkInfo `json:"sdkInfo"` - SdkInitializationID string `json:"sdkInitializationId"` - DisablePublisher bool `json:"disablePublisher"` - DisableSubscriber bool `json:"disableSubscriber"` - DisableSubscriberAudio bool `json:"disableSubscriberAudio"` - } - - type HelloRequest struct { - UID string `json:"uid"` - Hello HelloPayload `json:"hello"` - } - - type FlexUrls []string - - type WSSResponse struct { - UID string `json:"uid"` - ServerHello struct { - RtcConfiguration struct { - IceServers []struct { - Urls FlexUrls `json:"urls"` - Username string `json:"username,omitempty"` - Credential string `json:"credential,omitempty"` - } `json:"iceServers"` - } `json:"rtcConfiguration"` - } `json:"serverHello"` - } - - type WSSAck struct { - Uid string `json:"uid"` - Ack struct { - Status struct { - Code string `json:"code"` - } `json:"status"` - } `json:"ack"` - } - - type WSSData struct { - ParticipantId string - RoomId string - Credentials string - Wss string - } - - endpoint := "https://" + telemostConfHost + telemostConfPath - client := &http.Client{ - Timeout: 20 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - }, - } - defer client.CloseIdleConnections() - req, err := http.NewRequest("GET", endpoint, nil) - if err != nil { - return "", "", "", err - } - req.Header.Set("User-Agent", userAgent) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Referer", "https://telemost.yandex.ru/") - req.Header.Set("Origin", "https://telemost.yandex.ru") - req.Header.Set("Client-Instance-Id", uuid.New().String()) - - resp, err := client.Do(req) - if err != nil { - return "", "", "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return "", "", "", fmt.Errorf("GetConference: status=%s body=%s", resp.Status, string(body)) - } - - var result ConferenceResponse - if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", "", "", fmt.Errorf("decode conf: %v", err) - } - data := WSSData{ - ParticipantId: result.PeerID, - RoomId: result.RoomID, - Credentials: result.Credentials, - Wss: result.ClientConfiguration.MediaServerURL, - } - h := http.Header{} - h.Set("Origin", "https://telemost.yandex.ru") - h.Set("User-Agent", userAgent) - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - dialer := websocket.Dialer{} - conn, _, err := dialer.DialContext(ctx, data.Wss, h) - if err != nil { - return "", "", "", fmt.Errorf("ws dial: %w", err) - } - defer conn.Close() - - req1 := HelloRequest{ - UID: uuid.New().String(), - Hello: HelloPayload{ - ParticipantMeta: PartMeta{ - Name: "Гость", - Role: "SPEAKER", - Description: "", - SendAudio: false, - SendVideo: false, - }, - ParticipantAttributes: PartAttrs{ - Name: "Гость", - Role: "SPEAKER", - Description: "", - }, - SendAudio: false, - SendVideo: false, - SendSharing: false, - - ParticipantID: data.ParticipantId, - RoomID: data.RoomId, - ServiceName: "telemost", - Credentials: data.Credentials, - SdkInfo: SdkInfo{ - Implementation: "browser", - Version: "5.15.0", - UserAgent: userAgent, - HwConcurrency: 4, - }, - SdkInitializationID: uuid.New().String(), - DisablePublisher: false, - DisableSubscriber: false, - DisableSubscriberAudio: false, - CapabilitiesOffer: Capabilities{ - OfferAnswerMode: []string{"SEPARATE"}, - InitialSubscriberOffer: []string{"ON_HELLO"}, - SlotsMode: []string{"FROM_CONTROLLER"}, - SimulcastMode: []string{"DISABLED"}, - SelfVadStatus: []string{"FROM_SERVER"}, - DataChannelSharing: []string{"TO_RTP"}, - VideoEncoderConfig: []string{"NO_CONFIG"}, - DataChannelVideoCodec: []string{"VP8"}, - BandwidthLimitationReason: []string{"BANDWIDTH_REASON_DISABLED"}, - SdkDefaultDeviceManagement: []string{"SDK_DEFAULT_DEVICE_MANAGEMENT_DISABLED"}, - JoinOrderLayout: []string{"JOIN_ORDER_LAYOUT_DISABLED"}, - PinLayout: []string{"PIN_LAYOUT_DISABLED"}, - SendSelfViewVideoSlot: []string{"SEND_SELF_VIEW_VIDEO_SLOT_DISABLED"}, - ServerLayoutTransition: []string{"SERVER_LAYOUT_TRANSITION_DISABLED"}, - SdkPublisherOptimizeBitrate: []string{"SDK_PUBLISHER_OPTIMIZE_BITRATE_DISABLED"}, - SdkNetworkLostDetection: []string{"SDK_NETWORK_LOST_DETECTION_DISABLED"}, - SdkNetworkPathMonitor: []string{"SDK_NETWORK_PATH_MONITOR_DISABLED"}, - PublisherVp9: []string{"PUBLISH_VP9_DISABLED"}, - SvcMode: []string{"SVC_MODE_DISABLED"}, - SubscriberOfferAsyncAck: []string{"SUBSCRIBER_OFFER_ASYNC_ACK_DISABLED"}, - SvcModes: []string{"FALSE"}, - ReportTelemetryModes: []string{"TRUE"}, - KeepDefaultDevicesModes: []string{"TRUE"}, - }, - }, - } - - if debug { - b, _ := json.MarshalIndent(req1, "", " ") - log.Printf("Sending HELLO:\n%s", string(b)) - } - - if err := conn.WriteJSON(req1); err != nil { - return "", "", "", fmt.Errorf("ws write: %w", err) - } - - conn.SetReadDeadline(time.Now().Add(15 * time.Second)) - - for { - _, msg, err := conn.ReadMessage() - if err != nil { - return "", "", "", fmt.Errorf("ws read: %w", err) - } - if debug { - s := string(msg) - if len(s) > 800 { - s = s[:800] + "...(truncated)" - } - log.Printf("WSS recv: %s", s) - } - - var ack WSSAck - if err := json.Unmarshal(msg, &ack); err == nil && ack.Ack.Status.Code != "" { - continue - } - - var resp WSSResponse - if err := json.Unmarshal(msg, &resp); err == nil { - ice := resp.ServerHello.RtcConfiguration.IceServers - for _, s := range ice { - for _, u := range s.Urls { - if !strings.HasPrefix(u, "turn:") && !strings.HasPrefix(u, "turns:") { - continue - } - if strings.Contains(u, "transport=tcp") { - continue - } - clean := strings.Split(u, "?")[0] - address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") - - return s.Username, s.Credential, address, nil - } - } - } - } -} - func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net.Conn, error) { certificate, err := selfsign.GenerateSelfSigned() if err != nil { @@ -1087,9 +427,9 @@ func main() { //nolint:cyclop port := flag.String("port", "", "override TURN port") listen := flag.String("listen", "127.0.0.1:9000", "listen on ip:port") vklink := flag.String("vk-link", "", "VK calls invite link \"https://vk.com/call/join/...\"") - yalink := flag.String("yandex-link", "", "Yandex telemost invite link \"https://telemost.yandex.ru/j/...\"") + wb := flag.Bool("wb", false, "use WB Stream instead of VK") peerAddr := flag.String("peer", "", "peer server address (host:port)") - n := flag.Int("n", 0, "connections to TURN (default 16 for VK, 1 for Yandex)") + n := flag.Int("n", 0, "connections to TURN (default 4)") udp := flag.Bool("udp", false, "connect to TURN with UDP") direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE") sessionIDFlag := flag.String("session-id", "", "override session ID (hex, 32 chars)") @@ -1101,26 +441,29 @@ func main() { //nolint:cyclop if err != nil { panic(err) } - if (*vklink == "") == (*yalink == "") { - log.Panicf("Need either vk-link or yandex-link!") + if !*wb && *vklink == "" { + log.Panicf("Need either -wb or -vk-link!") } + var link string var getCreds getCredsFunc - if *vklink != "" { - parts := strings.Split(*vklink, "join/") - link = parts[len(parts)-1] - getCreds = getVkCredsCached - if *n <= 0 { - *n = 4 + + if *wb { + link = "wb" + getCreds = func(ctx context.Context, lk string, streamID int) (string, string, string, error) { + return getCredsCached(ctx, lk, streamID, wbFetch) } } else { - parts := strings.Split(*yalink, "j/") + parts := strings.Split(*vklink, "join/") link = parts[len(parts)-1] - getCreds = getYandexCreds - if *n <= 0 { - *n = 1 + getCreds = func(ctx context.Context, lk string, streamID int) (string, string, string, error) { + return getCredsCached(ctx, lk, streamID, getVkCreds) } } + + if *n <= 0 { + *n = 4 + } if idx := strings.IndexAny(link, "/?#"); idx != -1 { link = link[:idx] } diff --git a/client/vk.go b/client/vk.go new file mode 100644 index 0000000..a75c55b --- /dev/null +++ b/client/vk.go @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: 2023 The Pion community +// 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 +} diff --git a/client/wb.go b/client/wb.go new file mode 100644 index 0000000..6beffd5 --- /dev/null +++ b/client/wb.go @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: 2023 The Pion community +// 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 +} From f41f75d10b27c78058ec809f42397c1c4760b0cb Mon Sep 17 00:00:00 2001 From: kiper292 Date: Sun, 5 Apr 2026 00:12:02 +0500 Subject: [PATCH 08/11] Update vk_captcha.go --- client/vk_captcha.go | 92 +++++++++++++++----------------------------- 1 file changed, 32 insertions(+), 60 deletions(-) diff --git a/client/vk_captcha.go b/client/vk_captcha.go index 8e3a348..92abf49 100644 --- a/client/vk_captcha.go +++ b/client/vk_captcha.go @@ -91,11 +91,8 @@ func ExtractCaptchaData(errResp map[string]interface{}) (*VkCaptchaData, bool) { func SolveVkCaptcha(ctx context.Context, captchaData *VkCaptchaData) (string, error) { log.Printf("[Captcha] Solving Not Robot Captcha...") - // HAR: Token 2 error → Captcha HTML = 2.72s (browser page load + user perception) - time.Sleep(1500*time.Millisecond + time.Duration(rand.Intn(1000))*time.Millisecond) - // Fetch captcha HTML (browser redirect) - powInput, difficulty, cookies, err := fetchCaptchaPowInput(ctx, captchaData.RedirectURI) + powInput, difficulty, err := fetchCaptchaPowInput(ctx, captchaData.RedirectURI) if err != nil { return "", fmt.Errorf("failed to fetch powInput: %w", err) } @@ -105,8 +102,8 @@ func SolveVkCaptcha(ctx context.Context, captchaData *VkCaptchaData) (string, er hash := solveCaptchaPoW(powInput, difficulty) log.Printf("[Captcha] PoW solved: hash=%s", hash) - // Call captchaNotRobot API with cookies from captcha page - successToken, err := callCaptchaNotRobotAPI(ctx, captchaData.SessionToken, hash, cookies) + // Call captchaNotRobot API + successToken, err := callCaptchaNotRobotAPI(ctx, captchaData.SessionToken, hash) if err != nil { return "", fmt.Errorf("captchaNotRobot API failed: %w", err) } @@ -115,10 +112,10 @@ func SolveVkCaptcha(ctx context.Context, captchaData *VkCaptchaData) (string, er return successToken, nil } -func fetchCaptchaPowInput(ctx context.Context, redirectURI string) (string, int, string, error) { +func fetchCaptchaPowInput(ctx context.Context, redirectURI string) (string, int, error) { req, err := http.NewRequestWithContext(ctx, "GET", redirectURI, nil) if err != nil { - return "", 0, "", err + return "", 0, err } req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") @@ -133,25 +130,13 @@ func fetchCaptchaPowInput(ctx context.Context, redirectURI string) (string, int, resp, err := client.Do(req) if err != nil { - return "", 0, "", err + return "", 0, err } defer resp.Body.Close() - // Capture Set-Cookie headers - var cookieValues []string - for _, setCookie := range resp.Header.Values("Set-Cookie") { - // Extract just the cookie name=value part (before ; expires= or ; path=) - cookieParts := strings.Split(setCookie, ";") - cookieValues = append(cookieValues, strings.TrimSpace(cookieParts[0])) - } - cookies := strings.Join(cookieValues, "; ") - if cookies != "" { - log.Printf("[Captcha] Captcha page set %d cookie(s)", len(cookieValues)) - } - body, err := io.ReadAll(resp.Body) if err != nil { - return "", 0, "", err + return "", 0, err } html := string(body) @@ -160,7 +145,7 @@ func fetchCaptchaPowInput(ctx context.Context, redirectURI string) (string, int, powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) powInputMatch := powInputRe.FindStringSubmatch(html) if len(powInputMatch) < 2 { - return "", 0, "", fmt.Errorf("powInput not found in captcha HTML") + return "", 0, fmt.Errorf("powInput not found in captcha HTML") } powInput := powInputMatch[1] @@ -174,7 +159,7 @@ func fetchCaptchaPowInput(ctx context.Context, redirectURI string) (string, int, } } - return powInput, difficulty, cookies, nil + return powInput, difficulty, nil } func solveCaptchaPoW(powInput string, difficulty int) string { @@ -190,7 +175,7 @@ func solveCaptchaPoW(powInput string, difficulty int) string { return "" } -func callCaptchaNotRobotAPI(ctx context.Context, sessionToken, hash, cookies string) (string, error) { +func callCaptchaNotRobotAPI(ctx context.Context, sessionToken, hash string) (string, error) { vkReq := func(method string, postData string) (map[string]interface{}, error) { requestURL := fmt.Sprintf("https://api.vk.ru/method/%s?v=%s", method, vkCaptchaNotRobotVer) @@ -199,24 +184,20 @@ func callCaptchaNotRobotAPI(ctx context.Context, sessionToken, hash, cookies str 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("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) 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://id.vk.ru") - req.Header.Set("Referer", "https://id.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("Origin", "https://vk.ru") + req.Header.Set("Referer", "https://vk.ru/") + req.Header.Set("sec-ch-ua-platform", "\"Linux\"") + 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("DNT", "1") 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") - // Add cookies captured from captcha page - if cookies != "" { - req.Header.Set("Cookie", cookies) - } + req.Header.Set("Sec-GPC", "1") client := &http.Client{ Timeout: 20 * time.Second, @@ -253,46 +234,38 @@ func callCaptchaNotRobotAPI(ctx context.Context, sessionToken, hash, cookies str if err != nil { return "", fmt.Errorf("settings failed: %w", err) } - // HAR: settings → componentDone = 0.19s - time.Sleep(100*time.Millisecond + time.Duration(rand.Intn(100))*time.Millisecond) + time.Sleep(200 * time.Millisecond) // Step 2: componentDone log.Printf("[Captcha] Step 2/4: componentDone") - browserFp := fmt.Sprintf("%032x", uint64(time.Now().UnixNano())) + browserFp := fmt.Sprintf("%032x", rand.Int63()) deviceJSON := `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1920,"innerHeight":945,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":16,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"denied"}` componentData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, url.QueryEscape(deviceJSON)) _, err = vkReq("captchaNotRobot.componentDone", componentData) if err != nil { return "", fmt.Errorf("componentDone failed: %w", err) } - // HAR: componentDone → check ≈ 1.95s + statEvents delay ≈ 3.2s total - time.Sleep(1500*time.Millisecond + time.Duration(rand.Intn(1000))*time.Millisecond) + time.Sleep(200 * time.Millisecond) // Step 3: check log.Printf("[Captcha] Step 3/4: check") cursorJSON := `[{"x":950,"y":500},{"x":945,"y":510},{"x":940,"y":520},{"x":938,"y":525},{"x":938,"y":525}]` answer := base64.StdEncoding.EncodeToString([]byte("{}")) // e30= - // Generate random connectionDownlink values (simulating Network Information API) - // HAR shows browser repeats the same value 7 times: [9.8,9.8,9.8,9.8,9.8,9.8,9.8] - baseDownlink := 8.0 + rand.Float64()*4.0 // Random in [8.0, 12.0) for typical WiFi - downlinkStr := fmt.Sprintf("%.1f", baseDownlink) - connectionDownlink := "[" + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "," + downlinkStr + "]" - checkData := baseParams + fmt.Sprintf( "&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s"+ "&browser_fp=%s&hash=%s&answer=%s&debug_info=%s", - url.QueryEscape("[]"), // accelerometer - url.QueryEscape("[]"), // gyroscope - url.QueryEscape("[]"), // motion - url.QueryEscape(cursorJSON), // cursor - url.QueryEscape("[]"), // taps - url.QueryEscape("[]"), // connectionRtt - url.QueryEscape(connectionDownlink), - browserFp, // browser_fp - hash, // hash (PoW result) - answer, // answer - vkDebugInfo, // debug_info (static) + url.QueryEscape("[]"), // accelerometer + url.QueryEscape("[]"), // gyroscope + url.QueryEscape("[]"), // motion + url.QueryEscape(cursorJSON), // cursor + url.QueryEscape("[]"), // taps + url.QueryEscape("[]"), // connectionRtt + url.QueryEscape("[9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5]"), // connectionDownlink (16 values as in HAR) + browserFp, // browser_fp + hash, // hash (PoW result) + answer, // answer + vkDebugInfo, // debug_info (static) ) checkResp, err := vkReq("captchaNotRobot.check", checkData) @@ -314,8 +287,7 @@ func callCaptchaNotRobotAPI(ctx context.Context, sessionToken, hash, cookies str if !ok || successToken == "" { return "", fmt.Errorf("success_token not found in check response: %v", checkResp) } - // HAR: check → endSession = 0.48s - time.Sleep(200*time.Millisecond + time.Duration(rand.Intn(300))*time.Millisecond) + time.Sleep(200 * time.Millisecond) // Step 4: endSession log.Printf("[Captcha] Step 4/4: endSession") From ac181c352a5052f188f19feda578a9e65168c820 Mon Sep 17 00:00:00 2001 From: kiper292 Date: Sun, 5 Apr 2026 01:08:12 +0500 Subject: [PATCH 09/11] support for old v1 server --- README.en.md | 1 + README.md | 1 + client/main.go | 34 +++++++++++++++++++--------------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/README.en.md b/README.en.md index fc6fa03..f665b81 100644 --- a/README.en.md +++ b/README.en.md @@ -86,6 +86,7 @@ Additional flags: - `-turn `: override TURN server address. - `-port `: override TURN server port. - `-no-dtls`: without DTLS obfuscation (may result in a ban). +- `-v1`: use v1 protocol (no session_id and stream_id sent). For legacy servers. #### Linux diff --git a/README.md b/README.md index f1aeca5..54155fd 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ chmod 777 ./client-android - `-turn `: переопределить адрес TURN сервера. - `-port `: переопределить порт TURN сервера. - `-no-dtls`: без DTLS-обфускации (может привести к бану). +- `-v1`: использовать протокол v1 (без отправки session_id и stream_id). Для серверов старой версии. #### Linux diff --git a/client/main.go b/client/main.go index 3e19922..001e127 100644 --- a/client/main.go +++ b/client/main.go @@ -52,7 +52,7 @@ func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net. return dtlsConn, nil } -func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, c chan<- error, sessionID []byte, streamID byte) { +func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, c chan<- error, sessionID []byte, streamID byte, v1 bool) { var err error = nil defer func() { c <- err }() dtlsctx, dtlscancel := context.WithCancel(ctx) @@ -81,17 +81,20 @@ func oneDtlsConnection(ctx context.Context, peer *net.UDPAddr, listenConn net.Pa log.Printf("Closed DTLS connection\n") }() - // Phase 1: Send Session ID + Stream ID (17 bytes) - dtlsConn.SetWriteDeadline(time.Now().Add(time.Second * 5)) - idBuf := make([]byte, 17) - copy(idBuf[:16], sessionID) - idBuf[16] = streamID - if _, err1 = dtlsConn.Write(idBuf); err1 != nil { - err = fmt.Errorf("failed to send session ID: %s", err1) - return + // Phase 1: Send Session ID + Stream ID (17 bytes) - only for v2 protocol + if !v1 { + dtlsConn.SetWriteDeadline(time.Now().Add(time.Second * 5)) + idBuf := make([]byte, 17) + copy(idBuf[:16], sessionID) + idBuf[16] = streamID + if _, err1 = dtlsConn.Write(idBuf); err1 != nil { + err = fmt.Errorf("failed to send session ID: %s", err1) + return + } + log.Printf("Established DTLS connection and sent session ID with stream %d!\n", streamID) + } else { + log.Printf("Established DTLS connection (v1 protocol, no session ID)!\n") } - - log.Printf("Established DTLS connection and sent session ID with stream %d!\n", streamID) go func() { for { select { @@ -369,14 +372,14 @@ func oneTurnConnection(ctx context.Context, turnParams *turnParams, peer *net.UD conn2.SetDeadline(time.Time{}) } -func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnChan <-chan net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, sessionID []byte, streamID byte) { +func oneDtlsConnectionLoop(ctx context.Context, peer *net.UDPAddr, listenConnChan <-chan net.PacketConn, connchan chan<- net.PacketConn, okchan chan<- struct{}, sessionID []byte, streamID byte, v1 bool) { for { select { case <-ctx.Done(): return case listenConn := <-listenConnChan: c := make(chan error) - go oneDtlsConnection(ctx, peer, listenConn, connchan, okchan, c, sessionID, streamID) + go oneDtlsConnection(ctx, peer, listenConn, connchan, okchan, c, sessionID, streamID, v1) if err := <-c; err != nil { log.Printf("%s", err) } @@ -432,6 +435,7 @@ func main() { //nolint:cyclop n := flag.Int("n", 0, "connections to TURN (default 4)") udp := flag.Bool("udp", false, "connect to TURN with UDP") direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE") + v1 := flag.Bool("v1", false, "use v1 server protocol (no session_id and stream_id)") sessionIDFlag := flag.String("session-id", "", "override session ID (hex, 32 chars)") flag.Parse() if *peerAddr == "" { @@ -525,7 +529,7 @@ func main() { //nolint:cyclop wg1.Add(1) go func() { defer wg1.Done() - oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, okchan, sessionID, 0) + oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, okchan, sessionID, 0, *v1) }() wg1.Add(1) @@ -544,7 +548,7 @@ func main() { //nolint:cyclop wg1.Add(1) go func(sID byte) { defer wg1.Done() - oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil, sessionID, sID) + oneDtlsConnectionLoop(ctx, peer, listenConnChan, connchan, nil, sessionID, sID, *v1) }(byte(streamID)) wg1.Add(1) go func() { From ae3d1b4895ff8b724c9eafdb2d8c37498ee5b97a Mon Sep 17 00:00:00 2001 From: kiper292 Date: Thu, 16 Apr 2026 16:03:28 +0500 Subject: [PATCH 10/11] captcha fix --- client/manual_captcha.go | 579 +++++++++++++++++ client/vk.go | 12 +- client/vk_captcha.go | 1304 +++++++++++++++++++++++++++++++++----- 3 files changed, 1721 insertions(+), 174 deletions(-) create mode 100644 client/manual_captcha.go diff --git a/client/manual_captcha.go b/client/manual_captcha.go new file mode 100644 index 0000000..bb11d1b --- /dev/null +++ b/client/manual_captcha.go @@ -0,0 +1,579 @@ +package main + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/http/httputil" + neturl "net/url" + "os/exec" + "runtime" + "strings" + "time" +) + +const captchaListenPort = "8765" + +type browserCommand struct { + name string + args []string +} + +func localCaptchaOrigin() string { + return "http://localhost:" + captchaListenPort +} + +func localCaptchaListenAddrs() []string { + return []string{ + "127.0.0.1:" + captchaListenPort, + "[::1]:" + captchaListenPort, + } +} + +func localCaptchaHosts() []string { + return []string{ + "localhost:" + captchaListenPort, + "127.0.0.1:" + captchaListenPort, + "[::1]:" + captchaListenPort, + } +} + +func isLocalCaptchaHost(host string) bool { + for _, localHost := range localCaptchaHosts() { + if strings.EqualFold(host, localHost) { + return true + } + } + return false +} + +func localCaptchaURLForTarget(targetURL *neturl.URL) string { + localURL := &neturl.URL{ + Scheme: "http", + Host: "localhost:" + captchaListenPort, + Path: targetURL.Path, + RawPath: targetURL.RawPath, + RawQuery: targetURL.RawQuery, + } + if localURL.Path == "" { + localURL.Path = "/" + } + return localURL.String() +} + +func targetOrigin(targetURL *neturl.URL) string { + return targetURL.Scheme + "://" + targetURL.Host +} + +func isSafeLocalRedirectPath(raw string) bool { + if raw == "" || raw[0] != '/' { + return false + } + if len(raw) > 1 && (raw[1] == '/' || raw[1] == '\\') { + return false + } + return true +} + +func rewriteProxyRedirectLocation(raw string, targetURL *neturl.URL) (string, bool) { + if isSafeLocalRedirectPath(raw) { + return raw, true + } + + parsed, err := neturl.Parse(raw) + if err != nil { + return "", false + } + if !strings.EqualFold(parsed.Scheme, targetURL.Scheme) || !strings.EqualFold(parsed.Host, targetURL.Host) { + return "", false + } + + return localCaptchaURLForTarget(parsed), true +} + +func rewriteProxyHeaderURL(raw string, targetURL *neturl.URL) string { + if raw == "" { + return raw + } + parsed, err := neturl.Parse(raw) + if err != nil { + return raw + } + if parsed.Scheme != "http" || !isLocalCaptchaHost(parsed.Host) { + return raw + } + parsed.Scheme = targetURL.Scheme + parsed.Host = targetURL.Host + return parsed.String() +} + +func rewriteProxyRequest(req *http.Request, targetURL *neturl.URL) { + req.URL.Scheme = targetURL.Scheme + req.URL.Host = targetURL.Host + if req.URL.Path == "" { + req.URL.Path = targetURL.Path + } + req.Host = targetURL.Host + + req.Header.Del("Accept-Encoding") + for _, headerName := range []string{"Origin", "Referer"} { + if rewritten := rewriteProxyHeaderURL(req.Header.Get(headerName), targetURL); rewritten != "" { + req.Header.Set(headerName, rewritten) + } else { + req.Header.Del(headerName) + } + } +} + +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 rewriteProxyCookies(header http.Header) { + cookies := (&http.Response{Header: header}).Cookies() + if len(cookies) == 0 { + return + } + 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 + } + header.Add("Set-Cookie", cookie.String()) + } +} + +func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string { + localOrigin := localCaptchaOrigin() + upstreamOrigin := targetOrigin(targetURL) + html = strings.ReplaceAll(html, upstreamOrigin, localOrigin) + + script := fmt.Sprintf(` + +`, localOrigin, upstreamOrigin) + + switch { + case strings.Contains(html, ""): + return strings.Replace(html, "", script+"", 1) + case strings.Contains(html, ""): + return strings.Replace(html, "", script+"", 1) + default: + return html + script + } +} + +func startCaptchaServer(srv *http.Server, logPrefix string) error { + var listenErrs []string + var listening bool + + for _, addr := range localCaptchaListenAddrs() { + listener, err := net.Listen("tcp", addr) + if err != nil { + listenErrs = append(listenErrs, fmt.Sprintf("%s (%v)", addr, err)) + continue + } + listening = true + go func(listener net.Listener) { + if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("%s: %s", logPrefix, err) + } + }(listener) + } + + if listening { + return nil + } + + return fmt.Errorf("captcha listeners failed: %s", strings.Join(listenErrs, "; ")) +} + +func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-chan string, logPrefix string) (string, error) { + srv := &http.Server{Handler: handler} + + if err := startCaptchaServer(srv, logPrefix); err != nil { + return "", err + } + + fmt.Println("\n==============================================") + fmt.Println("ACTION REQUIRED: MANUAL CAPTCHA SOLVING NEEDED") + fmt.Println("Open this URL in your browser: " + localCaptchaOrigin()) + fmt.Println("==============================================") + fmt.Println() + + openBrowser(captchaURL) + + key := <-keyCh + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + return "", err + } + + return key, nil +} + +func notifyKey(keyCh chan<- string, key string) { + if key != "" { + select { + case keyCh <- key: + default: + } + } +} + +func solveCaptchaViaHTTP(captchaImg string) (string, error) { + keyCh := make(chan string, 1) + mux := http.NewServeMux() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf(w, ` + + + + +

Solve the Captcha

+captcha +
+
+
+
`, captchaImg) + }) + + mux.HandleFunc("/solve", func(w http.ResponseWriter, r *http.Request) { + notifyKey(keyCh, r.URL.Query().Get("key")) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprint(w, `

Done!

`) + }) + + return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error") +} + +func solveCaptchaViaProxy(redirectURI string) (string, error) { + keyCh := make(chan string, 1) + + targetURL, err := neturl.Parse(redirectURI) + if err != nil { + return "", fmt.Errorf("invalid redirect URI: %v", err) + } + + transport := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ForceAttemptHTTP2: true, + } + + proxy := &httputil.ReverseProxy{ + Transport: transport, + Rewrite: func(req *httputil.ProxyRequest) { + rewriteProxyRequest(req.Out, targetURL) + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("captcha proxy error for %s: %v", r.URL.String(), err) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + _, _ = fmt.Fprintf(w, `

Captcha proxy error

%v

`, err) + }, + ModifyResponse: func(res *http.Response) error { + rewriteProxyCookies(res.Header) + + if res.StatusCode >= 300 && res.StatusCode < 400 { + if loc := res.Header.Get("Location"); loc != "" { + if rewritten, ok := rewriteProxyRedirectLocation(loc, targetURL); ok { + res.Header.Set("Location", rewritten) + } else { + res.Header.Del("Location") + } + } + } + + contentType := res.Header.Get("Content-Type") + shouldInspectBody := strings.Contains(contentType, "text/html") || strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") + if !shouldInspectBody { + return nil + } + + reader := res.Body + if res.Header.Get("Content-Encoding") == "gzip" { + gzReader, err := gzip.NewReader(res.Body) + if err == nil { + reader = gzReader + defer func() { + _ = gzReader.Close() + }() + } + } + + bodyBytes, err := io.ReadAll(reader) + if err != nil { + return err + } + if err := res.Body.Close(); err != nil { + return err + } + + if strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") { + notifyKey(keyCh, extractSuccessToken(bodyBytes)) + } + + if strings.Contains(contentType, "text/html") { + for _, headerName := 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", + } { + res.Header.Del(headerName) + } + + bodyBytes = []byte(rewriteCaptchaHTML(string(bodyBytes), targetURL)) + 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) { + notifyKey(keyCh, r.FormValue("token")) + w.Header().Set("Access-Control-Allow-Origin", "*") + _, _ = fmt.Fprint(w, "ok") + }) + + mux.HandleFunc("/generic_proxy", func(w http.ResponseWriter, r *http.Request) { + targetAuthURL := r.URL.Query().Get("proxy_url") + targetParsed, err := neturl.Parse(targetAuthURL) + if err != nil || targetParsed.Host == "" { + http.Error(w, "Bad URL", http.StatusBadRequest) + return + } + genericReverse := &httputil.ReverseProxy{ + Transport: transport, + Rewrite: func(req *httputil.ProxyRequest) { + req.Out.URL.Path = targetParsed.Path + req.Out.URL.RawQuery = targetParsed.RawQuery + rewriteProxyRequest(req.Out, targetParsed) + }, + } + genericReverse.ServeHTTP(w, r) + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" { + http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect) + return + } + proxy.ServeHTTP(w, r) + }) + + return runCaptchaServerAndWait(mux, localCaptchaURLForTarget(targetURL), keyCh, "proxy HTTP server error") +} + +func openBrowser(url string) { + for _, cmd := range browserOpenCommands(runtime.GOOS, url) { + if err := exec.Command(cmd.name, cmd.args...).Start(); err == nil { + return + } + } +} + +func browserOpenCommands(goos string, url string) []browserCommand { + switch goos { + case "windows": + return []browserCommand{{name: "cmd", args: []string{"/c", "start", url}}} + case "darwin": + return []browserCommand{{name: "open", args: []string{url}}} + case "linux": + return []browserCommand{ + {name: "xdg-open", args: []string{url}}, + {name: "gio", args: []string{"open", url}}, + } + } + return nil +} diff --git a/client/vk.go b/client/vk.go index a75c55b..77d9549 100644 --- a/client/vk.go +++ b/client/vk.go @@ -149,13 +149,13 @@ func getVkCreds(ctx context.Context, link string) (string, string, string, error // Check for captcha error if errMsg, ok := resp["error"].(map[string]interface{}); ok { - captchaData, isCaptcha := ExtractCaptchaData(errMsg) - if !isCaptcha { + captchaErr := ParseVkCaptchaError(errMsg) + if captchaErr == nil || !captchaErr.IsCaptchaError() { return "", "", "", fmt.Errorf("Token 2 VK error: %v", errMsg) } log.Printf("[VK Auth] Captcha detected, solving...") - successToken, solveErr := SolveVkCaptcha(ctx, captchaData) + successToken, solveErr := SolveVkCaptcha(ctx, captchaErr) if solveErr != nil { return "", "", "", fmt.Errorf("captcha solving failed: %w", solveErr) } @@ -171,10 +171,10 @@ func getVkCreds(ctx context.Context, link string) (string, string, string, error "&success_token=%s&captcha_ts=%s&captcha_attempt=%s"+ "&access_token=%s", url.QueryEscape(link), - captchaData.CaptchaSid, + captchaErr.CaptchaSid, successToken, - captchaData.CaptchaTs, - captchaData.CaptchaAttempt, + captchaErr.CaptchaTs, + captchaErr.CaptchaAttempt, token1, ) resp, err = vkHTTPPost(ctx, t2Data, t2URL) diff --git a/client/vk_captcha.go b/client/vk_captcha.go index 92abf49..7358cf1 100644 --- a/client/vk_captcha.go +++ b/client/vk_captcha.go @@ -1,9 +1,20 @@ -// SPDX-FileCopyrightText: 2023 The Pion community -// SPDX-License-Identifier: MIT +/* SPDX-License-Identifier: Apache-2.0 + * + * Copyright © 2026 WireGuard LLC. All Rights Reserved. + */ package main +/* +#include +// These are JNI functions from Android, they won't work in standalone exe +// But we keep the signature for consistency with libwg-go if asked +// extern const char* requestCaptcha(const char* redirect_uri); +*/ +// import "C" // Disabled cgo for standalone compatibility unless strictly needed + import ( + "bytes" "context" "crypto/sha256" "crypto/tls" @@ -11,112 +22,209 @@ import ( "encoding/hex" "encoding/json" "fmt" + "image" + "image/color" + _ "image/jpeg" "io" "log" "math/rand" + "net" "net/http" "net/url" "regexp" + "sort" "strconv" "strings" + "sync" + "syscall" "time" ) -const ( - vkCaptchaAPIVersion = "5.275" - vkCaptchaNotRobotVer = "5.131" - vkDebugInfo = "d44f534ce8deb56ba20be52e05c433309b49ee4d2a70602deeb17a1954257785" -) +// --- Compatibility Shims for Standalone vk-turn-proxy --- -// VkCaptchaData holds captcha challenge data from VK error response -type VkCaptchaData struct { - CaptchaSid string - CaptchaTs string - CaptchaAttempt string - RedirectURI string - SessionToken string +func turnLog(format string, v ...interface{}) { + log.Printf(format, v...) } -// ExtractCaptchaData parses VK API error response for captcha info -func ExtractCaptchaData(errResp map[string]interface{}) (*VkCaptchaData, bool) { - code, _ := errResp["error_code"].(float64) - if int(code) != 14 { - return nil, false - } +// Simple host cache for standalone +var hostCache = &simpleHostCache{} - redirectURI, _ := errResp["redirect_uri"].(string) - if redirectURI == "" { - return nil, false - } +type simpleHostCache struct{} - // Parse session_token from redirect_uri - parsed, err := url.Parse(redirectURI) +func (s *simpleHostCache) Resolve(ctx context.Context, host string) (string, error) { + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { - return nil, false + return "", err } - sessionToken := parsed.Query().Get("session_token") - if sessionToken == "" { - return nil, false + if len(ips) == 0 { + return "", fmt.Errorf("no IP found for %s", host) + } + return ips[0].IP.String(), nil +} + +// No-op protectControl for standalone +func protectControl(network, address string, c syscall.RawConn) error { + return nil +} + +// --- Original libwg-go code (adapted) --- + +// VkCaptchaError represents a VK captcha error +type VkCaptchaError struct { + ErrorCode int + ErrorMsg string + CaptchaSid string + CaptchaImg string + RedirectUri string + IsSoundCaptchaAvailable bool + SessionToken string + CaptchaTs string // captcha_ts from error + CaptchaAttempt string // captcha_attempt from error +} + +// ParseVkCaptchaError parses a VK error response into VkCaptchaError +func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { + codeFloat, _ := errData["error_code"].(float64) + code := int(codeFloat) + + redirectUri, _ := errData["redirect_uri"].(string) + captchaSid, _ := errData["captcha_sid"].(string) + captchaImg, _ := errData["captcha_img"].(string) + errorMsg, _ := errData["error_msg"].(string) + + // Extract session_token from redirect_uri + var sessionToken string + if redirectUri != "" { + if parsed, err := url.Parse(redirectUri); err == nil { + sessionToken = parsed.Query().Get("session_token") + } } - // Extract captcha_sid - captchaSid, _ := errResp["captcha_sid"].(string) + isSound, _ := errData["is_sound_captcha_available"].(bool) - // captcha_ts can be float64 or string + // captcha_ts can be float64 (scientific notation) or string var captchaTs string - if tsFloat, ok := errResp["captcha_ts"].(float64); ok { + if tsFloat, ok := errData["captcha_ts"].(float64); ok { captchaTs = fmt.Sprintf("%.0f", tsFloat) - } else if tsStr, ok := errResp["captcha_ts"].(string); ok { + } else if tsStr, ok := errData["captcha_ts"].(string); ok { captchaTs = tsStr } - // captcha_attempt + // captcha_attempt is usually a float64 var captchaAttempt string - if attFloat, ok := errResp["captcha_attempt"].(float64); ok { + if attFloat, ok := errData["captcha_attempt"].(float64); ok { captchaAttempt = fmt.Sprintf("%.0f", attFloat) - } else if attStr, ok := errResp["captcha_attempt"].(string); ok { + } else if attStr, ok := errData["captcha_attempt"].(string); ok { captchaAttempt = attStr } - return &VkCaptchaData{ - CaptchaSid: captchaSid, - CaptchaTs: captchaTs, - CaptchaAttempt: captchaAttempt, - RedirectURI: redirectURI, - SessionToken: sessionToken, - }, true + return &VkCaptchaError{ + ErrorCode: code, + ErrorMsg: errorMsg, + CaptchaSid: captchaSid, + CaptchaImg: captchaImg, + RedirectUri: redirectUri, + IsSoundCaptchaAvailable: isSound, + SessionToken: sessionToken, + CaptchaTs: captchaTs, + CaptchaAttempt: captchaAttempt, + } +} + +// IsCaptchaError checks if the error data is a Not Robot Captcha error +func (e *VkCaptchaError) IsCaptchaError() bool { + return e.ErrorCode == 14 && e.RedirectUri != "" && e.SessionToken != "" } -// SolveVkCaptcha fetches captcha page, solves PoW, and calls captchaNotRobot API -func SolveVkCaptcha(ctx context.Context, captchaData *VkCaptchaData) (string, error) { - log.Printf("[Captcha] Solving Not Robot Captcha...") +// captchaMutex serializes captcha solving to avoid multiple concurrent attempts +var captchaMutex sync.Mutex + +// SolveVkCaptcha solves the VK Not Robot Captcha and returns success_token +// First tries automatic solution, falls back to manual solution if it fails +func SolveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError) (string, error) { + // Serialize captcha solving to avoid multiple concurrent attempts + captchaMutex.Lock() + defer captchaMutex.Unlock() + + turnLog("[Captcha] Solving Not Robot Captcha...") - // Fetch captcha HTML (browser redirect) - powInput, difficulty, err := fetchCaptchaPowInput(ctx, captchaData.RedirectURI) + // Step 1: Try automatic solution + turnLog("[Captcha] Attempting automatic solution...") + successToken, err := solveVkCaptchaAutomatic(ctx, captchaErr) + if err == nil && successToken != "" { + turnLog("[Captcha] Automatic solution SUCCESS!") + return successToken, nil + } + + turnLog("[Captcha] Automatic solution FAILED: %v", err) + + // Step 2: Fall back to manual solving + turnLog("[Captcha] Triggering manual captcha fallback...") + if captchaErr.RedirectUri != "" { + return solveCaptchaViaProxy(captchaErr.RedirectUri) + } else if captchaErr.CaptchaImg != "" { + return solveCaptchaViaHTTP(captchaErr.CaptchaImg) + } + + return "", fmt.Errorf("no more solve modes available") +} + +// solveVkCaptchaAutomatic performs the automatic captcha solving without UI +func solveVkCaptchaAutomatic(ctx context.Context, captchaErr *VkCaptchaError) (string, error) { + sessionToken := captchaErr.SessionToken + if sessionToken == "" { + return "", fmt.Errorf("no session_token in redirect_uri") + } + + // Step 1: Fetch the captcha HTML page to get powInput + bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectUri) if err != nil { - return "", fmt.Errorf("failed to fetch powInput: %w", err) + return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err) } - log.Printf("[Captcha] PoW input: %s, difficulty: %d", powInput, difficulty) - // Solve PoW - hash := solveCaptchaPoW(powInput, difficulty) - log.Printf("[Captcha] PoW solved: hash=%s", hash) + turnLog("[Captcha] PoW input: %s, difficulty: %d", bootstrap.PowInput, bootstrap.Difficulty) - // Call captchaNotRobot API - successToken, err := callCaptchaNotRobotAPI(ctx, captchaData.SessionToken, hash) + // Step 2: Solve PoW + hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty) + turnLog("[Captcha] PoW solved: hash=%s", hash) + + // Step 3: Call captchaNotRobot API with slider POC support + successToken, err := callCaptchaNotRobotWithSliderPOC(ctx, sessionToken, hash, bootstrap.Settings) if err != nil { return "", fmt.Errorf("captchaNotRobot API failed: %w", err) } - log.Printf("[Captcha] Success! Got success_token") + turnLog("[Captcha] Success! Got success_token") return successToken, nil } -func fetchCaptchaPowInput(ctx context.Context, redirectURI string) (string, int, error) { - req, err := http.NewRequestWithContext(ctx, "GET", redirectURI, nil) +// fetchCaptchaBootstrap fetches the captcha HTML page and extracts PoW input, difficulty, and settings +func fetchCaptchaBootstrap(ctx context.Context, redirectUri string) (*captchaBootstrap, error) { + parsedURL, err := url.Parse(redirectUri) if err != nil { - return "", 0, err + return nil, fmt.Errorf("failed to parse redirect_uri: %w", err) } + + domain := parsedURL.Hostname() + resolvedIP, err := hostCache.Resolve(ctx, domain) + if err != nil { + return nil, fmt.Errorf("DNS resolution failed for %s: %w", domain, err) + } + + port := parsedURL.Port() + if port == "" { + port = "443" + } + ipURL := "https://" + resolvedIP + ":" + port + parsedURL.Path + if parsedURL.RawQuery != "" { + ipURL += "?" + parsedURL.RawQuery + } + + req, err := http.NewRequestWithContext(ctx, "GET", ipURL, nil) + if err != nil { + return nil, err + } + req.Host = domain req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") req.Header.Set("Accept-Language", "en-US,en;q=0.9") @@ -124,174 +232,1034 @@ func fetchCaptchaPowInput(ctx context.Context, redirectURI string) (string, int, client := &http.Client{ Timeout: 20 * time.Second, Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + // Control: protectControl, // Disabled for standalone + }).DialContext, + TLSClientConfig: &tls.Config{ + ServerName: domain, + }, }, } resp, err := client.Do(req) if err != nil { - return "", 0, err + return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return "", 0, err + return nil, err } html := string(body) - - // Extract powInput: const powInput = "..." - powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) - powInputMatch := powInputRe.FindStringSubmatch(html) - if len(powInputMatch) < 2 { - return "", 0, fmt.Errorf("powInput not found in captcha HTML") - } - powInput := powInputMatch[1] - - // Extract difficulty: '0'.repeat(N) - diffRe := regexp.MustCompile(`startsWith\('0'\.repeat\((\d+)\)\)`) - diffMatch := diffRe.FindStringSubmatch(html) - difficulty := 2 // default - if len(diffMatch) >= 2 { - if d, err := strconv.Atoi(diffMatch[1]); err == nil { - difficulty = d - } + bootstrap, err := parseCaptchaBootstrapHTML(html) + if err != nil { + return nil, err } - return powInput, difficulty, nil + return bootstrap, nil } -func solveCaptchaPoW(powInput string, difficulty int) string { +// solvePoW finds nonce where SHA-256(powInput + nonce) starts with '0' * difficulty +func solvePoW(powInput string, difficulty int) string { target := strings.Repeat("0", difficulty) + for nonce := 1; nonce <= 10000000; nonce++ { data := powInput + strconv.Itoa(nonce) hash := sha256.Sum256([]byte(data)) hexHash := hex.EncodeToString(hash[:]) + if strings.HasPrefix(hexHash, target) { return hexHash } } + + // Fallback: should not happen with difficulty <= 3 return "" } -func callCaptchaNotRobotAPI(ctx context.Context, sessionToken, hash string) (string, error) { - vkReq := func(method string, postData string) (map[string]interface{}, error) { - requestURL := fmt.Sprintf("https://api.vk.ru/method/%s?v=%s", method, vkCaptchaNotRobotVer) +const ( + sliderCaptchaType = "slider" + defaultSliderAttempts = 4 +) + +// captchaBootstrap holds parsed captcha bootstrap data +type captchaBootstrap struct { + PowInput string + Difficulty int + Settings *captchaSettingsResponse +} + +// captchaSettingsResponse holds captcha settings from VK API +type captchaSettingsResponse struct { + ShowCaptchaType string + SettingsByType map[string]string +} + +// captchaCheckResult holds the result of a captcha check request +type captchaCheckResult struct { + Status string + SuccessToken string + ShowCaptchaType string +} + +// sliderCaptchaContent holds decoded slider captcha content +type sliderCaptchaContent struct { + Image image.Image + Size int + Steps []int + Attempts int +} - req, err := http.NewRequestWithContext(ctx, "POST", requestURL, strings.NewReader(postData)) +// sliderCandidate represents a ranked slider candidate +type sliderCandidate struct { + Index int + ActiveSteps []int + Score int64 +} + +// captchaNotRobotSession represents a captcha solving session +type captchaNotRobotSession struct { + ctx context.Context + sessionToken string + hash string + browserFp string +} + +// newCaptchaNotRobotSession creates a new captcha solving session +func newCaptchaNotRobotSession( + ctx context.Context, + sessionToken string, + hash string, +) *captchaNotRobotSession { + // Generate random browser fingerprint + browserFp := fmt.Sprintf("%032x", randInt63()) + + return &captchaNotRobotSession{ + ctx: ctx, + sessionToken: sessionToken, + hash: hash, + browserFp: browserFp, + } +} + +// baseValues returns base URL values for API requests +func (s *captchaNotRobotSession) baseValues() url.Values { + values := url.Values{} + values.Set("session_token", s.sessionToken) + values.Set("domain", "vk.com") + values.Set("adFp", "") + values.Set("access_token", "") + return values +} + +// request makes a VK API request +func (s *captchaNotRobotSession) request(method string, values url.Values) (map[string]interface{}, error) { + reqURL := "https://api.vk.ru/method/" + method + "?v=5.131" + + parsedURL, err := url.Parse(reqURL) + if err != nil { + return nil, err + } + + domain := parsedURL.Hostname() + resolvedIP, err := hostCache.Resolve(s.ctx, domain) + if err != nil { + return nil, fmt.Errorf("DNS resolution failed for %s: %w", domain, err) + } + + port := parsedURL.Port() + if port == "" { + port = "443" + } + ipURL := "https://" + resolvedIP + ":" + port + parsedURL.Path + if parsedURL.RawQuery != "" { + ipURL += "?" + parsedURL.RawQuery + } + + req, err := http.NewRequestWithContext(s.ctx, "POST", ipURL, strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + + req.Host = domain + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) 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", "\"Linux\"") + 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("DNT", "1") + 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("Sec-GPC", "1") + + client := &http.Client{ + Timeout: 20 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + // Control: protectControl, + }).DialContext, + TLSClientConfig: &tls.Config{ + ServerName: domain, + }, + }, + } + + 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 respMap map[string]interface{} + if err := json.Unmarshal(body, &respMap); err != nil { + return nil, err + } + + return respMap, nil +} + +// requestSettings fetches captcha settings from VK API +func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, error) { + resp, err := s.request("captchaNotRobot.settings", s.baseValues()) + if err != nil { + return nil, fmt.Errorf("settings failed: %w", err) + } + return parseCaptchaSettingsResponse(resp) +} + +// requestComponentDone marks the component as done +func (s *captchaNotRobotSession) requestComponentDone() error { + values := s.baseValues() + values.Set("browser_fp", s.browserFp) + values.Set("device", buildCaptchaDeviceJSON()) + + resp, err := s.request("captchaNotRobot.componentDone", values) + if err != nil { + return fmt.Errorf("componentDone failed: %w", err) + } + + respObj, ok := resp["response"].(map[string]interface{}) + if ok { + if status, _ := respObj["status"].(string); status != "" && status != "OK" { + return fmt.Errorf("componentDone status: %s", status) + } + } + + return nil +} + +// requestCheckboxCheck performs a checkbox-style captcha check +func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) { + return s.requestCheck("[]", base64.StdEncoding.EncodeToString([]byte("{}"))) +} + +// requestSliderContent fetches slider captcha content +func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) { + values := s.baseValues() + if sliderSettings != "" { + values.Set("captcha_settings", sliderSettings) + } + + resp, err := s.request("captchaNotRobot.getContent", values) + if err != nil { + return nil, fmt.Errorf("getContent failed: %w", err) + } + return parseSliderCaptchaContentResponse(resp) +} + +// requestSliderCheck performs a slider captcha check +func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) { + answer, err := encodeSliderAnswer(activeSteps) + if err != nil { + return nil, err + } + + return s.requestCheck(generateSliderCursor(candidateIndex, candidateCount), answer) +} + +// requestCheck performs the main captcha check request +func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) { + values := s.baseValues() + values.Set("accelerometer", "[]") + values.Set("gyroscope", "[]") + values.Set("motion", "[]") + values.Set("cursor", cursor) + values.Set("taps", "[]") + values.Set("connectionRtt", "[]") + values.Set("connectionDownlink", "[]") + values.Set("browser_fp", s.browserFp) + values.Set("hash", s.hash) + values.Set("answer", answer) + values.Set("debug_info", "d44f534ce8deb56ba20be52e05c433309b49ee4d2a70602deeb17a1954257785") + + resp, err := s.request("captchaNotRobot.check", values) + if err != nil { + return nil, fmt.Errorf("check failed: %w", err) + } + return parseCaptchaCheckResult(resp) +} + +// requestEndSession ends the captcha session +func (s *captchaNotRobotSession) requestEndSession() { + turnLog("[Captcha] Step 4/4: endSession") + if _, err := s.request("captchaNotRobot.endSession", s.baseValues()); err != nil { + turnLog("[Captcha] Warning: endSession failed: %v", err) + } +} + +// callCaptchaNotRobotWithSliderPOC solves captcha with slider POC support +func callCaptchaNotRobotWithSliderPOC( + ctx context.Context, + sessionToken string, + hash string, + initialSettings *captchaSettingsResponse, +) (string, error) { + session := newCaptchaNotRobotSession(ctx, sessionToken, hash) + + turnLog("[Captcha] Step 1/4: settings") + settingsResp, err := session.requestSettings() + if err != nil { + return "", err + } + settingsResp = mergeCaptchaSettings(settingsResp, initialSettings) + + time.Sleep(200 * time.Millisecond) + + turnLog("[Captcha] Step 2/4: componentDone") + if err := session.requestComponentDone(); err != nil { + return "", err + } + + time.Sleep(200 * time.Millisecond) + + turnLog("[Captcha] Step 3/4: check") + initialCheck, err := session.requestCheckboxCheck() + if err != nil { + return "", err + } + if initialCheck.Status == "OK" { + if initialCheck.SuccessToken == "" { + return "", fmt.Errorf("success_token not found") + } + time.Sleep(200 * time.Millisecond) + session.requestEndSession() + return initialCheck.SuccessToken, nil + } + + sliderSettings, hasSlider := settingsResp.SettingsByType[sliderCaptchaType] + turnLog( + "[Captcha] Checkbox-style check returned status=%s (settings show_type=%q, check show_type=%q, available_types=%s)", + initialCheck.Status, + settingsResp.ShowCaptchaType, + initialCheck.ShowCaptchaType, + describeCaptchaTypes(settingsResp.SettingsByType), + ) + + if !hasSlider { + turnLog( + "[Captcha] Slider settings not found in settings response. Trying getContent without captcha_settings...", + ) + } else { + turnLog("[Captcha] Trying experimental slider solver...") + } + + sliderContent, err := session.requestSliderContent(sliderSettings) + if err != nil { + return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err) + } + + candidates, err := rankSliderCandidates(sliderContent.Image, sliderContent.Size, sliderContent.Steps) + if err != nil { + return "", err + } + + turnLog( + "[Captcha] Ranked %d slider positions locally; submitting top %d based on attempt budget %d", + len(candidates), + minInt(sliderContent.Attempts, len(candidates)), + sliderContent.Attempts, + ) + + successToken, err := trySliderCaptchaCandidates(candidates, sliderContent.Attempts, func(candidate sliderCandidate) (*captchaCheckResult, error) { + turnLog( + "[Captcha] Slider guess position=%d score=%d", + candidate.Index, + candidate.Score, + ) + return session.requestSliderCheck(candidate.ActiveSteps, candidate.Index, len(candidates)) + }) + if err != nil { + return "", err + } + + time.Sleep(200 * time.Millisecond) + session.requestEndSession() + return successToken, nil +} + +// buildCaptchaDeviceJSON builds device information JSON +func buildCaptchaDeviceJSON() string { + return fmt.Sprintf( + `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default"}`, + ) +} + +// parseCaptchaSettingsResponse parses captcha settings from API response +func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettingsResponse, error) { + respObj, ok := resp["response"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid settings response: %v", resp) + } + + settings := &captchaSettingsResponse{ + SettingsByType: make(map[string]string), + } + settings.ShowCaptchaType, _ = respObj["show_captcha_type"].(string) + + rawSettings, ok := expandCaptchaSettings(respObj["captcha_settings"]) + if !ok { + return settings, nil + } + + for _, rawItem := range rawSettings { + item, ok := rawItem.(map[string]interface{}) + if !ok { + continue + } + + captchaType, _ := item["type"].(string) + if captchaType == "" { + continue + } + + normalized, err := normalizeCaptchaSettings(item["settings"]) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid captcha_settings for %s: %w", captchaType, err) } - // Headers matching HAR capture exactly - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) 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", "\"Linux\"") - 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("DNT", "1") - 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("Sec-GPC", "1") - - client := &http.Client{ - Timeout: 20 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, + + settings.SettingsByType[captchaType] = normalized + } + + return settings, nil +} + +// parseCaptchaBootstrapHTML parses HTML page to extract PoW input and settings +func parseCaptchaBootstrapHTML(html string) (*captchaBootstrap, error) { + powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) + powInputMatch := powInputRe.FindStringSubmatch(html) + if len(powInputMatch) < 2 { + return nil, fmt.Errorf("powInput not found in captcha HTML") + } + + difficulty := 2 + for _, expr := range []*regexp.Regexp{ + regexp.MustCompile(`startsWith\('0'\.repeat\((\d+)\)\)`), + regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`), + } { + if match := expr.FindStringSubmatch(html); len(match) >= 2 { + if parsed, err := strconv.Atoi(match[1]); err == nil { + difficulty = parsed + break + } } + } + + settings, err := parseCaptchaSettingsFromHTML(html) + if err != nil { + return nil, err + } + + return &captchaBootstrap{ + PowInput: powInputMatch[1], + Difficulty: difficulty, + Settings: settings, + }, nil +} + +// parseCaptchaSettingsFromHTML parses captcha settings from HTML window.init +func parseCaptchaSettingsFromHTML(html string) (*captchaSettingsResponse, error) { + initRe := regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;\s*window\.lang`) + initMatch := initRe.FindStringSubmatch(html) + if len(initMatch) < 2 { + return &captchaSettingsResponse{SettingsByType: make(map[string]string)}, nil + } + + var initPayload struct { + Data struct { + ShowCaptchaType string `json:"show_captcha_type"` + CaptchaSettings interface{} `json:"captcha_settings"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(initMatch[1]), &initPayload); err != nil { + return nil, fmt.Errorf("parse window.init captcha data: %w", err) + } + + return parseCaptchaSettingsResponse(map[string]interface{}{ + "response": map[string]interface{}{ + "show_captcha_type": initPayload.Data.ShowCaptchaType, + "captcha_settings": initPayload.Data.CaptchaSettings, + }, + }) +} - resp, err := client.Do(req) +// mergeCaptchaSettings merges two captcha settings responses +func mergeCaptchaSettings(primary *captchaSettingsResponse, fallback *captchaSettingsResponse) *captchaSettingsResponse { + if primary == nil { + return cloneCaptchaSettings(fallback) + } + if primary.SettingsByType == nil { + primary.SettingsByType = make(map[string]string) + } + if fallback == nil { + return primary + } + if primary.ShowCaptchaType == "" { + primary.ShowCaptchaType = fallback.ShowCaptchaType + } + for captchaType, settings := range fallback.SettingsByType { + if _, exists := primary.SettingsByType[captchaType]; !exists { + primary.SettingsByType[captchaType] = settings + } + } + return primary +} + +// cloneCaptchaSettings clones a captcha settings response +func cloneCaptchaSettings(src *captchaSettingsResponse) *captchaSettingsResponse { + if src == nil { + return nil + } + + cloned := &captchaSettingsResponse{ + ShowCaptchaType: src.ShowCaptchaType, + SettingsByType: make(map[string]string, len(src.SettingsByType)), + } + for captchaType, settings := range src.SettingsByType { + cloned.SettingsByType[captchaType] = settings + } + return cloned +} + +// expandCaptchaSettings expands raw captcha settings into a slice +func expandCaptchaSettings(raw interface{}) ([]interface{}, bool) { + switch value := raw.(type) { + case nil: + return nil, false + case []interface{}: + return value, true + case map[string]interface{}: + items := make([]interface{}, 0, len(value)) + for captchaType, settings := range value { + items = append(items, map[string]interface{}{ + "type": captchaType, + "settings": settings, + }) + } + return items, true + case string: + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, false + } + + var items []interface{} + if err := json.Unmarshal([]byte(trimmed), &items); err == nil { + return items, true + } + + var mapping map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &mapping); err == nil { + return expandCaptchaSettings(mapping) + } + } + + return nil, false +} + +// normalizeCaptchaSettings normalizes captcha settings to string +func normalizeCaptchaSettings(raw interface{}) (string, error) { + switch value := raw.(type) { + case nil: + return "", nil + case string: + return value, nil + default: + data, err := json.Marshal(value) + if err != nil { + return "", err + } + return string(data), nil + } +} + +// parseCaptchaCheckResult parses captcha check result from API response +func parseCaptchaCheckResult(resp map[string]interface{}) (*captchaCheckResult, error) { + respObj, ok := resp["response"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid check response: %v", resp) + } + + result := &captchaCheckResult{} + result.Status, _ = respObj["status"].(string) + result.SuccessToken, _ = respObj["success_token"].(string) + result.ShowCaptchaType, _ = respObj["show_captcha_type"].(string) + if result.Status == "" { + return nil, fmt.Errorf("check status missing: %v", resp) + } + + return result, nil +} + +// parseSliderCaptchaContentResponse parses slider captcha content from API response +func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCaptchaContent, error) { + respObj, ok := resp["response"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid slider content response: %v", resp) + } + + status, _ := respObj["status"].(string) + if status != "OK" { + return nil, fmt.Errorf("slider getContent status: %s", status) + } + + extension, _ := respObj["extension"].(string) + extension = strings.ToLower(extension) + if extension != "jpeg" && extension != "jpg" { + return nil, fmt.Errorf("unsupported slider image format: %s", extension) + } + + rawImage, _ := respObj["image"].(string) + if rawImage == "" { + return nil, fmt.Errorf("slider image missing") + } + + rawSteps, ok := respObj["steps"].([]interface{}) + if !ok { + return nil, fmt.Errorf("slider steps missing") + } + + steps, err := parseIntSlice(rawSteps) + if err != nil { + return nil, err + } + + size, swaps, attempts, err := parseSliderSteps(steps) + if err != nil { + return nil, err + } + + img, err := decodeSliderImage(rawImage) + if err != nil { + return nil, err + } + + return &sliderCaptchaContent{ + Image: img, + Size: size, + Steps: swaps, + Attempts: attempts, + }, nil +} + +// parseIntSlice parses a slice of integers from interface{} +func parseIntSlice(raw []interface{}) ([]int, error) { + values := make([]int, 0, len(raw)) + for _, item := range raw { + number, err := parseIntValue(item) if err != nil { return nil, err } - defer resp.Body.Close() + values = append(values, number) + } + return values, nil +} + +// parseIntValue parses a single integer from interface{} +func parseIntValue(raw interface{}) (int, error) { + switch value := raw.(type) { + case float64: + return int(value), nil + case int: + return value, nil + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return 0, fmt.Errorf("invalid numeric value: %v", raw) + } + return parsed, nil + default: + return 0, fmt.Errorf("invalid numeric value: %v", raw) + } +} + +// parseSliderSteps parses slider steps into size, swaps, and attempts +func parseSliderSteps(steps []int) (int, []int, int, error) { + if len(steps) < 3 { + return 0, nil, 0, fmt.Errorf("slider steps payload too short") + } + + size := steps[0] + if size <= 0 { + return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size) + } + + remaining := append([]int(nil), steps[1:]...) + attempts := defaultSliderAttempts + if len(remaining)%2 != 0 { + attempts = remaining[len(remaining)-1] + remaining = remaining[:len(remaining)-1] + } + if attempts <= 0 { + attempts = defaultSliderAttempts + } + if len(remaining) == 0 || len(remaining)%2 != 0 { + return 0, nil, 0, fmt.Errorf("invalid slider swap payload") + } + + return size, remaining, attempts, nil +} + +// decodeSliderImage decodes base64-encoded slider image +func decodeSliderImage(rawImage string) (image.Image, error) { + decoded, err := base64.StdEncoding.DecodeString(rawImage) + if err != nil { + return nil, fmt.Errorf("decode slider image: %w", err) + } + + img, _, err := image.Decode(bytes.NewReader(decoded)) + if err != nil { + return nil, fmt.Errorf("decode slider image: %w", err) + } + + return img, nil +} + +// encodeSliderAnswer encodes slider answer to base64 JSON +func encodeSliderAnswer(activeSteps []int) (string, error) { + payload := struct { + Value []int `json:"value"` + }{ + Value: activeSteps, + } + + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(data), nil +} + +// buildSliderActiveSteps builds active steps for a candidate +func buildSliderActiveSteps(swaps []int, candidateIndex int) []int { + if candidateIndex <= 0 { + return []int{} + } + + end := candidateIndex * 2 + if end > len(swaps) { + end = len(swaps) + } + + return append([]int(nil), swaps[:end]...) +} + +// buildSliderTileMapping builds tile mapping for a candidate +func buildSliderTileMapping(gridSize int, activeSteps []int) ([]int, error) { + tileCount := gridSize * gridSize + if tileCount <= 0 { + return nil, fmt.Errorf("invalid slider tile count: %d", tileCount) + } + if len(activeSteps)%2 != 0 { + return nil, fmt.Errorf("invalid active steps length: %d", len(activeSteps) / 2) + } + + mapping := make([]int, tileCount) + for i := range mapping { + mapping[i] = i + } + + for idx := 0; idx < len(activeSteps); idx += 2 { + left := activeSteps[idx] + right := activeSteps[idx+1] + if left < 0 || right < 0 || left >= tileCount || right >= tileCount { + return nil, fmt.Errorf("slider step out of range: %d,%d", left, right) + } + mapping[left], mapping[right] = mapping[right], mapping[left] + } + + return mapping, nil +} - body, err := io.ReadAll(resp.Body) +// rankSliderCandidates ranks slider candidates by score +func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderCandidate, error) { + candidateCount := len(swaps) / 2 + if candidateCount == 0 { + return nil, fmt.Errorf("slider has no candidates") + } + + candidates := make([]sliderCandidate, 0, candidateCount) + for idx := 1; idx <= candidateCount; idx++ { + activeSteps := buildSliderActiveSteps(swaps, idx) + mapping, err := buildSliderTileMapping(gridSize, activeSteps) if err != nil { return nil, err } - var result map[string]interface{} - if err := json.Unmarshal(body, &result); err != nil { + score, err := scoreSliderCandidate(img, gridSize, mapping) + if err != nil { return nil, err } - return result, nil + + candidates = append(candidates, sliderCandidate{ + Index: idx, + ActiveSteps: activeSteps, + Score: score, + }) } - domain := "vk.com" - baseParams := fmt.Sprintf("session_token=%s&domain=%s&adFp=&access_token=", - url.QueryEscape(sessionToken), url.QueryEscape(domain)) + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Score == candidates[j].Score { + return candidates[i].Index < candidates[j].Index + } + return candidates[i].Score < candidates[j].Score + }) + + return candidates, nil +} - // Step 1: settings - log.Printf("[Captcha] Step 1/4: settings") - _, err := vkReq("captchaNotRobot.settings", baseParams) +// scoreSliderCandidate scores a slider candidate +func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, error) { + rendered, err := renderSliderCandidate(img, gridSize, mapping) if err != nil { - return "", fmt.Errorf("settings failed: %w", err) + return 0, err } - time.Sleep(200 * time.Millisecond) - // Step 2: componentDone - log.Printf("[Captcha] Step 2/4: componentDone") - browserFp := fmt.Sprintf("%032x", rand.Int63()) - deviceJSON := `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1032,"innerWidth":1920,"innerHeight":945,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":16,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"denied"}` - componentData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, url.QueryEscape(deviceJSON)) - _, err = vkReq("captchaNotRobot.componentDone", componentData) - if err != nil { - return "", fmt.Errorf("componentDone failed: %w", err) + return scoreRenderedSliderImage(rendered, gridSize), nil +} + +// renderSliderCandidate renders a slider candidate image +func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image.RGBA, error) { + if gridSize <= 0 { + return nil, fmt.Errorf("invalid grid size: %d", gridSize) } - time.Sleep(200 * time.Millisecond) - // Step 3: check - log.Printf("[Captcha] Step 3/4: check") - cursorJSON := `[{"x":950,"y":500},{"x":945,"y":510},{"x":940,"y":520},{"x":938,"y":525},{"x":938,"y":525}]` - answer := base64.StdEncoding.EncodeToString([]byte("{}")) // e30= - - checkData := baseParams + fmt.Sprintf( - "&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s"+ - "&browser_fp=%s&hash=%s&answer=%s&debug_info=%s", - url.QueryEscape("[]"), // accelerometer - url.QueryEscape("[]"), // gyroscope - url.QueryEscape("[]"), // motion - url.QueryEscape(cursorJSON), // cursor - url.QueryEscape("[]"), // taps - url.QueryEscape("[]"), // connectionRtt - url.QueryEscape("[9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5]"), // connectionDownlink (16 values as in HAR) - browserFp, // browser_fp - hash, // hash (PoW result) - answer, // answer - vkDebugInfo, // debug_info (static) - ) + tileCount := gridSize * gridSize + if len(mapping) != tileCount { + return nil, fmt.Errorf("unexpected tile mapping length: %d", len(mapping)) + } + + bounds := img.Bounds() + rendered := image.NewRGBA(bounds) + for dstIndex, srcIndex := range mapping { + srcRect := sliderTileRect(bounds, gridSize, srcIndex) + dstRect := sliderTileRect(bounds, gridSize, dstIndex) + copyScaledTile(rendered, dstRect, img, srcRect) + } + + return rendered, nil +} + +// scoreRenderedSliderImage scores a rendered slider image +func scoreRenderedSliderImage(img image.Image, gridSize int) int64 { + bounds := img.Bounds() + var score int64 + + for row := 0; row < gridSize; row++ { + for col := 0; col < gridSize-1; col++ { + leftRect := sliderTileRect(bounds, gridSize, row*gridSize+col) + rightRect := sliderTileRect(bounds, gridSize, row*gridSize+col+1) + height := minInt(leftRect.Dy(), rightRect.Dy()) + for offset := 0; offset < height; offset++ { + score += pixelDiff( + img.At(leftRect.Max.X-1, leftRect.Min.Y+offset), + img.At(rightRect.Min.X, rightRect.Min.Y+offset), + ) + } + } + } - checkResp, err := vkReq("captchaNotRobot.check", checkData) + for row := 0; row < gridSize-1; row++ { + for col := 0; col < gridSize; col++ { + topRect := sliderTileRect(bounds, gridSize, row*gridSize+col) + bottomRect := sliderTileRect(bounds, gridSize, (row+1)*gridSize+col) + width := minInt(topRect.Dx(), bottomRect.Dx()) + for offset := 0; offset < width; offset++ { + score += pixelDiff( + img.At(topRect.Min.X+offset, topRect.Max.Y-1), + img.At(bottomRect.Min.X+offset, bottomRect.Min.Y), + ) + } + } + } + + return score +} + +// sliderTileRect returns the rectangle for a tile +func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle { + row := index / gridSize + col := index % gridSize + + x0 := bounds.Min.X + col*bounds.Dx()/gridSize + x1 := bounds.Min.X + (col+1)*bounds.Dx()/gridSize + y0 := bounds.Min.Y + row*bounds.Dy()/gridSize + y1 := bounds.Min.Y + (row+1)*bounds.Dy()/gridSize + + return image.Rect(x0, y0, x1, y1) +} + +// copyScaledTile copies a scaled tile +func copyScaledTile(dst *image.RGBA, dstRect image.Rectangle, src image.Image, srcRect image.Rectangle) { + if dstRect.Empty() || srcRect.Empty() { + return + } + + dstWidth := dstRect.Dx() + dstHeight := dstRect.Dy() + srcWidth := srcRect.Dx() + srcHeight := srcRect.Dy() + + for y := 0; y < dstHeight; y++ { + sy := srcRect.Min.Y + y*srcHeight/dstHeight + for x := 0; x < dstWidth; x++ { + sx := srcRect.Min.X + x*srcWidth/dstWidth + dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, src.At(sx, sy)) + } + } +} + +// pixelDiff calculates pixel difference +func pixelDiff(left color.Color, right color.Color) int64 { + lr, lg, lb, _ := left.RGBA() + rr, rg, rb, _ := right.RGBA() + + return absDiff(lr, rr) + absDiff(lg, rg) + absDiff(lb, rb) +} + +// absDiff calculates absolute difference +func absDiff(left uint32, right uint32) int64 { + if left > right { + return int64(left - right) + } + return int64(right - left) +} + +// generateSliderCursor generates a fake slider cursor +func generateSliderCursor(candidateIndex int, candidateCount int) string { + return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli()) +} + +// buildSliderCursor builds a fake slider cursor +func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) string { + if candidateCount <= 0 { + return "[]" + } + + type cursorPoint struct { + X int `json:"x"` + Y int `json:"y"` + T int64 `json:"t"` + } + + startX := 140 + endX := startX + 620*candidateIndex/candidateCount + startY := 430 + + points := make([]cursorPoint, 0, 12) + for step := 0; step < 12; step++ { + x := startX + (endX-startX)*step/11 + y := startY + ((step % 3) - 1) + points = append(points, cursorPoint{ + X: x, + Y: y, + T: startTime + int64(step*18), + }) + } + + data, err := json.Marshal(points) if err != nil { - return "", fmt.Errorf("check request failed: %w", err) + return "[]" } + return string(data) +} - respObj, ok := checkResp["response"].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("invalid check response: %v", checkResp) +// trySliderCaptchaCandidates tries slider captcha candidates +func trySliderCaptchaCandidates( + candidates []sliderCandidate, + maxAttempts int, + check func(candidate sliderCandidate) (*captchaCheckResult, error), +) (string, error) { + if len(candidates) == 0 { + return "", fmt.Errorf("slider has no ranked candidates") } - status, _ := respObj["status"].(string) - if status != "OK" { - return "", fmt.Errorf("check response status: %s, full response: %v", status, checkResp) + limit := minInt(maxAttempts, len(candidates)) + if limit <= 0 { + return "", fmt.Errorf("slider has no attempts available") } - successToken, ok := respObj["success_token"].(string) - if !ok || successToken == "" { - return "", fmt.Errorf("success_token not found in check response: %v", checkResp) + for idx := 0; idx < limit; idx++ { + result, err := check(candidates[idx]) + if err != nil { + return "", err + } + + switch result.Status { + case "OK": + if result.SuccessToken == "" { + return "", fmt.Errorf("success_token not found") + } + return result.SuccessToken, nil + case "ERROR_LIMIT": + return "", fmt.Errorf("slider check status: %s", result.Status) + default: + continue + } } - time.Sleep(200 * time.Millisecond) - // Step 4: endSession - log.Printf("[Captcha] Step 4/4: endSession") - vkReq("captchaNotRobot.endSession", baseParams) + return "", fmt.Errorf("slider guesses exhausted") +} - return successToken, nil +// minInt returns the minimum of two integers +func minInt(left int, right int) int { + if left < right { + return left + } + return right +} + +// describeCaptchaTypes describes available captcha types +func describeCaptchaTypes(settingsByType map[string]string) string { + if len(settingsByType) == 0 { + return "none" + } + + types := make([]string, 0, len(settingsByType)) + for captchaType := range settingsByType { + types = append(types, captchaType) + } + sort.Strings(types) + return strings.Join(types, ",") +} + +// randInt63 generates a random int63 +func randInt63() int64 { + return rand.Int63() } From d8c30b4ae9ed70bdab77a1ba124d2f0d2a22a2ed Mon Sep 17 00:00:00 2001 From: kiper292 Date: Fri, 17 Apr 2026 01:30:24 +0500 Subject: [PATCH 11/11] sync captcha solver with https://github.com/cacggghp/vk-turn-proxy --- client/main.go | 907 +++++++++++++++++++- client/manual_captcha.go | 66 +- client/namegen.go | 187 ++++ client/profiles.go | 82 ++ client/{vk_captcha.go => slider_captcha.go} | 493 ++--------- client/vk.go | 258 ------ client/wb.go | 65 +- go.mod | 19 + go.sum | 54 ++ 9 files changed, 1414 insertions(+), 717 deletions(-) create mode 100644 client/namegen.go create mode 100644 client/profiles.go rename client/{vk_captcha.go => slider_captcha.go} (60%) delete mode 100644 client/vk.go diff --git a/client/main.go b/client/main.go index 001e127..d971129 100644 --- a/client/main.go +++ b/client/main.go @@ -4,20 +4,35 @@ package main import ( + "bytes" "context" + "crypto/md5" + "crypto/sha256" "crypto/tls" + "encoding/base64" + "encoding/hex" + "encoding/json" "flag" "fmt" + "io" "log" + "math/rand" "net" + neturl "net/url" "os" "os/signal" + "strconv" "strings" "sync" "sync/atomic" "syscall" "time" + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" + + "github.com/bschaatsbergen/dnsdialer" "github.com/cbeuw/connutil" "github.com/google/uuid" "github.com/pion/dtls/v3" @@ -26,6 +41,851 @@ import ( "github.com/pion/turn/v5" ) +// Global state trackers +var ( + globalCaptchaLockout atomic.Int64 + isDebug bool + manualCaptcha bool + autoCaptchaSliderPOC bool + globalAppCancel context.CancelFunc +) + +type captchaSolveMode int + +const ( + captchaSolveModeAuto captchaSolveMode = iota + captchaSolveModeSliderPOC + captchaSolveModeManual +) + +func captchaSolveModeForAttempt(attempt int, manualOnly bool, enableSliderPOC bool) (captchaSolveMode, bool) { + if manualOnly { + return captchaSolveModeManual, attempt == 0 + } + + switch attempt { + case 0: + return captchaSolveModeAuto, true + case 1: + if enableSliderPOC { + return captchaSolveModeSliderPOC, true + } + return captchaSolveModeManual, true + case 2: + if enableSliderPOC { + return captchaSolveModeManual, true + } + } + + return 0, false +} + +func captchaSolveModeLabel(mode captchaSolveMode) string { + switch mode { + case captchaSolveModeAuto: + return "auto captcha" + case captchaSolveModeSliderPOC: + return "auto captcha slider POC" + case captchaSolveModeManual: + return "manual captcha" + default: + return "captcha" + } +} + +// region Helper: HTTP Headers Injection + +func applyBrowserProfileFhttp(req *fhttp.Request, profile Profile) { + req.Header.Set("User-Agent", profile.UserAgent) + req.Header.Set("sec-ch-ua", profile.SecChUa) + req.Header.Set("sec-ch-ua-mobile", profile.SecChUaMobile) + req.Header.Set("sec-ch-ua-platform", profile.SecChUaPlatform) + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("DNT", "1") +} + +func generateBrowserFp(profile Profile) string { + data := profile.UserAgent + profile.SecChUa + "1920x1080x24" + strconv.FormatInt(time.Now().UnixNano(), 10) + h := md5.Sum([]byte(data)) + return hex.EncodeToString(h[:]) +} + +func getCustomNetDialer() net.Dialer { + return net.Dialer{ + Timeout: 20 * time.Second, + KeepAlive: 30 * time.Second, + Resolver: &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + var d net.Dialer + dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"} + var lastErr error + for _, dns := range dnsServers { + conn, err := d.DialContext(ctx, "udp", dns) + if err == nil { + return conn, nil + } + lastErr = err + } + return nil, lastErr + }, + }, + } +} + +func generateFakeCursor() string { + startX := 600 + rand.Intn(400) + startY := 300 + rand.Intn(200) + startTime := time.Now().UnixMilli() - int64(rand.Intn(2000)+1000) + var points []string + for i := 0; i < 15+rand.Intn(10); i++ { + startX += rand.Intn(15) - 5 + startY += rand.Intn(15) + 2 + startTime += int64(rand.Intn(40) + 10) + points = append(points, fmt.Sprintf(`{"x":%d,"y":%d,"t":%d}`, startX, startY, startTime)) + } + return "[" + strings.Join(points, ",") + "]" +} + +// endregion + +// region Automatic Captcha Solver & Authentication + +type VkCaptchaError struct { + ErrorCode int + ErrorMsg string + CaptchaSid string + CaptchaImg string + RedirectURI string + IsSoundCaptchaAvailable bool + SessionToken string + CaptchaTs string + CaptchaAttempt string +} + +func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { + // Extract error_code + codeFloat, ok := errData["error_code"].(float64) + if !ok { + log.Printf("missing error_code in captcha error data") + return nil + } + code := int(codeFloat) + + // Extract redirect_uri + RedirectURI, ok := errData["redirect_uri"].(string) + if !ok { + log.Printf("missing redirect_uri in captcha error data") + return nil + } + + // Extract captcha_sid + captchaSid, ok := errData["captcha_sid"].(string) + if !ok { + // try numeric + if sidNum, ok2 := errData["captcha_sid"].(float64); ok2 { + captchaSid = fmt.Sprintf("%.0f", sidNum) + } else { + log.Printf("missing captcha_sid in captcha error data") + return nil + } + } + + // Extract captcha_img + captchaImg, ok := errData["captcha_img"].(string) + if !ok { + log.Printf("missing captcha_img in captcha error data") + return nil + } + + // Extract error_msg + errorMsg, ok := errData["error_msg"].(string) + if !ok { + log.Printf("missing error_msg in captcha error data") + return nil + } + + // Extract session token if redirect_uri present + var sessionToken string + if RedirectURI != "" { + if parsed, err := neturl.Parse(RedirectURI); err == nil { + sessionToken = parsed.Query().Get("session_token") + } else { + log.Printf("failed to parse redirect_uri: %v", err) + return nil + } + } + + // Extract is_sound_captcha_available + isSound, ok := errData["is_sound_captcha_available"].(bool) + if !ok { + isSound = false + } + + // Extract captcha_ts + var captchaTs string + if tsFloat, ok := errData["captcha_ts"].(float64); ok { + captchaTs = fmt.Sprintf("%.0f", tsFloat) + } else if tsStr, ok := errData["captcha_ts"].(string); ok { + captchaTs = tsStr + } + + // Extract captcha_attempt + var captchaAttempt string + if attFloat, ok := errData["captcha_attempt"].(float64); ok { + captchaAttempt = fmt.Sprintf("%.0f", attFloat) + } else if attStr, ok := errData["captcha_attempt"].(string); ok { + captchaAttempt = attStr + } + + // Build VkCaptchaError + return &VkCaptchaError{ + ErrorCode: code, + ErrorMsg: errorMsg, + CaptchaSid: captchaSid, + CaptchaImg: captchaImg, + RedirectURI: RedirectURI, + IsSoundCaptchaAvailable: isSound, + SessionToken: sessionToken, + CaptchaTs: captchaTs, + CaptchaAttempt: captchaAttempt, + } +} + +func (e *VkCaptchaError) IsCaptchaError() bool { + return e.ErrorCode == 14 && e.RedirectURI != "" && e.SessionToken != "" +} + +func solveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError, streamID int, client tlsclient.HttpClient, profile Profile, useSliderPOC bool) (string, error) { + if useSliderPOC { + log.Printf("[STREAM %d] [Captcha] Solving captcha with slider POC...", streamID) + } else { + log.Printf("[STREAM %d] [Captcha] Solving captcha...", streamID) + } + + if captchaErr.SessionToken == "" { + return "", fmt.Errorf("no session_token in redirect_uri for auto-solve") + } + if captchaErr.RedirectURI == "" { + return "", fmt.Errorf("no redirect_uri for auto-solve") + } + + bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectURI, client, profile) + if err != nil { + return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err) + } + + log.Printf("[STREAM %d] [Captcha] PoW input: %s, difficulty: %d", streamID, bootstrap.PowInput, bootstrap.Difficulty) + + hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty) + log.Printf("[STREAM %d] [Captcha] PoW solved: hash=%s", streamID, hash) + + var successToken string + if useSliderPOC { + successToken, err = callCaptchaNotRobotWithSliderPOC( + ctx, + captchaErr.SessionToken, + hash, + streamID, + client, + profile, + bootstrap.Settings, + ) + } else { + successToken, err = callCaptchaNotRobot(ctx, captchaErr.SessionToken, hash, streamID, client, profile) + } + if err != nil { + return "", fmt.Errorf("captchaNotRobot API failed: %w", err) + } + + log.Printf("[STREAM %d] [Captcha] Success! Got success_token", streamID) + return successToken, nil +} + +func fetchCaptchaBootstrap(ctx context.Context, redirectURI string, client tlsclient.HttpClient, profile Profile) (*captchaBootstrap, error) { + parsedURL, err := neturl.Parse(redirectURI) + if err != nil { + return nil, err + } + domain := parsedURL.Hostname() + + req, err := fhttp.NewRequestWithContext(ctx, "GET", redirectURI, nil) + if err != nil { + return nil, err + } + + req.Host = domain + applyBrowserProfileFhttp(req, profile) + req.Header.Set("Sec-Fetch-Site", "none") + req.Header.Set("Sec-Fetch-Mode", "navigate") + req.Header.Set("Sec-Fetch-Dest", "document") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return parseCaptchaBootstrapHTML(string(body)) +} + +func solvePoW(powInput string, difficulty int) string { + target := strings.Repeat("0", difficulty) + for nonce := 1; nonce <= 10000000; nonce++ { + data := powInput + strconv.Itoa(nonce) + hash := sha256.Sum256([]byte(data)) + hexHash := hex.EncodeToString(hash[:]) + if strings.HasPrefix(hexHash, target) { + return hexHash + } + } + return "" +} + +func callCaptchaNotRobot(ctx context.Context, sessionToken, hash string, streamID int, client tlsclient.HttpClient, profile Profile) (string, error) { + vkReq := func(method string, postData string) (map[string]interface{}, error) { + reqURL := "https://api.vk.ru/method/" + method + "?v=5.131" + parsedURL, err := neturl.Parse(reqURL) + if err != nil { + return nil, fmt.Errorf("parse request URL: %w", err) + } + domain := parsedURL.Hostname() + + req, err := fhttp.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(postData)) + if err != nil { + return nil, err + } + + req.Host = domain + applyBrowserProfileFhttp(req, profile) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", "https://id.vk.ru") + req.Header.Set("Referer", "https://id.vk.ru/") + 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("Sec-GPC", "1") + req.Header.Set("Priority", "u=1, i") + + httpResp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(httpResp.Body) + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + var resp map[string]interface{} + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + return resp, nil + } + + baseParams := fmt.Sprintf("session_token=%s&domain=vk.com&adFp=&access_token=", neturl.QueryEscape(sessionToken)) + + log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID) + if _, err := vkReq("captchaNotRobot.settings", baseParams); err != nil { + return "", fmt.Errorf("settings failed: %w", err) + } + + time.Sleep(200 * time.Millisecond) + + log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID) + browserFp := generateBrowserFp(profile) + deviceJSON := buildCaptchaDeviceJSON(profile) + componentDoneData := baseParams + fmt.Sprintf("&browser_fp=%s&device=%s", browserFp, neturl.QueryEscape(deviceJSON)) + + if _, err := vkReq("captchaNotRobot.componentDone", componentDoneData); err != nil { + return "", fmt.Errorf("componentDone failed: %w", err) + } + + time.Sleep(200 * time.Millisecond) + + log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID) + cursorJSON := generateFakeCursor() + answer := base64.StdEncoding.EncodeToString([]byte("{}")) + + // Dynamically generate debug_info to avoid static fingerprint bans + debugInfoBytes := md5.Sum([]byte(profile.UserAgent + strconv.FormatInt(time.Now().UnixNano(), 10))) + debugInfo := hex.EncodeToString(debugInfoBytes[:]) + + connectionRtt := "[50,50,50,50,50,50,50,50,50,50]" + connectionDownlink := "[9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5,9.5]" + + checkData := baseParams + fmt.Sprintf( + "&accelerometer=%s&gyroscope=%s&motion=%s&cursor=%s&taps=%s&connectionRtt=%s&connectionDownlink=%s&browser_fp=%s&hash=%s&answer=%s&debug_info=%s", + neturl.QueryEscape("[]"), neturl.QueryEscape("[]"), neturl.QueryEscape("[]"), + neturl.QueryEscape(cursorJSON), neturl.QueryEscape("[]"), neturl.QueryEscape(connectionRtt), + neturl.QueryEscape(connectionDownlink), + browserFp, hash, answer, debugInfo, + ) + + checkResp, err := vkReq("captchaNotRobot.check", checkData) + if err != nil { + return "", fmt.Errorf("check failed: %w", err) + } + + respObj, ok := checkResp["response"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("invalid check response: %v", checkResp) + } + status, ok := respObj["status"].(string) + if !ok || status != "OK" { + return "", fmt.Errorf("check status: %s", status) + } + successToken, ok := respObj["success_token"].(string) + if !ok || successToken == "" { + return "", fmt.Errorf("success_token not found") + } + + time.Sleep(200 * time.Millisecond) + + log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", streamID) + _, err = vkReq("captchaNotRobot.endSession", baseParams) + if err != nil { + log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", streamID, err) + } + + return successToken, nil +} + +func isAuthError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "401") || + strings.Contains(errStr, "Unauthorized") || + strings.Contains(errStr, "authentication") || + strings.Contains(errStr, "invalid credential") || + strings.Contains(errStr, "stale nonce") +} + +func handleAuthError(streamID int) bool { + cache := getStreamCache(streamID) + cacheID := getCacheID(streamID) + + now := time.Now().Unix() + + if now-cache.lastErrorTime.Load() > int64(errorWindow.Seconds()) { + cache.errorCount.Store(0) + } + + count := cache.errorCount.Add(1) + cache.lastErrorTime.Store(now) + + log.Printf("[STREAM %d] Auth error (cache=%d, count=%d/%d)", streamID, cacheID, count, maxCacheErrors) + + if count >= maxCacheErrors { + log.Printf("[VK Auth] Multiple auth errors detected (%d), invalidating cache %d for stream %d...", count, cacheID, streamID) + cache.invalidate(streamID) + return true + } + return false +} + +// region VK Credentials Layer + +type VKCredentials struct { + ClientID string + ClientSecret string +} + +var vkCredentialsList = []VKCredentials{ + {ClientID: "6287487", ClientSecret: "QbYic1K3lEV5kTGiqlq2"}, // VK_WEB_APP_ID + //{ClientID: "7879029", ClientSecret: "aR5NKGmm03GYrCiNKsaw"}, // VK_MVK_APP_ID + //{ClientID: "52461373", ClientSecret: "o557NLIkAErNhakXrQ7A"}, // VK_WEB_VKVIDEO_APP_ID + //{ClientID: "52649896", ClientSecret: "WStp4ihWG4l3nmXZgIbC"}, // VK_MVK_VKVIDEO_APP_ID + //{ClientID: "51781872", ClientSecret: "IjjCNl4L4Tf5QZEXIHKK"}, // VK_ID_AUTH_APP +} + +func vkDelayRandom(minMs, maxMs int) { + ms := minMs + rand.Intn(maxMs-minMs+1) + time.Sleep(time.Duration(ms) * time.Millisecond) +} + +func getVkCredsCached(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) { + cache := getStreamCache(streamID) + cacheID := getCacheID(streamID) + + cache.mutex.RLock() + if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) { + expires := time.Until(cache.creds.ExpiresAt) + u, p, a := cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr + cache.mutex.RUnlock() + if isDebug { + log.Printf("[STREAM %d] [VK Auth] Using cached credentials (cache=%d, expires in %v)", streamID, cacheID, expires) + } + return u, p, a, nil + } + cache.mutex.RUnlock() + + cache.mutex.Lock() + defer cache.mutex.Unlock() + + // Double-check inside lock + if cache.creds.Link == link && time.Now().Before(cache.creds.ExpiresAt) { + return cache.creds.Username, cache.creds.Password, cache.creds.ServerAddr, nil + } + + user, pass, addr, err := fetchVkCredsSerialized(ctx, link, streamID, dialer) + if err != nil { + return "", "", "", err + } + + cache.creds = TurnCredentials{Username: user, Password: pass, ServerAddr: addr, ExpiresAt: time.Now().Add(credentialLifetime - cacheSafetyMargin), Link: link} + return user, pass, addr, nil +} + +var ( + vkRequestMu sync.Mutex + globalLastVkFetchTime time.Time +) + +func fetchVkCredsSerialized(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) { + vkRequestMu.Lock() + defer vkRequestMu.Unlock() + + // Ensure a minimum cooldown between credential requests to avoid VK rate limits + minInterval := 3*time.Second + time.Duration(rand.Intn(3000))*time.Millisecond + elapsed := time.Since(globalLastVkFetchTime) + + if !globalLastVkFetchTime.IsZero() && elapsed < minInterval { + wait := minInterval - elapsed + log.Printf("[STREAM %d] [VK Auth] Throttling: waiting %v to prevent rate limit...", streamID, wait.Truncate(time.Millisecond)) + select { + case <-ctx.Done(): + return "", "", "", ctx.Err() + case <-time.After(wait): + } + } + + defer func() { + globalLastVkFetchTime = time.Now() + }() + + return fetchVkCreds(ctx, link, streamID, dialer) +} + +func fetchVkCreds(ctx context.Context, link string, streamID int, dialer *dnsdialer.Dialer) (string, string, string, error) { + // Check Global Lockout to prevent API bans + if time.Now().Unix() < globalCaptchaLockout.Load() { + return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED: global lockout active") + } + + var lastErr error + jar := tlsclient.NewCookieJar() + + for _, creds := range vkCredentialsList { + log.Printf("[STREAM %d] [VK Auth] Trying credentials: client_id=%s", streamID, creds.ClientID) + + user, pass, addr, err := getTokenChain(ctx, link, streamID, creds, dialer, jar) + + if err == nil { + log.Printf("[STREAM %d] [VK Auth] Success with client_id=%s", streamID, creds.ClientID) + return user, pass, addr, nil + } + + lastErr = err + log.Printf("[STREAM %d] [VK Auth] Failed with client_id=%s: %v", streamID, creds.ClientID, err) + + // Hard abort on captcha/fatal conditions instead of trying next creds + if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") || strings.Contains(err.Error(), "FATAL_CAPTCHA") { + return "", "", "", err + } + + if strings.Contains(err.Error(), "error_code:29") || strings.Contains(err.Error(), "error_code: 29") || strings.Contains(err.Error(), "Rate limit") { + log.Printf("[STREAM %d] [VK Auth] Rate limit detected, trying next credentials...", streamID) + } + } + + return "", "", "", fmt.Errorf("all VK credentials failed: %w", lastErr) +} + +func getTokenChain(ctx context.Context, link string, streamID int, creds VKCredentials, dialer *dnsdialer.Dialer, jar tlsclient.CookieJar) (string, string, string, 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.WithCookieJar(jar), + tlsclient.WithDialer(getCustomNetDialer()), + ) + if err != nil { + return "", "", "", fmt.Errorf("failed to initialize tls_client: %w", err) + } + + name := generateName() + escapedName := neturl.QueryEscape(name) + + log.Printf("[STREAM %d] [VK Auth] Connecting Identity - Name: %s | User-Agent: %s", streamID, name, profile.UserAgent) + + doRequest := func(data string, url string) (resp map[string]interface{}, err error) { + parsedURL, err := neturl.Parse(url) + if err != nil { + return nil, fmt.Errorf("parse request URL: %w", err) + } + domain := parsedURL.Hostname() + + req, err := fhttp.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer([]byte(data))) + if err != nil { + return nil, err + } + + req.Host = domain + applyBrowserProfileFhttp(req, profile) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "*/*") + req.Header.Set("Origin", "https://vk.ru") + req.Header.Set("Referer", "https://vk.ru/") + 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("Priority", "u=1, i") + + httpResp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { + if closeErr := httpResp.Body.Close(); closeErr != nil { + log.Printf("close response body: %s", closeErr) + } + }() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(body, &resp) + if err != nil { + return nil, err + } + return resp, nil + } + + // Token 1 + data := fmt.Sprintf("client_id=%s&token_type=messages&client_secret=%s&version=1&app_id=%s", creds.ClientID, creds.ClientSecret, creds.ClientID) + resp, err := doRequest(data, "https://login.vk.ru/?act=get_anonym_token") + if err != nil { + return "", "", "", err + } + dataMap, ok := resp["data"].(map[string]interface{}) + if !ok { + return "", "", "", fmt.Errorf("unexpected anon token response: %v", resp) + } + token1, ok := dataMap["access_token"].(string) + if !ok { + return "", "", "", fmt.Errorf("missing access_token in response: %v", resp) + } + + vkDelayRandom(100, 150) + + // Token 1 -> getCallPreview + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&fields=photo_200&access_token=%s", link, token1) + _, err = doRequest(data, "https://api.vk.ru/method/calls.getCallPreview?v=5.275&client_id="+creds.ClientID) + if err != nil { + log.Printf("[STREAM %d] [VK Auth] Warning: getCallPreview failed: %v", streamID, err) + } + + vkDelayRandom(200, 400) + + // Token 2 + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&access_token=%s", link, escapedName, token1) + urlAddr := fmt.Sprintf("https://api.vk.ru/method/calls.getAnonymousToken?v=5.275&client_id=%s", creds.ClientID) + + var token2 string + for attempt := 0; ; attempt++ { + resp, err = doRequest(data, urlAddr) + if err != nil { + return "", "", "", err + } + + if errObj, hasErr := resp["error"].(map[string]interface{}); hasErr { + captchaErr := ParseVkCaptchaError(errObj) + if captchaErr != nil && captchaErr.IsCaptchaError() { + solveMode, hasSolveMode := captchaSolveModeForAttempt(attempt, manualCaptcha, autoCaptchaSliderPOC) + if !hasSolveMode { + log.Printf("[STREAM %d] [Captcha] No more solve modes available (attempt %d)", streamID, attempt+1) + + // Engage global lockout to protect API + globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix()) + + return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED") + } + + var successToken string + var captchaKey string + var solveErr error + + switch solveMode { + case captchaSolveModeAuto: + if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" { + successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, false) + if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] Auto captcha failed: %v", streamID, solveErr) + } + } else { + solveErr = fmt.Errorf("missing fields for auto solve") + } + case captchaSolveModeSliderPOC: + if captchaErr.SessionToken != "" && captchaErr.RedirectURI != "" { + successToken, solveErr = solveVkCaptcha(ctx, captchaErr, streamID, client, profile, true) + if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] Auto captcha slider POC failed: %v", streamID, solveErr) + } + } else { + solveErr = fmt.Errorf("missing fields for slider POC auto solve") + } + case captchaSolveModeManual: + log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID) + manualCtx, manualCancel := context.WithTimeout(ctx, 60*time.Second) + + type manualRes struct { + token string + key string + err error + } + resCh := make(chan manualRes, 1) + + go func() { + var t, k string + var e error + if captchaErr.RedirectURI != "" { + t, e = solveCaptchaViaProxy(captchaErr.RedirectURI, dialer) + } else if captchaErr.CaptchaImg != "" { + k, e = solveCaptchaViaHTTP(captchaErr.CaptchaImg) + } else { + e = fmt.Errorf("no redirect_uri or captcha_img") + } + resCh <- manualRes{t, k, e} + }() + + select { + case res := <-resCh: + successToken = res.token + captchaKey = res.key + solveErr = res.err + case <-manualCtx.Done(): + solveErr = fmt.Errorf("manual captcha timed out after 60s") + } + manualCancel() + } + + // If solving failed (auto or manual) or timed out + if solveErr != nil { + log.Printf("[STREAM %d] [Captcha] %s failed (attempt %d): %v", streamID, captchaSolveModeLabel(solveMode), attempt+1, solveErr) + + nextSolveMode, hasNextSolveMode := captchaSolveModeForAttempt(attempt+1, manualCaptcha, autoCaptchaSliderPOC) + if hasNextSolveMode { + log.Printf("[STREAM %d] [Captcha] Falling back to %s...", streamID, captchaSolveModeLabel(nextSolveMode)) + continue + } + + // Engage global lockout to protect API + globalCaptchaLockout.Store(time.Now().Add(60 * time.Second).Unix()) + + return "", "", "", fmt.Errorf("CAPTCHA_WAIT_REQUIRED") + } + + if captchaErr.CaptchaAttempt == "0" || captchaErr.CaptchaAttempt == "" { + captchaErr.CaptchaAttempt = "1" + } + + if captchaKey != "" { + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=%s&captcha_sid=%s&access_token=%s", + link, escapedName, neturl.QueryEscape(captchaKey), captchaErr.CaptchaSid, token1) + } else { + data = fmt.Sprintf("vk_join_link=https://vk.com/call/join/%s&name=%s&captcha_key=&captcha_sid=%s&is_sound_captcha=0&success_token=%s&captcha_ts=%s&captcha_attempt=%s&access_token=%s", + link, escapedName, captchaErr.CaptchaSid, neturl.QueryEscape(successToken), captchaErr.CaptchaTs, captchaErr.CaptchaAttempt, token1) + } + continue + } + return "", "", "", fmt.Errorf("VK API error: %v", errObj) + } + + respMap, okLoop := resp["response"].(map[string]interface{}) + if !okLoop { + return "", "", "", fmt.Errorf("unexpected getAnonymousToken response: %v", resp) + } + token2, okLoop = respMap["token"].(string) + if !okLoop { + return "", "", "", fmt.Errorf("missing token in response: %v", resp) + } + break + } + + vkDelayRandom(100, 150) + + // Token 3 + sessionData := fmt.Sprintf(`{"version":2,"device_id":"%s","client_version":1.1,"client_type":"SDK_JS"}`, uuid.New()) + data = fmt.Sprintf("session_data=%s&method=auth.anonymLogin&format=JSON&application_key=CGMMEJLGDIHBABABA", neturl.QueryEscape(sessionData)) + resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do") + if err != nil { + return "", "", "", err + } + token3, ok := resp["session_key"].(string) + if !ok { + return "", "", "", fmt.Errorf("missing session_key in response: %v", resp) + } + + vkDelayRandom(100, 150) + + // Token 4 -> TURN Creds + data = fmt.Sprintf("joinLink=%s&isVideo=false&protocolVersion=5&capabilities=2F7F&anonymToken=%s&method=vchat.joinConversationByLink&format=JSON&application_key=CGMMEJLGDIHBABABA&session_key=%s", link, token2, token3) + resp, err = doRequest(data, "https://calls.okcdn.ru/fb.do") + if err != nil { + return "", "", "", err + } + + tsRaw, ok := resp["turn_server"].(map[string]interface{}) + if !ok { + return "", "", "", fmt.Errorf("missing turn_server in response: %v", resp) + } + user, ok := tsRaw["username"].(string) + if !ok { + return "", "", "", fmt.Errorf("missing username in turn_server") + } + pass, ok := tsRaw["credential"].(string) + if !ok { + return "", "", "", fmt.Errorf("missing credential in turn_server") + } + urlsRaw, ok := tsRaw["urls"].([]interface{}) + if !ok || len(urlsRaw) == 0 { + return "", "", "", fmt.Errorf("missing or empty urls in turn_server") + } + urlStr, ok := urlsRaw[0].(string) + if !ok { + return "", "", "", fmt.Errorf("turn server url is not a string") + } + + clean := strings.Split(urlStr, "?")[0] + address := strings.TrimPrefix(strings.TrimPrefix(clean, "turn:"), "turns:") + + return user, pass, address, nil +} + +// endregion + func dtlsFunc(ctx context.Context, conn net.PacketConn, peer *net.UDPAddr) (net.Conn, error) { certificate, err := selfsign.GenerateSelfSigned() if err != nil { @@ -402,7 +1262,37 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne c := make(chan error) go oneTurnConnection(ctx, &tp, peer, conn2, c) if err := <-c; err != nil { - log.Printf("%s", err) + if strings.Contains(err.Error(), "FATAL_CAPTCHA") { + log.Printf("[STREAM %d] Fatal manual captcha error. Shutting down application.", streamID) + if globalAppCancel != nil { + globalAppCancel() + } + return + } + if strings.Contains(err.Error(), "CAPTCHA_WAIT_REQUIRED") { + if !strings.Contains(err.Error(), "global lockout active") { + log.Printf("[STREAM %d] Backing off for 60 seconds to avoid IP ban...", streamID) + select { + case <-ctx.Done(): + return + case <-time.After(60 * time.Second): + } + } else { + lockoutEnd := globalCaptchaLockout.Load() + sleepDuration := time.Until(time.Unix(lockoutEnd, 0)) + if sleepDuration < 0 { + sleepDuration = 5 * time.Second + } + select { + case <-ctx.Done(): + return + case <-time.After(sleepDuration): + } + } + } else { + log.Printf("[STREAM %d] %s", streamID, err) + time.Sleep(2 * time.Second) + } } default: } @@ -412,6 +1302,7 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne func main() { //nolint:cyclop ctx, cancel := context.WithCancel(context.Background()) + globalAppCancel = cancel defer cancel() signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGTERM, syscall.SIGINT) @@ -437,6 +1328,8 @@ func main() { //nolint:cyclop direct := flag.Bool("no-dtls", false, "connect without obfuscation. DO NOT USE") v1 := flag.Bool("v1", false, "use v1 server protocol (no session_id and stream_id)") sessionIDFlag := flag.String("session-id", "", "override session ID (hex, 32 chars)") + debugFlag := flag.Bool("debug", false, "enable debug logging") + manualCaptchaFlag := flag.Bool("manual-captcha", false, "skip auto captcha solving, use manual mode immediately") flag.Parse() if *peerAddr == "" { log.Panicf("Need peer address!") @@ -449,9 +1342,19 @@ func main() { //nolint:cyclop log.Panicf("Need either -wb or -vk-link!") } + isDebug = *debugFlag + manualCaptcha = *manualCaptchaFlag + autoCaptchaSliderPOC = !manualCaptcha + var link string var getCreds getCredsFunc + dialer := dnsdialer.New( + dnsdialer.WithResolvers("77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"), + dnsdialer.WithStrategy(dnsdialer.Fallback{}), + dnsdialer.WithCache(100, 10*time.Hour, 10*time.Hour), + ) + if *wb { link = "wb" getCreds = func(ctx context.Context, lk string, streamID int) (string, string, string, error) { @@ -461,7 +1364,7 @@ func main() { //nolint:cyclop parts := strings.Split(*vklink, "join/") link = parts[len(parts)-1] getCreds = func(ctx context.Context, lk string, streamID int) (string, string, string, error) { - return getCredsCached(ctx, lk, streamID, getVkCreds) + return getVkCredsCached(ctx, lk, streamID, dialer) } } diff --git a/client/manual_captcha.go b/client/manual_captcha.go index bb11d1b..826478c 100644 --- a/client/manual_captcha.go +++ b/client/manual_captcha.go @@ -17,6 +17,8 @@ import ( "runtime" "strings" "time" + + "github.com/bschaatsbergen/dnsdialer" ) const captchaListenPort = "8765" @@ -123,6 +125,7 @@ func rewriteProxyRequest(req *http.Request, targetURL *neturl.URL) { req.Host = targetURL.Host req.Header.Del("Accept-Encoding") + req.Header.Del("TE") // Disable transfer encoding compression for _, headerName := range []string{"Origin", "Referer"} { if rewritten := rewriteProxyHeaderURL(req.Header.Get(headerName), targetURL); rewritten != "" { req.Header.Set(headerName, rewritten) @@ -332,6 +335,21 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string { } } +func newCaptchaProxyTransport(dialer *dnsdialer.Dialer) *http.Transport { + transport := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ForceAttemptHTTP2: false, + } + if dialer != nil { + transport.DialContext = dialer.DialContext + } + return transport +} + func startCaptchaServer(srv *http.Server, logPrefix string) error { var listenErrs []string var listening bool @@ -357,6 +375,7 @@ func startCaptchaServer(srv *http.Server, logPrefix string) error { return fmt.Errorf("captcha listeners failed: %s", strings.Join(listenErrs, "; ")) } +// runCaptchaServerAndWait triggers the browser, and waiting gracefully for the solution token. func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-chan string, logPrefix string) (string, error) { srv := &http.Server{Handler: handler} @@ -383,6 +402,7 @@ func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-ch return key, nil } +// notifyKey pushes the key string to the given channel without blocking func notifyKey(keyCh chan<- string, key string) { if key != "" { select { @@ -423,7 +443,7 @@ button{font-size:24px;padding:12px 32px;margin-top:12px;cursor:pointer} return runCaptchaServerAndWait(mux, localCaptchaOrigin(), keyCh, "captcha HTTP server error") } -func solveCaptchaViaProxy(redirectURI string) (string, error) { +func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string, error) { keyCh := make(chan string, 1) targetURL, err := neturl.Parse(redirectURI) @@ -431,14 +451,7 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { return "", fmt.Errorf("invalid redirect URI: %v", err) } - transport := &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - ForceAttemptHTTP2: true, - } + transport := newCaptchaProxyTransport(dialer) proxy := &httputil.ReverseProxy{ Transport: transport, @@ -446,16 +459,17 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { rewriteProxyRequest(req.Out, targetURL) }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { - log.Printf("captcha proxy error for %s: %v", r.URL.String(), err) + log.Printf("[Captcha Proxy] ERROR for %s %s: %v", r.Method, r.URL.String(), err) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadGateway) - _, _ = fmt.Fprintf(w, `

Captcha proxy error

%v

`, err) + _, _ = fmt.Fprintf(w, `

Captcha proxy error

%s %s

%v

`, r.Method, r.URL.String(), err) }, ModifyResponse: func(res *http.Response) error { rewriteProxyCookies(res.Header) if res.StatusCode >= 300 && res.StatusCode < 400 { if loc := res.Header.Get("Location"); loc != "" { + log.Printf("[Captcha Proxy] Redirecting to: %s", loc) if rewritten, ok := rewriteProxyRedirectLocation(loc, targetURL); ok { res.Header.Set("Location", rewritten) } else { @@ -465,7 +479,13 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { } contentType := res.Header.Get("Content-Type") - shouldInspectBody := strings.Contains(contentType, "text/html") || strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") + contentEncoding := res.Header.Get("Content-Encoding") + log.Printf("[Captcha Proxy] %s %d | Content-Type: %q, Encoding: %q", res.Request.Method, res.StatusCode, contentType, contentEncoding) + + shouldInspectBody := strings.Contains(contentType, "text/html") || + strings.Contains(contentType, "application/xhtml+xml") || + strings.Contains(res.Request.URL.Path, "captchaNotRobot.check") + if !shouldInspectBody { return nil } @@ -476,7 +496,9 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { if err == nil { reader = gzReader defer func() { - _ = gzReader.Close() + if err := gzReader.Close(); err != nil { + log.Printf("failed to close gzip reader: %v", err) + } }() } } @@ -503,6 +525,8 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { "Cross-Origin-Embedder-Policy", "Cross-Origin-Resource-Policy", "X-Frame-Options", + "Strict-Transport-Security", + "Alt-Svc", } { res.Header.Del(headerName) } @@ -521,7 +545,7 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { mux := http.NewServeMux() mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) { - notifyKey(keyCh, r.FormValue("token")) + notifyKey(keyCh, r.FormValue("token")) // r.FormValue automatically parses the form w.Header().Set("Access-Control-Allow-Origin", "*") _, _ = fmt.Fprint(w, "ok") }) @@ -545,7 +569,9 @@ func solveCaptchaViaProxy(redirectURI string) (string, error) { }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + log.Printf("[Captcha Proxy] HTTP %s %s", r.Method, r.URL.String()) if r.URL.Path == "/" && targetURL.Path != "" && targetURL.Path != "/" && r.URL.RawQuery == "" { + log.Printf("[Captcha Proxy] Redirecting ROOT to: %s", localCaptchaURLForTarget(targetURL)) http.Redirect(w, r, localCaptchaURLForTarget(targetURL), http.StatusTemporaryRedirect) return } @@ -574,6 +600,18 @@ func browserOpenCommands(goos string, url string) []browserCommand { {name: "xdg-open", args: []string{url}}, {name: "gio", args: []string{"open", url}}, } + case "android": + return []browserCommand{ + {name: "termux-open-url", args: []string{url}}, + {name: "/system/bin/am", args: []string{"start", "-a", "android.intent.action.VIEW", "-d", url}}, + {name: "am", args: []string{"start", "-a", "android.intent.action.VIEW", "-d", url}}, + {name: "xdg-open", args: []string{url}}, + } + case "ios": + return []browserCommand{ + {name: "open", args: []string{url}}, + {name: "uiopen", args: []string{url}}, + } } return nil } diff --git a/client/namegen.go b/client/namegen.go new file mode 100644 index 0000000..0593c9d --- /dev/null +++ b/client/namegen.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + "math/rand" + "strings" +) + +var maleFirstNames = []string{ + "Александр", + "Алексей", + "Андрей", + "Антон", + "Арсений", + "Артур", + "Артём", + "Богдан", + "Валерий", + "Василий", + "Виктор", + "Владислав", + "Глеб", + "Григорий", + "Даниил", + "Денис", + "Дмитрий", + "Евгений", + "Егор", + "Иван", + "Игорь", + "Илья", + "Кирилл", + "Леонид", + "Максим", + "Марк", + "Матвей", + "Михаил", + "Никита", + "Николай", + "Олег", + "Павел", + "Пётр", + "Роман", + "Руслан", + "Сергей", + "Станислав", + "Тимофей", + "Фёдор", +} + +var femaleFirstNames = []string{ + "Алина", + "Алёна", + "Анастасия", + "Ангелина", + "Анна", + "Вера", + "Вероника", + "Виктория", + "Дарья", + "Ева", + "Екатерина", + "Елена", + "Елизавета", + "Ирина", + "Кира", + "Кристина", + "Ксения", + "Любовь", + "Маргарита", + "Марина", + "Мария", + "Милана", + "Надежда", + "Наталья", + "Ольга", + "Полина", + "Светлана", + "София", + "Татьяна", + "Юлия", + "Яна", +} + +var lastNames = []string{ + "Алексеев", + "Андреев", + "Антонов", + "Баранов", + "Белов", + "Белый", + "Бельский", + "Беляев", + "Борисов", + "Васильев", + "Великий", + "Волков", + "Воробьёв", + "Григорьев", + "Давыдов", + "Егоров", + "Жуков", + "Зайцев", + "Захаров", + "Иванов", + "Калинин", + "Ковалёв", + "Козлов", + "Комаров", + "Крамской", + "Кузнецов", + "Кузьмин", + "Лебедев", + "Макаров", + "Медведев", + "Михайлов", + "Морозов", + "Никитин", + "Николаев", + "Новиков", + "Орлов", + "Островский", + "Павлов", + "Петров", + "Покровский", + "Попов", + "Раевский", + "Романов", + "Семёнов", + "Сергеев", + "Смирнов", + "Соколов", + "Соловьёв", + "Степанов", + "Тарасов", + "Титов", + "Толстой", + "Трубецкой", + "Филиппов", + "Фролов", + "Фёдоров", + "Чайковский", + "Черный", + "Яковлев", +} + +// convertToFemaleSurname handles Russian suffix rules +func convertToFemaleSurname(surname string) string { + // Handle adjective-style surnames: + if strings.HasSuffix(surname, "ий") || strings.HasSuffix(surname, "ый") || strings.HasSuffix(surname, "ой") { + return surname[:len(surname)-4] + "ая" + } + + // Handle standard possessive surnames: + if strings.HasSuffix(surname, "ов") || strings.HasSuffix(surname, "ев") || + strings.HasSuffix(surname, "ин") || strings.HasSuffix(surname, "ын") || + strings.HasSuffix(surname, "ёв") { + return surname + "а" + } + + // Foreign or unchangeable + return surname +} + +func generateName() string { + // Decide gender first + isFemale := rand.Intn(2) == 0 + + var fn string + if isFemale { + fn = femaleFirstNames[rand.Intn(len(femaleFirstNames))] + } else { + fn = maleFirstNames[rand.Intn(len(maleFirstNames))] + } + + // 70% chance to have a last name + if rand.Float32() < 0.3 { + return fn + } + + ln := lastNames[rand.Intn(len(lastNames))] + if isFemale { + ln = convertToFemaleSurname(ln) + } + + return fmt.Sprintf("%s %s", fn, ln) +} diff --git a/client/profiles.go b/client/profiles.go new file mode 100644 index 0000000..01d4f0c --- /dev/null +++ b/client/profiles.go @@ -0,0 +1,82 @@ +package main + +import ( + "math/rand" +) + +type Profile struct { + UserAgent string + SecChUa string + SecChUaMobile string + SecChUaPlatform string +} + +// profiles contain paired User-Agent and Client Hints strings to harden bot detection. +var profile = []Profile{ + // Windows Chrome + { + 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: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + + // Windows Edge + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Microsoft Edge";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + { + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Microsoft Edge";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Windows"`, + }, + + // macOS Chrome + { + UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"macOS"`, + }, + { + UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="145", "Not-A.Brand";v="99", "Google Chrome";v="145"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"macOS"`, + }, + + // Linux Chrome + { + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Linux"`, + }, + { + UserAgent: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + SecChUa: `"Chromium";v="144", "Not-A.Brand";v="8", "Google Chrome";v="144"`, + SecChUaMobile: "?0", + SecChUaPlatform: `"Linux"`, + }, +} + +// getRandomProfile returns a paired User-Agent and Client Hints profile. +func getRandomProfile() Profile { + return profile[rand.Intn(len(profile))] +} diff --git a/client/vk_captcha.go b/client/slider_captcha.go similarity index 60% rename from client/vk_captcha.go rename to client/slider_captcha.go index 7358cf1..b4644c4 100644 --- a/client/vk_captcha.go +++ b/client/slider_captcha.go @@ -1,25 +1,9 @@ -/* SPDX-License-Identifier: Apache-2.0 - * - * Copyright © 2026 WireGuard LLC. All Rights Reserved. - */ - package main -/* -#include -// These are JNI functions from Android, they won't work in standalone exe -// But we keep the signature for consistency with libwg-go if asked -// extern const char* requestCaptcha(const char* redirect_uri); -*/ -// import "C" // Disabled cgo for standalone compatibility unless strictly needed - import ( "bytes" "context" - "crypto/sha256" - "crypto/tls" "encoding/base64" - "encoding/hex" "encoding/json" "fmt" "image" @@ -27,286 +11,44 @@ import ( _ "image/jpeg" "io" "log" - "math/rand" - "net" - "net/http" - "net/url" + neturl "net/url" "regexp" "sort" "strconv" "strings" - "sync" - "syscall" "time" -) - -// --- Compatibility Shims for Standalone vk-turn-proxy --- - -func turnLog(format string, v ...interface{}) { - log.Printf(format, v...) -} - -// Simple host cache for standalone -var hostCache = &simpleHostCache{} - -type simpleHostCache struct{} - -func (s *simpleHostCache) Resolve(ctx context.Context, host string) (string, error) { - ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) - if err != nil { - return "", err - } - if len(ips) == 0 { - return "", fmt.Errorf("no IP found for %s", host) - } - return ips[0].IP.String(), nil -} - -// No-op protectControl for standalone -func protectControl(network, address string, c syscall.RawConn) error { - return nil -} - -// --- Original libwg-go code (adapted) --- - -// VkCaptchaError represents a VK captcha error -type VkCaptchaError struct { - ErrorCode int - ErrorMsg string - CaptchaSid string - CaptchaImg string - RedirectUri string - IsSoundCaptchaAvailable bool - SessionToken string - CaptchaTs string // captcha_ts from error - CaptchaAttempt string // captcha_attempt from error -} - -// ParseVkCaptchaError parses a VK error response into VkCaptchaError -func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError { - codeFloat, _ := errData["error_code"].(float64) - code := int(codeFloat) - - redirectUri, _ := errData["redirect_uri"].(string) - captchaSid, _ := errData["captcha_sid"].(string) - captchaImg, _ := errData["captcha_img"].(string) - errorMsg, _ := errData["error_msg"].(string) - - // Extract session_token from redirect_uri - var sessionToken string - if redirectUri != "" { - if parsed, err := url.Parse(redirectUri); err == nil { - sessionToken = parsed.Query().Get("session_token") - } - } - - isSound, _ := errData["is_sound_captcha_available"].(bool) - - // captcha_ts can be float64 (scientific notation) or string - var captchaTs string - if tsFloat, ok := errData["captcha_ts"].(float64); ok { - captchaTs = fmt.Sprintf("%.0f", tsFloat) - } else if tsStr, ok := errData["captcha_ts"].(string); ok { - captchaTs = tsStr - } - - // captcha_attempt is usually a float64 - var captchaAttempt string - if attFloat, ok := errData["captcha_attempt"].(float64); ok { - captchaAttempt = fmt.Sprintf("%.0f", attFloat) - } else if attStr, ok := errData["captcha_attempt"].(string); ok { - captchaAttempt = attStr - } - - return &VkCaptchaError{ - ErrorCode: code, - ErrorMsg: errorMsg, - CaptchaSid: captchaSid, - CaptchaImg: captchaImg, - RedirectUri: redirectUri, - IsSoundCaptchaAvailable: isSound, - SessionToken: sessionToken, - CaptchaTs: captchaTs, - CaptchaAttempt: captchaAttempt, - } -} - -// IsCaptchaError checks if the error data is a Not Robot Captcha error -func (e *VkCaptchaError) IsCaptchaError() bool { - return e.ErrorCode == 14 && e.RedirectUri != "" && e.SessionToken != "" -} - -// captchaMutex serializes captcha solving to avoid multiple concurrent attempts -var captchaMutex sync.Mutex - -// SolveVkCaptcha solves the VK Not Robot Captcha and returns success_token -// First tries automatic solution, falls back to manual solution if it fails -func SolveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError) (string, error) { - // Serialize captcha solving to avoid multiple concurrent attempts - captchaMutex.Lock() - defer captchaMutex.Unlock() - - turnLog("[Captcha] Solving Not Robot Captcha...") - - // Step 1: Try automatic solution - turnLog("[Captcha] Attempting automatic solution...") - successToken, err := solveVkCaptchaAutomatic(ctx, captchaErr) - if err == nil && successToken != "" { - turnLog("[Captcha] Automatic solution SUCCESS!") - return successToken, nil - } - - turnLog("[Captcha] Automatic solution FAILED: %v", err) - - // Step 2: Fall back to manual solving - turnLog("[Captcha] Triggering manual captcha fallback...") - if captchaErr.RedirectUri != "" { - return solveCaptchaViaProxy(captchaErr.RedirectUri) - } else if captchaErr.CaptchaImg != "" { - return solveCaptchaViaHTTP(captchaErr.CaptchaImg) - } - - return "", fmt.Errorf("no more solve modes available") -} - -// solveVkCaptchaAutomatic performs the automatic captcha solving without UI -func solveVkCaptchaAutomatic(ctx context.Context, captchaErr *VkCaptchaError) (string, error) { - sessionToken := captchaErr.SessionToken - if sessionToken == "" { - return "", fmt.Errorf("no session_token in redirect_uri") - } - - // Step 1: Fetch the captcha HTML page to get powInput - bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectUri) - if err != nil { - return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err) - } - - turnLog("[Captcha] PoW input: %s, difficulty: %d", bootstrap.PowInput, bootstrap.Difficulty) - - // Step 2: Solve PoW - hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty) - turnLog("[Captcha] PoW solved: hash=%s", hash) - - // Step 3: Call captchaNotRobot API with slider POC support - successToken, err := callCaptchaNotRobotWithSliderPOC(ctx, sessionToken, hash, bootstrap.Settings) - if err != nil { - return "", fmt.Errorf("captchaNotRobot API failed: %w", err) - } - - turnLog("[Captcha] Success! Got success_token") - return successToken, nil -} - -// fetchCaptchaBootstrap fetches the captcha HTML page and extracts PoW input, difficulty, and settings -func fetchCaptchaBootstrap(ctx context.Context, redirectUri string) (*captchaBootstrap, error) { - parsedURL, err := url.Parse(redirectUri) - if err != nil { - return nil, fmt.Errorf("failed to parse redirect_uri: %w", err) - } - - domain := parsedURL.Hostname() - resolvedIP, err := hostCache.Resolve(ctx, domain) - if err != nil { - return nil, fmt.Errorf("DNS resolution failed for %s: %w", domain, err) - } - - port := parsedURL.Port() - if port == "" { - port = "443" - } - ipURL := "https://" + resolvedIP + ":" + port + parsedURL.Path - if parsedURL.RawQuery != "" { - ipURL += "?" + parsedURL.RawQuery - } - - req, err := http.NewRequestWithContext(ctx, "GET", ipURL, nil) - if err != nil { - return nil, err - } - req.Host = domain - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - - client := &http.Client{ - Timeout: 20 * time.Second, - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - // Control: protectControl, // Disabled for standalone - }).DialContext, - TLSClientConfig: &tls.Config{ - ServerName: domain, - }, - }, - } - - 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 - } - - html := string(body) - bootstrap, err := parseCaptchaBootstrapHTML(html) - if err != nil { - return nil, err - } - return bootstrap, nil -} - -// solvePoW finds nonce where SHA-256(powInput + nonce) starts with '0' * difficulty -func solvePoW(powInput string, difficulty int) string { - target := strings.Repeat("0", difficulty) - - for nonce := 1; nonce <= 10000000; nonce++ { - data := powInput + strconv.Itoa(nonce) - hash := sha256.Sum256([]byte(data)) - hexHash := hex.EncodeToString(hash[:]) - - if strings.HasPrefix(hexHash, target) { - return hexHash - } - } - - // Fallback: should not happen with difficulty <= 3 - return "" -} + fhttp "github.com/bogdanfinn/fhttp" + tlsclient "github.com/bogdanfinn/tls-client" +) const ( + captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c" sliderCaptchaType = "slider" defaultSliderAttempts = 4 ) -// captchaBootstrap holds parsed captcha bootstrap data -type captchaBootstrap struct { - PowInput string - Difficulty int - Settings *captchaSettingsResponse +type captchaNotRobotSession struct { + ctx context.Context + sessionToken string + hash string + streamID int + client tlsclient.HttpClient + profile Profile + browserFp string } -// captchaSettingsResponse holds captcha settings from VK API type captchaSettingsResponse struct { ShowCaptchaType string SettingsByType map[string]string } -// captchaCheckResult holds the result of a captcha check request type captchaCheckResult struct { Status string SuccessToken string ShowCaptchaType string } -// sliderCaptchaContent holds decoded slider captcha content type sliderCaptchaContent struct { Image image.Image Size int @@ -314,41 +56,39 @@ type sliderCaptchaContent struct { Attempts int } -// sliderCandidate represents a ranked slider candidate type sliderCandidate struct { Index int ActiveSteps []int Score int64 } -// captchaNotRobotSession represents a captcha solving session -type captchaNotRobotSession struct { - ctx context.Context - sessionToken string - hash string - browserFp string +type captchaBootstrap struct { + PowInput string + Difficulty int + Settings *captchaSettingsResponse } -// newCaptchaNotRobotSession creates a new captcha solving session func newCaptchaNotRobotSession( ctx context.Context, sessionToken string, hash string, + streamID int, + client tlsclient.HttpClient, + profile Profile, ) *captchaNotRobotSession { - // Generate random browser fingerprint - browserFp := fmt.Sprintf("%032x", randInt63()) - return &captchaNotRobotSession{ ctx: ctx, sessionToken: sessionToken, hash: hash, - browserFp: browserFp, + streamID: streamID, + client: client, + profile: profile, + browserFp: generateBrowserFp(profile), } } -// baseValues returns base URL values for API requests -func (s *captchaNotRobotSession) baseValues() url.Values { - values := url.Values{} +func (s *captchaNotRobotSession) baseValues() neturl.Values { + values := neturl.Values{} values.Set("session_token", s.sessionToken) values.Set("domain", "vk.com") values.Set("adFp", "") @@ -356,85 +96,34 @@ func (s *captchaNotRobotSession) baseValues() url.Values { return values } -// request makes a VK API request -func (s *captchaNotRobotSession) request(method string, values url.Values) (map[string]interface{}, error) { +func (s *captchaNotRobotSession) request(method string, values neturl.Values) (map[string]interface{}, error) { reqURL := "https://api.vk.ru/method/" + method + "?v=5.131" - parsedURL, err := url.Parse(reqURL) - if err != nil { - return nil, err - } - - domain := parsedURL.Hostname() - resolvedIP, err := hostCache.Resolve(s.ctx, domain) - if err != nil { - return nil, fmt.Errorf("DNS resolution failed for %s: %w", domain, err) - } - - port := parsedURL.Port() - if port == "" { - port = "443" - } - ipURL := "https://" + resolvedIP + ":" + port + parsedURL.Path - if parsedURL.RawQuery != "" { - ipURL += "?" + parsedURL.RawQuery - } - - req, err := http.NewRequestWithContext(s.ctx, "POST", ipURL, strings.NewReader(values.Encode())) + req, err := fhttp.NewRequestWithContext(s.ctx, "POST", reqURL, strings.NewReader(values.Encode())) if err != nil { return nil, err } - req.Host = domain - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) 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", "\"Linux\"") - 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("DNT", "1") - 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("Sec-GPC", "1") - - client := &http.Client{ - Timeout: 20 * time.Second, - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - // Control: protectControl, - }).DialContext, - TLSClientConfig: &tls.Config{ - ServerName: domain, - }, - }, - } - - resp, err := client.Do(req) + httpResp, err := s.client.Do(req) if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { + _ = httpResp.Body.Close() + }() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(httpResp.Body) if err != nil { return nil, err } - var respMap map[string]interface{} - if err := json.Unmarshal(body, &respMap); err != nil { + var resp map[string]interface{} + if err := json.Unmarshal(body, &resp); err != nil { return nil, err } - - return respMap, nil + return resp, nil } -// requestSettings fetches captcha settings from VK API func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, error) { resp, err := s.request("captchaNotRobot.settings", s.baseValues()) if err != nil { @@ -443,11 +132,10 @@ func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, er return parseCaptchaSettingsResponse(resp) } -// requestComponentDone marks the component as done func (s *captchaNotRobotSession) requestComponentDone() error { values := s.baseValues() values.Set("browser_fp", s.browserFp) - values.Set("device", buildCaptchaDeviceJSON()) + values.Set("device", buildCaptchaDeviceJSON(s.profile)) resp, err := s.request("captchaNotRobot.componentDone", values) if err != nil { @@ -464,12 +152,10 @@ func (s *captchaNotRobotSession) requestComponentDone() error { return nil } -// requestCheckboxCheck performs a checkbox-style captcha check func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) { - return s.requestCheck("[]", base64.StdEncoding.EncodeToString([]byte("{}"))) + return s.requestCheck(generateSliderCursor(0, 1), base64.StdEncoding.EncodeToString([]byte("{}"))) } -// requestSliderContent fetches slider captcha content func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) { values := s.baseValues() if sliderSettings != "" { @@ -483,7 +169,6 @@ func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*s return parseSliderCaptchaContentResponse(resp) } -// requestSliderCheck performs a slider captcha check func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) { answer, err := encodeSliderAnswer(activeSteps) if err != nil { @@ -493,7 +178,6 @@ func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidate return s.requestCheck(generateSliderCursor(candidateIndex, candidateCount), answer) } -// requestCheck performs the main captcha check request func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) { values := s.baseValues() values.Set("accelerometer", "[]") @@ -506,7 +190,7 @@ func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*ca values.Set("browser_fp", s.browserFp) values.Set("hash", s.hash) values.Set("answer", answer) - values.Set("debug_info", "d44f534ce8deb56ba20be52e05c433309b49ee4d2a70602deeb17a1954257785") + values.Set("debug_info", captchaDebugInfo) resp, err := s.request("captchaNotRobot.check", values) if err != nil { @@ -515,24 +199,25 @@ func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*ca return parseCaptchaCheckResult(resp) } -// requestEndSession ends the captcha session func (s *captchaNotRobotSession) requestEndSession() { - turnLog("[Captcha] Step 4/4: endSession") + log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", s.streamID) if _, err := s.request("captchaNotRobot.endSession", s.baseValues()); err != nil { - turnLog("[Captcha] Warning: endSession failed: %v", err) + log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", s.streamID, err) } } -// callCaptchaNotRobotWithSliderPOC solves captcha with slider POC support func callCaptchaNotRobotWithSliderPOC( ctx context.Context, sessionToken string, hash string, + streamID int, + client tlsclient.HttpClient, + profile Profile, initialSettings *captchaSettingsResponse, ) (string, error) { - session := newCaptchaNotRobotSession(ctx, sessionToken, hash) + session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile) - turnLog("[Captcha] Step 1/4: settings") + log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID) settingsResp, err := session.requestSettings() if err != nil { return "", err @@ -541,14 +226,14 @@ func callCaptchaNotRobotWithSliderPOC( time.Sleep(200 * time.Millisecond) - turnLog("[Captcha] Step 2/4: componentDone") + log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID) if err := session.requestComponentDone(); err != nil { return "", err } time.Sleep(200 * time.Millisecond) - turnLog("[Captcha] Step 3/4: check") + log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID) initialCheck, err := session.requestCheckboxCheck() if err != nil { return "", err @@ -557,14 +242,14 @@ func callCaptchaNotRobotWithSliderPOC( if initialCheck.SuccessToken == "" { return "", fmt.Errorf("success_token not found") } - time.Sleep(200 * time.Millisecond) session.requestEndSession() return initialCheck.SuccessToken, nil } sliderSettings, hasSlider := settingsResp.SettingsByType[sliderCaptchaType] - turnLog( - "[Captcha] Checkbox-style check returned status=%s (settings show_type=%q, check show_type=%q, available_types=%s)", + log.Printf( + "[STREAM %d] [Captcha] Checkbox-style check returned status=%s (settings show_type=%q, check show_type=%q, available_types=%s)", + streamID, initialCheck.Status, settingsResp.ShowCaptchaType, initialCheck.ShowCaptchaType, @@ -572,15 +257,32 @@ func callCaptchaNotRobotWithSliderPOC( ) if !hasSlider { - turnLog( - "[Captcha] Slider settings not found in settings response. Trying getContent without captcha_settings...", + log.Printf( + "[STREAM %d] [Captcha] Slider settings not found in settings response. Trying getContent without captcha_settings...", + streamID, ) } else { - turnLog("[Captcha] Trying experimental slider solver...") + log.Printf("[STREAM %d] [Captcha] Trying experimental slider solver...", streamID) } sliderContent, err := session.requestSliderContent(sliderSettings) if err != nil { + log.Printf( + "[STREAM %d] [Captcha] Slider getContent failed (status: %v). Trying to solve as a checkbox instead...", + streamID, + err, + ) + // Fallback: maybe it's just a checkbox that needs a human-like check + time.Sleep(300 * time.Millisecond) + finalCheck, err2 := session.requestCheckboxCheck() + if err2 == nil && finalCheck.Status == "OK" { + if finalCheck.SuccessToken == "" { + return "", fmt.Errorf("success_token not found in fallback check") + } + log.Printf("[STREAM %d] [Captcha] Fallback checkbox check succeeded!", streamID) + session.requestEndSession() + return finalCheck.SuccessToken, nil + } return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err) } @@ -589,16 +291,18 @@ func callCaptchaNotRobotWithSliderPOC( return "", err } - turnLog( - "[Captcha] Ranked %d slider positions locally; submitting top %d based on attempt budget %d", + log.Printf( + "[STREAM %d] [Captcha] Ranked %d slider positions locally; submitting top %d based on attempt budget %d", + streamID, len(candidates), minInt(sliderContent.Attempts, len(candidates)), sliderContent.Attempts, ) successToken, err := trySliderCaptchaCandidates(candidates, sliderContent.Attempts, func(candidate sliderCandidate) (*captchaCheckResult, error) { - turnLog( - "[Captcha] Slider guess position=%d score=%d", + log.Printf( + "[STREAM %d] [Captcha] Slider guess position=%d score=%d", + streamID, candidate.Index, candidate.Score, ) @@ -608,19 +312,17 @@ func callCaptchaNotRobotWithSliderPOC( return "", err } - time.Sleep(200 * time.Millisecond) session.requestEndSession() return successToken, nil } -// buildCaptchaDeviceJSON builds device information JSON -func buildCaptchaDeviceJSON() string { +func buildCaptchaDeviceJSON(profile Profile) string { return fmt.Sprintf( - `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default"}`, + `{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default","userAgent":"%s","platform":"Win32"}`, + profile.UserAgent, ) } -// parseCaptchaSettingsResponse parses captcha settings from API response func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettingsResponse, error) { respObj, ok := resp["response"].(map[string]interface{}) if !ok { @@ -659,7 +361,6 @@ func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettings return settings, nil } -// parseCaptchaBootstrapHTML parses HTML page to extract PoW input and settings func parseCaptchaBootstrapHTML(html string) (*captchaBootstrap, error) { powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`) powInputMatch := powInputRe.FindStringSubmatch(html) @@ -692,7 +393,6 @@ func parseCaptchaBootstrapHTML(html string) (*captchaBootstrap, error) { }, nil } -// parseCaptchaSettingsFromHTML parses captcha settings from HTML window.init func parseCaptchaSettingsFromHTML(html string) (*captchaSettingsResponse, error) { initRe := regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;\s*window\.lang`) initMatch := initRe.FindStringSubmatch(html) @@ -718,7 +418,6 @@ func parseCaptchaSettingsFromHTML(html string) (*captchaSettingsResponse, error) }) } -// mergeCaptchaSettings merges two captcha settings responses func mergeCaptchaSettings(primary *captchaSettingsResponse, fallback *captchaSettingsResponse) *captchaSettingsResponse { if primary == nil { return cloneCaptchaSettings(fallback) @@ -740,7 +439,6 @@ func mergeCaptchaSettings(primary *captchaSettingsResponse, fallback *captchaSet return primary } -// cloneCaptchaSettings clones a captcha settings response func cloneCaptchaSettings(src *captchaSettingsResponse) *captchaSettingsResponse { if src == nil { return nil @@ -756,7 +454,6 @@ func cloneCaptchaSettings(src *captchaSettingsResponse) *captchaSettingsResponse return cloned } -// expandCaptchaSettings expands raw captcha settings into a slice func expandCaptchaSettings(raw interface{}) ([]interface{}, bool) { switch value := raw.(type) { case nil: @@ -792,7 +489,6 @@ func expandCaptchaSettings(raw interface{}) ([]interface{}, bool) { return nil, false } -// normalizeCaptchaSettings normalizes captcha settings to string func normalizeCaptchaSettings(raw interface{}) (string, error) { switch value := raw.(type) { case nil: @@ -808,7 +504,6 @@ func normalizeCaptchaSettings(raw interface{}) (string, error) { } } -// parseCaptchaCheckResult parses captcha check result from API response func parseCaptchaCheckResult(resp map[string]interface{}) (*captchaCheckResult, error) { respObj, ok := resp["response"].(map[string]interface{}) if !ok { @@ -826,7 +521,6 @@ func parseCaptchaCheckResult(resp map[string]interface{}) (*captchaCheckResult, return result, nil } -// parseSliderCaptchaContentResponse parses slider captcha content from API response func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCaptchaContent, error) { respObj, ok := resp["response"].(map[string]interface{}) if !ok { @@ -877,7 +571,6 @@ func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCapt }, nil } -// parseIntSlice parses a slice of integers from interface{} func parseIntSlice(raw []interface{}) ([]int, error) { values := make([]int, 0, len(raw)) for _, item := range raw { @@ -890,7 +583,6 @@ func parseIntSlice(raw []interface{}) ([]int, error) { return values, nil } -// parseIntValue parses a single integer from interface{} func parseIntValue(raw interface{}) (int, error) { switch value := raw.(type) { case float64: @@ -908,7 +600,6 @@ func parseIntValue(raw interface{}) (int, error) { } } -// parseSliderSteps parses slider steps into size, swaps, and attempts func parseSliderSteps(steps []int) (int, []int, int, error) { if len(steps) < 3 { return 0, nil, 0, fmt.Errorf("slider steps payload too short") @@ -935,7 +626,6 @@ func parseSliderSteps(steps []int) (int, []int, int, error) { return size, remaining, attempts, nil } -// decodeSliderImage decodes base64-encoded slider image func decodeSliderImage(rawImage string) (image.Image, error) { decoded, err := base64.StdEncoding.DecodeString(rawImage) if err != nil { @@ -950,7 +640,6 @@ func decodeSliderImage(rawImage string) (image.Image, error) { return img, nil } -// encodeSliderAnswer encodes slider answer to base64 JSON func encodeSliderAnswer(activeSteps []int) (string, error) { payload := struct { Value []int `json:"value"` @@ -966,7 +655,6 @@ func encodeSliderAnswer(activeSteps []int) (string, error) { return base64.StdEncoding.EncodeToString(data), nil } -// buildSliderActiveSteps builds active steps for a candidate func buildSliderActiveSteps(swaps []int, candidateIndex int) []int { if candidateIndex <= 0 { return []int{} @@ -980,14 +668,13 @@ func buildSliderActiveSteps(swaps []int, candidateIndex int) []int { return append([]int(nil), swaps[:end]...) } -// buildSliderTileMapping builds tile mapping for a candidate func buildSliderTileMapping(gridSize int, activeSteps []int) ([]int, error) { tileCount := gridSize * gridSize if tileCount <= 0 { return nil, fmt.Errorf("invalid slider tile count: %d", tileCount) } if len(activeSteps)%2 != 0 { - return nil, fmt.Errorf("invalid active steps length: %d", len(activeSteps) / 2) + return nil, fmt.Errorf("invalid active steps length: %d", len(activeSteps)) } mapping := make([]int, tileCount) @@ -1007,7 +694,6 @@ func buildSliderTileMapping(gridSize int, activeSteps []int) ([]int, error) { return mapping, nil } -// rankSliderCandidates ranks slider candidates by score func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderCandidate, error) { candidateCount := len(swaps) / 2 if candidateCount == 0 { @@ -1044,7 +730,6 @@ func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderC return candidates, nil } -// scoreSliderCandidate scores a slider candidate func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, error) { rendered, err := renderSliderCandidate(img, gridSize, mapping) if err != nil { @@ -1054,7 +739,6 @@ func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, return scoreRenderedSliderImage(rendered, gridSize), nil } -// renderSliderCandidate renders a slider candidate image func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image.RGBA, error) { if gridSize <= 0 { return nil, fmt.Errorf("invalid grid size: %d", gridSize) @@ -1076,7 +760,6 @@ func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image return rendered, nil } -// scoreRenderedSliderImage scores a rendered slider image func scoreRenderedSliderImage(img image.Image, gridSize int) int64 { bounds := img.Bounds() var score int64 @@ -1112,7 +795,6 @@ func scoreRenderedSliderImage(img image.Image, gridSize int) int64 { return score } -// sliderTileRect returns the rectangle for a tile func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle { row := index / gridSize col := index % gridSize @@ -1125,7 +807,6 @@ func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Recta return image.Rect(x0, y0, x1, y1) } -// copyScaledTile copies a scaled tile func copyScaledTile(dst *image.RGBA, dstRect image.Rectangle, src image.Image, srcRect image.Rectangle) { if dstRect.Empty() || srcRect.Empty() { return @@ -1145,7 +826,6 @@ func copyScaledTile(dst *image.RGBA, dstRect image.Rectangle, src image.Image, s } } -// pixelDiff calculates pixel difference func pixelDiff(left color.Color, right color.Color) int64 { lr, lg, lb, _ := left.RGBA() rr, rg, rb, _ := right.RGBA() @@ -1153,7 +833,6 @@ func pixelDiff(left color.Color, right color.Color) int64 { return absDiff(lr, rr) + absDiff(lg, rg) + absDiff(lb, rb) } -// absDiff calculates absolute difference func absDiff(left uint32, right uint32) int64 { if left > right { return int64(left - right) @@ -1161,12 +840,10 @@ func absDiff(left uint32, right uint32) int64 { return int64(right - left) } -// generateSliderCursor generates a fake slider cursor func generateSliderCursor(candidateIndex int, candidateCount int) string { return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli()) } -// buildSliderCursor builds a fake slider cursor func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) string { if candidateCount <= 0 { return "[]" @@ -1200,7 +877,6 @@ func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) return string(data) } -// trySliderCaptchaCandidates tries slider captcha candidates func trySliderCaptchaCandidates( candidates []sliderCandidate, maxAttempts int, @@ -1237,7 +913,6 @@ func trySliderCaptchaCandidates( return "", fmt.Errorf("slider guesses exhausted") } -// minInt returns the minimum of two integers func minInt(left int, right int) int { if left < right { return left @@ -1245,7 +920,6 @@ func minInt(left int, right int) int { return right } -// describeCaptchaTypes describes available captcha types func describeCaptchaTypes(settingsByType map[string]string) string { if len(settingsByType) == 0 { return "none" @@ -1258,8 +932,3 @@ func describeCaptchaTypes(settingsByType map[string]string) string { sort.Strings(types) return strings.Join(types, ",") } - -// randInt63 generates a random int63 -func randInt63() int64 { - return rand.Int63() -} diff --git a/client/vk.go b/client/vk.go deleted file mode 100644 index 77d9549..0000000 --- a/client/vk.go +++ /dev/null @@ -1,258 +0,0 @@ -// SPDX-FileCopyrightText: 2023 The Pion community -// 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 { - captchaErr := ParseVkCaptchaError(errMsg) - if captchaErr == nil || !captchaErr.IsCaptchaError() { - return "", "", "", fmt.Errorf("Token 2 VK error: %v", errMsg) - } - - log.Printf("[VK Auth] Captcha detected, solving...") - successToken, solveErr := SolveVkCaptcha(ctx, captchaErr) - 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), - captchaErr.CaptchaSid, - successToken, - captchaErr.CaptchaTs, - captchaErr.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 -} diff --git a/client/wb.go b/client/wb.go index 6beffd5..8b5cd08 100644 --- a/client/wb.go +++ b/client/wb.go @@ -17,12 +17,14 @@ import ( "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" - wbUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" + wbBase = "https://stream.wb.ru" ) // WbTurnCred stores a single TURN credential @@ -48,32 +50,19 @@ func wbFetch(ctx context.Context, link string) (string, string, string, error) { 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) { +// 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 := http.NewRequestWithContext(ctx, method, wbBase+ep, rd) + rq, err := fhttp.NewRequestWithContext(ctx, method, wbBase+ep, rd) if err != nil { return nil, err } - rq.Header.Set("User-Agent", wbUA) + 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) @@ -113,13 +102,26 @@ func wbReq(ctx context.Context, client *http.Client, method, ep string, body []b // fetchWbCreds performs the full WB credential acquisition flow func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { - client := wbHTTPClient() - defer client.CloseIdleConnections() + 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, "POST", "/auth/api/v1/auth/user/guest-register", + 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) @@ -137,7 +139,7 @@ func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { 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", + 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 { @@ -160,14 +162,14 @@ func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { 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), + _, 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, "GET", fmt.Sprintf( + 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 { @@ -185,7 +187,7 @@ func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { } log.Println("[WB Auth] Step 5: Negotiating ICE (LiveKit)...") - creds, err := wbLkICE(ctx, tok.RoomToken) + creds, err := wbLkICE(ctx, tok.RoomToken, profile.UserAgent) if err != nil { return nil, fmt.Errorf("livekit ICE: %w", err) } @@ -198,17 +200,18 @@ func fetchWbCreds(ctx context.Context) ([]WbTurnCred, error) { } // wbLkICE connects to LiveKit WebSocket and extracts TURN credentials -func wbLkICE(ctx context.Context, token string) ([]WbTurnCred, error) { +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, http.Header{ - "User-Agent": {wbUA}, - "Origin": {wbBase}, - }) + }).DialContext(ctx, u, header) if err != nil { return nil, err } diff --git a/go.mod b/go.mod index 57582dd..135e112 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/cacggghp/vk-turn-proxy go 1.25.5 require ( + github.com/bogdanfinn/fhttp v0.6.8 + github.com/bogdanfinn/tls-client v1.14.0 + github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45 github.com/cbeuw/connutil v1.0.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 @@ -12,10 +15,26 @@ require ( ) require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/bdandy/go-socks4 v1.2.3 // indirect + github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect + github.com/bogdanfinn/utls v1.7.7-barnius // indirect + github.com/bogdanfinn/websocket v1.5.5-barnius // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/miekg/dns v1.1.69 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/stun/v3 v3.1.1 // indirect github.com/pion/transport/v4 v4.0.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/crypto v0.47.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index 92594c3..5f68b76 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,37 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= +github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= +github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= +github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI= +github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4= +github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg= +github.com/bogdanfinn/tls-client v1.14.0 h1:vyk7Cn4BIvLAGVuMfb0tP22OqogfO1lYamquQNEZU1A= +github.com/bogdanfinn/tls-client v1.14.0/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM= +github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU= +github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg= +github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI= +github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI= +github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45 h1:0b2i5TvZm8FVcuHP1288k+DEu1XM26DtRjcidOxpGXs= +github.com/bschaatsbergen/dnsdialer v0.0.0-20251225104348-3e7610e8ea45/go.mod h1:NU7MdmhQD8Ounc0760w90fL6nxI2lxjlnIaN6qWzNIU= github.com/cbeuw/connutil v1.0.1 h1:LWuNYjwm7JEDYG/ISAO1TfU4G+q2dA5NhR97eq2roCA= github.com/cbeuw/connutil v1.0.1/go.mod h1:lKofNtrW7Atmosgp1eNnTt2j2NjA2IkifapgLVI1QtA= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= +github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= @@ -20,17 +46,45 @@ github.com/pion/turn/v5 v5.0.2 h1:GHlDk+fiegz+yibb3ch+tK+iPFokoVWiM+aVJakySqA= github.com/pion/turn/v5 v5.0.2/go.mod h1:cumcsSEF2ytAtDhDwkYgYhv1uJ3AOP7a4pFt0NL/snY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=