Lionheart: как я спрятал SOCKS5-туннель внутри видеоконференции Wildberries
- среда, 1 апреля 2026 г. в 00:00:07
Привет, Хабр. Я написал SOCKS5-прокси, который прячет весь трафик внутри WebRTC TURN-сессий видеоплатформы WB Stream (stream.wb.ru — да, у Wildberries есть свой аналог Телемоста, я тоже прифигел). Для любого наблюдателя трафик выглядит как обычная видеоконференция.
В этом посте — полный разбор: как я отреверсил HTTP API платформы, зачем написал свой protobuf-парсер на 40 строк, и как KCP-соединение с VPS пролезает через чужой TURN-сервер.
Репозиторий: github.com/jaykaiperson/lionheart
Дисклеймер. Проект написан для изучения WebRTC, TURN и KCP. Автор не рекомендует использовать его для обхода сетевых ограничений. То, что туннель пробивает DPI, файрволы и белые списки на гигабитных скоростях — непредвиденный побочный эффект архитектуры. Если знаете, как технически ограничить эту возможность — пишите в issues, буду искренне рад.
Концепция не новая. Есть отличный vk-turn-proxy, который делает похожую штуку с TURN-серверами VK Calls и Яндекс Телемоста. Там можно завернуть WireGuard или Hysteria через TURN, есть параллельные стримы, гибкая настройка. Но для запуска нужно вручную генерировать invite-ссылку на звонок, передавать её через флаги, разбираться с параметрами.
Мне хотелось сделать вещь, которая работает в одну команду. Запустил бинарник на сервере — получил ключ. Вставил ключ на клиенте — прокси работает. Без ссылок, без регистрации, без API-ключей. Всё автоматом. Моя цель — сделать массовое решение, которое сможет настроить каждый.
Ключевое решение — не запускать браузер. Первый прототип использовал headless Chrome через chromedp: заходил на сайт, кликал кнопки, перехватывал ICE-серверы из RTCPeerConnection. Это работало, но старт занимал 30 секунд, бинарник весил 50 МБ, и на серверах без GUI требовался установленный Chrome. Я сел реверсить API и выяснил, что браузер вообще не нужен.
Для начала я написал сниффер — puppeteer-скрипт, который открывал настоящий Chrome с вкладками на нескольких видеоплатформах. Я вручную создавал встречи, а скрипт молча логировал все HTTP-ответы и перехватывал WebRTC через monkey-patching RTCPeerConnection и WebSocket. Никакой автоматики — просто пассивный сбор данных.
Результат по WB Stream:
[API] 200 /auth/api/v1/auth/user/guest-register [API] 200 /api-room/api/v2/room [API] 200 /api-room/api/v1/room/{id}/join [API] 200 /api-room-manager/api/v1/room/{id}/token [WS] wss://wbstream01-el.wb.ru:7880/rtc?access_token=... [ICE] turn:wb-stream-turn-1.wb.ru:3478 user=eeaMmFicg5GYwVhscg2R pass=xtj4wgmXKcfu1Y6ulhg8
Четыре HTTP-запроса и один WebSocket. Главная страница stream.wb.ru возвращает 498 на запрос без браузера, но API-эндпоинты отвечают нормально — никаких cookies, CSRF, капч, проверок TLS fingerprint.
Дальше я воспроизвёл весь флоу на чистом Go http.Client.
Шаг 1 — регистрация гостя. POST с произвольным displayName. В ответ — JWT.
rr, _ := wb(cl, "POST", "/auth/api/v1/auth/user/guest-register", []byte(`{"displayName":"lh_42"}`), "") // → {"accessToken":"eyJhbGciOiJIUzI1NiIs..."}
Шаг 2 — создание комнаты. Тут я споткнулся. Пустой {} в теле отдаёт 400: invalid CreateRoomV2Request.RoomType: value must not be in list [ROOM_TYPE_UNSPECIFIED]. Починил — и тут же второй 400: invalid CreateRoomV2Request.RoomPrivacy. Значения нашёл в дампе существующей комнаты.
wb(cl, "POST", "/api-room/api/v2/room", []byte(`{"roomType":"ROOM_TYPE_ALL_ON_SCREEN","roomPrivacy":"ROOM_PRIVACY_FREE"}`), token) // → {"roomId":"019d2564-5f22-7af9-bce4-8799f61ca569"}
Шаг 3 — вход в комнату. POST, не GET. На GET возвращается 501 Method Not Allowed — ещё один грабль.
wb(cl, "POST", fmt.Sprintf("/api-room/api/v1/room/%s/join", roomID), []byte("{}"), token)
Шаг 4 — LiveKit-токен. GET-запрос с query-параметрами. Первая попытка вернула бинарный мусор — оказалось, Go http.Client по умолчанию посылает Accept-Encoding: gzip, сервер честно жмёт ответ, а Go не распаковывает автоматически, потому что я задал кастомный Transport. Убрал заголовок — заработало.
wb(cl, "GET", fmt.Sprintf( "/api-room-manager/api/v1/room/%s/token?deviceType=PARTICIPANT_DEVICE_TYPE_WEB_DESKTOP&displayName=lh_42", roomID), nil, token) // → {"roomToken":"eyJleHAiOjE3NzQ0NDQwODQs..."}
Декодирую payload JWT — и вижу "iss":"APIefx2BJbD3hvw". Это ключ LiveKit API. WB Stream работает на LiveKit — open-source WebRTC SFU. Протокол документирован, исходники открыты. Это сильно упростило следующий шаг.
LiveKit общается по WebSocket на protobuf. Клиент подключается к wss://wbstream01-el.wb.ru:7880/rtc с токеном в query string, и сервер сразу присылает SignalResponse с JoinResponse внутри — там параметры комнаты, участники и, главное, ICE-серверы.
u := "wss://wbstream01-el.wb.ru:7880/rtc?access_token=" + token + "&auto_subscribe=1&sdk=js&version=2.15.3&protocol=16&adaptive_stream=1" conn, _, _ := websocket.Dial(u, headers)
Параметры sdk=js&version=2.15.3&protocol=16 — копия того, что посылает браузерный SDK. С protocol=13 сервер молчит. С protocol=16 — сразу отвечает. Подбирал экспериментально.
Теперь нужно распарсить protobuf. Тащить google.golang.org/protobuf, генерить код из .proto-файлов, подтягивать весь livekit/protocol — перебор для трёх вложенных полей. Мне нужна вот эта структура:
SignalResponse { field 1 (bytes) = JoinResponse { field 5 (repeated bytes) = ICEServer { field 1 (repeated string) = urls field 2 (string) = username field 3 (string) = credential } } }
Protobuf wire format устроен не так страшно, как кажется. Каждое поле начинается с varint-тега: (field_number << 3) | wire_type. Wire type 0 — varint, 2 — length-delimited (строки, байты, вложенные сообщения). Для type=2 после тега идёт varint длины и сами байты. Varint — little-endian по 7 бит, старший бит — флаг продолжения.
Парсер varint на Go:
func pbVar(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 }
Функция для извлечения всех полей с нужным номером:
func pbAll(d []byte, f uint64) (r [][]byte) { for o := 0; o < len(d); { t, n := pbVar(d, o) if n == o { break } o = n switch t & 7 { case 0: _, o = pbVar(d, o) // varint — пропускаем case 2: // length-delimited l, n := pbVar(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 // fixed64 case 5: o += 4 // fixed32 default: return } } return }
И хелпер для строки: func pbStr(d []byte, f uint64) string.
Этих трёх функций хватает, чтобы добраться до ICE-серверов. Но тут был ещё один сюрприз. Парсер молча возвращал пустой результат — ICE-серверы не находились. Я добавил отладочный дамп всех полей JoinResponse:
field=1 bytes len=210 — Room field=2 bytes len=724 — Participant field=4 bytes len=5 — "1.9.0" field=5 bytes len=92 — ← ICEServer! field=5 bytes len=92 — ← ICEServer! field=5 bytes len=93 — ← ICEServer! field=5 bytes len=34 — ← ICEServer (STUN) field=6 varint=1
Field 5. В документации LiveKit ICE-серверы — field 9. У WB Stream — field 5. Видимо, старая версия LiveKit или кастомный форк. Парсер теперь проверяет оба варианта:
for _, f := range []uint64{5, 9} { for _, blk := range pbAll(inner, f) { urls := pbAll(blk, 1) // проверяем наличие turn:/stun: в urls un, pw := pbStr(blk, 2), pbStr(blk, 3) // собираем результат } }
Что происходит, когда вы открываете google.com через lionheart:
Браузер шлёт SOCKS5 CONNECT на 127.0.0.1:1080. Клиент lionheart принимает TCP-соединение и открывает yamux-стрим через KCP-сессию. KCP пакует данные в UDP, шифрует AES-256 и отправляет на relay-адрес TURN-сервера wb-stream-turn-1.wb.ru:3478. TURN-сервер WB Stream ретранслирует UDP-пакет на VPS. На VPS KCP расшифровывает, yamux демультиплексирует, SOCKS5-сервер выпускает TCP к google.com. Ответ идёт обратно той же цепочкой.
Для DPI — это UDP-пакеты к TURN-серверу Wildberries. Штатный WebRTC-трафик.
Установка туннеля через TURN-relay:
// свободный UDP-порт (не pre-connected, иначе pion/turn ругается) uc, _ := net.ListenUDP("udp", nil) // авторизация на TURN с перехваченными кредами tc, _ := turn.NewClient(&turn.ClientConfig{ STUNServerAddr: addr, TURNServerAddr: addr, Conn: uc, Username: cred.User, Password: cred.Pass, }) tc.Listen() relay, _ := tc.Allocate() // KCP через relay blk, _ := kcp.NewAESBlockCrypt(key(password)) kc, _ := kcp.NewConn(peerVPS, blk, 10, 3, relay) kc.SetNoDelay(1, 10, 2, 1) kc.SetWindowSize(1024, 1024) kc.SetStreamMode(true) // мультиплексор ym, _ := yamux.Client(kc, cfg)
Красивый момент: kcp.NewConn принимает net.PacketConn, а relay от pion/turn реализует этот интерфейс. KCP вообще не знает, что под ним TURN — просто пишет и читает UDP-пакеты.
Сервер — 40 строк. KCP-листенер с AES, yamux-мультиплексор, SOCKS5 на выходе:
blk, _ := kcp.NewAESBlockCrypt(key(password)) l, _ := kcp.ListenWithOptions(addr, blk, 10, 3) for { s, _ := l.AcceptKCP() s.SetNoDelay(1, 10, 2, 1) s.SetWindowSize(1024, 1024) s.SetStreamMode(true) go func(session *kcp.UDPSession) { ym, _ := yamux.Server(session, cfg) for { stream, _ := ym.AcceptStream() go socks5srv.ServeConn(stream) } }(s) }
Ключ шифрования — SHA-256 от пароля. Пароль генерируется при первом запуске и вшивается в смарт-ключ (base64 от IP|пароль). Один ключ — одна команда для настройки клиента.
Переподключение. Yamux-сессия пингуется каждые 15 секунд. При обрыве — exponential backoff от 2 до 60 секунд. TURN-креды кешируются на 5 минут, после трёх неудачных попыток подключения кеш сбрасывается и весь HTTP-флоу повторяется.
Самоуправление. При запуске программа ищет свои предыдущие экземпляры через pgrep, убивает их и останавливает systemd-сервис, если он запущен. Это решает вечную проблему «порт занят». Если на VPS уже стоит systemd-служба со старым бинарником — при ручном запуске нового unit-файл перезаписывается автоматически.
Ctrl+C. Первое нажатие — graceful shutdown (cancel context, 2 секунды на cleanup). Второе — моментальный os.Exit(0).
Go 1.22+. Chrome не нужен.
go mod tidy && go build -o lionheart .
Сервер (VPS):
./lionheart # режим 1, копируем смарт-ключ, опционально ставим systemd
Клиент:
./lionheart # режим 2, вставляем ключ → SOCKS5 на 127.0.0.1:1080
Адрес для телефона в той же Wi-Fi выводится в консоль автоматически.
Проект экспериментальный. TURN-креды привязаны к одной платформе — если WB Stream поменяет API или закрутит гайки, сломается. LiveKit-хост wbstream01-el.wb.ru захардкожен — если у них балансировка по разным хостам, нужно будет парсить конфиг.
Главный приоритет — мобильный клиент. Весь код — чистый Go без CGO, через gomobile компилируется под Android и iOS. Осталось написать обвязку: на Android — VpnService + TUN-интерфейс, на iOS — NEPacketTunnelProvider.
Второй приоритет — поддержка других платформ. SaluteJazz (jazz.sber.ru) отдаёт контент по HTTP без защиты и не требует регистрации, но TURN-креды приходят через WebSocket, а не HTTP. Конечно, Телемост тоже потенциальный кандидат, но там нужна регистрация.
Репозиторий: github.com/jaykaiperson/lionheart
Вдохновение: vk-turn-proxy — гибкий TURN-прокси для VK и Яндекса, другой подход к той же идее
Буду рад багрепортам и pr <3