В Rocket.Chat 8.4 появились нативные голосовые вызовы. Без Jitsi, без внешних VoIP-провайдеров — просто кнопка звонка в любом личном чате. Мобильные клиенты работали нормально: iOS и Android 4.72+ соединялись через TURN-relay, звук проходил, вызовы завершались корректно. С веб-клиентом всё было иначе. Звонки из Chrome уходили, на телефоне собеседника раздавался звонок, тот принимал вызов — и дальше двенадцать секунд тишины, после чего соединение обрывалось с ошибкой timeout-remote-sdp. На стороне сервера всё выглядело штатно. TURN был доступен, ICE-кандидаты собирались корректно, устройство вызываемого абонента фиксировало acceptedAt. Проблема была где-то в браузере, но одними DevTools её было не увидеть.
В этой статье речь пойдёт о rocketchat-voice-tester — диагностическом инструменте на базе headless Chrome, который я написал, чтобы ответить на один вопрос: сбой голосового вызова — это проблема сервера или браузера? Инструмент перехватывает WebRTC на уровне конструктора, захватывает необработанные DDP-фреймы WebSocket через Chrome DevTools Protocol и показывает, отправил ли браузер SDP offer на сервер. Ниже — архитектура, четыре диагностических скрипта и баг в RC 8.4.1, который этот инструмент обнаружил.
- Проблема: веб-звонки не работают, а мобильные — работают
- Тупики: что мы проверили и исключили
- Как работает инструмент
- Инжекция авторизации через PAT
- Перехват RTCPeerConnection
- Захват DDP-фреймов через CDP
- Четыре диагностических скрипта
- Скрипт 01 — Только ICE
- Скрипт 02 — Исходящий вызов
- Скрипт 03 — Захват DDP (ключевой скрипт)
- Скрипт 04 — Входящий вызов
- Установка и использование
- Корневая причина: отсутствие SDP offer в DDP
- Интерпретация результатов
- Чего инструмент не покажет
- Связанные проекты
- Частые вопросы
- Работает ли инструмент с версиями Rocket.Chat, отличными от 8.4?
- Можно ли запускать на сервере без дисплея?
- Почему используется системный Chrome, а не встроенный Chromium от Playwright?
- Безопасно ли использовать Personal Access Token?
- Что значит «Invalid state for renegotiation request» в серверном логе?
- Заключение
Проблема: веб-звонки не работают, а мобильные — работают

После включения голосовых вызовов на инстансе Rocket.Chat 8.4.1 с корректно настроенным coturn TURN-сервером характер сбоя был стабильным и воспроизводимым:
| Индикатор | Значение |
|---|---|
| Звонки мобильный → мобильный | Работают — hangupReason: normal |
| Звонки веб → мобильный | Не работают — hangupReason: timeout-remote-sdp |
Mongo acceptedAt | Установлен (вызываемый ответил) |
Mongo offer в negotiations | Отсутствует |
Браузер signalingState | have-local-offer |
Браузер connectionState | new (дальше не продвинулся) |
| Собранные ICE-кандидаты | 20+ (host + srflx + relay) |
Телефон звонил. Вызываемый нажимал «Принять». Сервер фиксировал принятие. Но аудиоканал не устанавливался, и примерно через 12 секунд таймаут сигнального состояния на сервере убивал вызов. MongoDB рассказала всю историю: поле offer в rocketchat_media_call_negotiations полностью отсутствовало. Браузер сгенерировал SDP offer локально, но так и не отправил его.
Тупики: что мы проверили и исключили
Прежде чем создавать диагностический инструмент, были проверены и отброшены все разумные серверные и сетевые гипотезы:
| Гипотеза | Результат | Примечание |
|---|---|---|
| TURN/STUN недоступен из браузера | Исключена | 12 ICE-серверов настроено, 15 relay-кандидатов собрано |
Устаревший e2eKeyId в DM-комнатах | Исключена | Очищено в 4 комнатах — поведение не изменилось |
Устаревшие media_call_channels | Исключена | Удалено 13 записей — основной баг остался |
| Cloudflare портит ICE через TURN | Исключена | Убрали CF, оставили только собственный coturn — тот же сбой |
| Блокировка доступа к микрофону | Исключена | Headless Chrome принимает автоматически — getUserMedia успешен |
| Неправильная кнопка голосового вызова в UI | Частично верно | Кнопка в заголовке DM — это legacy, а модальная «Call» — это media-calls — но offer всё равно не отправлялся |
После исключения серверных причин следующим шагом стала инструментация самого браузера. Не ручное наблюдение через DevTools, а программный перехват каждого события жизненного цикла WebRTC и каждого фрейма WebSocket, отправленного в ходе попытки вызова.
Как работает инструмент
rocketchat-voice-tester — это Node.js-проект на базе Playwright. Он управляет системным Google Chrome с инжектированной WebRTC-инструментацией на каждой странице, аутентифицируется через Personal Access Token, перехватывает каждый вызов конструктора RTCPeerConnection и опционально захватывает необработанные WebSocket-фреймы через Chrome DevTools Protocol (CDP).
┌──────────────────────────────────────────────────────────┐
│ scripts/0X-*.js │
│ Per-script flow: click buttons, wait, capture, report │
└────────────────┬─────────────────────────────────────────┘
│ uses
┌────────────────▼─────────────────────────────────────────┐
│ lib/launcher.js │
│ - PAT-based Meteor login injection │
│ - RTCPeerConnection constructor hook │
│ - REST API helpers (DM resolution) │
│ - Final stats capture (pc.getStats) │
└────────────────┬─────────────────────────────────────────┘
│ drives
┌────────────────▼─────────────────────────────────────────┐
│ System Google Chrome via Playwright │
│ --use-fake-device-for-media-stream │
│ --use-fake-ui-for-media-stream │
│ --disable-blink-features=AutomationControlled │
└────────────────┬─────────────────────────────────────────┘
│ HTTPS / WSS
▼
Rocket.Chat server Инструмент намеренно использует системный Chrome вместо встроенного в Playwright Chromium, чтобы WebRTC-стек совпадал с тем, что есть у реальных пользователей. Это важно, потому что поведение ICE-сбора и поддержка протоколов TURN могут различаться между сборками Chromium.
Инжекция авторизации через PAT
Вместо автоматизации формы входа (которая меняется между версиями RC и ломает селекторы) инструмент инжектирует токены сессии Meteor в localStorage до того, как выполнится какой-либо JavaScript страницы:
localStorage.setItem('Meteor.loginToken', TOKEN);
localStorage.setItem('Meteor.loginTokenExpires', '2030-01-01T00:00:00.000Z');
localStorage.setItem('Meteor.userId', USER_ID); Когда Meteor инициализируется, он находит валидную сессию и полностью пропускает экран входа. Этот подход не зависит от версии и устраняет нестабильные зависимости от CSS-селекторов.
Перехват RTCPeerConnection
Инструмент оборачивает глобальный конструктор RTCPeerConnection через addInitScript, перехватывая каждую WebRTC-операцию до того, как выполнится фронтенд-код RC:
const OrigPC = window.RTCPeerConnection;
window.RTCPeerConnection = function(...args) {
const pc = new OrigPC(...args);
window.__rtc_pcs.push(pc);
// listeners: icecandidate, icecandidateerror,
// connectionstatechange, signalingstatechange, track
// monkey-patches: createOffer, setLocalDescription, addIceCandidate
return pc;
};
Object.setPrototypeOf(window.RTCPeerConnection, OrigPC);
window.RTCPeerConnection.prototype = OrigPC.prototype; Сохранение цепочки прототипов в двух последних строках — принципиальный момент. Фронтенд Rocket.Chat использует проверки instanceof RTCPeerConnection в нескольких местах. Наивная обёртка, которая ломает цепочку прототипов, вызывает тихие сбои при инициализации голосового вызова, и вы потратите часы на выяснение причин.
Захват DDP-фреймов через CDP
Высокоуровневые события WebSocket в Playwright не раскрывают содержимое фреймов. Чтобы обойти это, инструмент открывает сессию Chrome DevTools Protocol напрямую:
const cdp = await page.context().newCDPSession(page);
await cdp.send('Network.enable');
cdp.on('Network.webSocketFrameSent', ({ response }) => {
// response.payloadData contains the raw frame bytes
// regex-match media-calls stream messages
// classify by "type" field: offer, signal, local-state, error
});
cdp.on('Network.webSocketFrameReceived', ({ response }) => { … }); Это даёт доступ к каждому DDP-сообщению, которое браузер отправляет в stream-notify-user/<userId>/media-calls — транспортный уровень, на котором должен появиться SDP offer. Классификация этих фреймов по полю type показывает, что именно произошло.
Четыре диагностических скрипта
Каждый скрипт изолирует отдельный уровень стека голосовых вызовов. Запускайте их по порядку — если предыдущий уровень не проходит, следующие скрипты не дадут осмысленных результатов.
Скрипт 01 — Только ICE
Чистый тест доступности TURN/STUN. Считывает VoIP_TeamCollab_Ice_Servers из вашего инстанса RC, создаёт локальный RTCPeerConnection и собирает ICE-кандидаты. Звонок не совершается — скрипт валидирует relay-инфраструктуру независимо от остальных компонентов.
npm run test:ice
# Healthy output:
candidates: {"host":10,"srflx":1,"relay":15} → TURN works
# Broken TURN:
candidates: {"host":10,"srflx":1,"relay":0} → TURN unreachable
# Everything broken:
candidates: {"host":10,"srflx":0,"relay":0} → STUN unreachable too Скрипт 02 — Исходящий вызов
Открывает личный чат с указанным собеседником, нажимает кнопку «Voice call», затем «Call» в модальном окне и наблюдает за жизненным циклом RTCPeerConnection в течение 30 секунд. Выводит итоговое состояние соединения и статистику candidate-pair.
npm run test:outgoing
# Healthy call:
connection: "connected", succeededPairs: [{rtt: 0.012, bytesSent: 4820}]
# RC 8.4.1 bug signature:
signaling: "have-local-offer", succeededPairs: []
# Offer created locally but never sent to server Скрипт 03 — Захват DDP (ключевой скрипт)
Тот же сценарий вызова, что и в скрипте 02, но с захватом необработанных WebSocket-фреймов через CDP. Каждый фрейм, отправленный в DDP-поток media-calls, перехватывается и классифицируется по типу. Это самый полезный скрипт в наборе — он отвечает на вопрос «отправил ли браузер SDP offer?» на уровне протокола.
npm run test:ddp
# RC 8.4.1 output (broken — no offer sent):
Breakdown of media-calls message types sent by browser:
error: 30
local-state: 5
# What a healthy call should show:
offer: 1
signal: 3
local-state: 5 Если offer отсутствует в этой разбивке, браузер создал SDP offer и установил его как локальное описание, но так и не передал на сервер по DDP. Это и есть веб-баг RC 8.4.1, подтверждённый на транспортном уровне.
Скрипт 04 — Входящий вызов
Открывает браузер и ждёт. Попросите кого-нибудь позвонить вам с мобильного устройства. Скрипт перехватывает входящий вызов и проверяет, доходит ли входящий DDP-сигнал до браузерной сессии.
WAIT_SECONDS=90 npm run test:incoming
# If count: 0 — incoming DDP signal didn't reach this session.
# Check for stale entries in rocketchat_media_call_channels. Установка и использование
git clone https://github.com/ilia-ae/rocketchat-voice-tester.git
cd rocketchat-voice-tester
# Skip bundled Chromium — we use system Chrome
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install
cp .env.example .env
chmod 600 .env # PAT carries full user permissions Отредактируйте .env, указав данные вашего Rocket.Chat:
RC_URL=https://chat.example.com
RC_USER_ID=your_user_id_from_rc
RC_TOKEN=your_personal_access_token
CALLEE_USERNAME=the_user_to_call Получите Personal Access Token в веб-клиенте RC: My Account → Personal Access Tokens → Add. Токен наследует все права пользователя, который его создал — обращайтесь с ним как с паролем.
На Linux задайте CHROME_PATH — путь к бинарнику Chrome (например, /usr/bin/google-chrome). На macOS путь определяется автоматически.
Корневая причина: отсутствие SDP offer в DDP
После исключения всех серверных и сетевых гипотез захват DDP (скрипт 03) однозначно доказал корневую причину. Во время попытки вызова веб → мобильный браузер отправил 30+ WebSocket-фреймов в stream-notify-user/<userId>/media-calls. Разбивка:
error: 30+— ошибки ICE-кандидатов (код 701 от TCP TURN, безобидный шум)local-state: 5— обновления состояния вызова (ringing)offer: 0— браузер так и не отправил SDP offer
Баг находится в @rocket.chat/ui-voip@20.0.0 и @rocket.chat/media-calls@0.4.0, поставляемых с RC 8.4.0–8.4.1. Процесс вызова доходит до signalingState: "have-local-offer" — то есть браузер создал offer и установил его как локальное описание — и останавливается. ICE-кандидаты собираются успешно (20+ включая relay-кандидаты от рабочего TURN-сервера), но без доставки offer на сервер удалённая сторона никогда не получает SDP, и сервер завершает вызов по таймауту через ~12 секунд.
PR #40422 в репозитории Rocket.Chat описывает связанное состояние гонки в packages/media-signaling/src/lib/Session.ts, при котором библиотека «would never request the user’s audio for that call, the negotiation would eventually timeout and the call would be dropped». RC 8.5.0-rc.0 обновляет оба пакета до мажорных новых версий (ui-voip@21.0.0-rc.0, media-calls@0.5.0-rc.0) — ожидается, что исправление включено.
Интерпретация результатов
| Сценарий | Что делать |
|---|---|
Все типы ICE присутствуют, offer: 1 в разбивке DDP | Голосовые вызовы должны работать от начала до конца. Если всё равно не работают — проверяйте согласование кодеков или доставку мобильных push-уведомлений. |
Отсутствуют relay-кандидаты ICE (relay: 0) | Исправьте конфигурацию TURN-сервера, прежде чем проводить дальнейшую диагностику. Проверьте логи coturn, правила файрвола и учётные данные TURN. |
Нет offer в разбивке DDP | Баг фронтенда RC 8.4.1 подтверждён. На стороне клиента не исправить. Обновляйтесь до RC 8.5.0+, когда он станет доступен. |
count: 0 (RTCPeerConnection не создан) | Клик по UI не запустил инициализацию вызова. Проверьте права на голосовые вызовы, лицензионный модуль и селекторы кнопок. |
| Много ошибок 701, но relay-кандидаты есть | Сбои привязки TCP TURN — безобидный шум на хостах с несколькими сетевыми интерфейсами или VPN. Важен UDP TURN. |
Чего инструмент не покажет
- Реальное качество звука. Chrome работает с фальшивым медиа-устройством (
--use-fake-device-for-media-stream), поэтому ICE/DTLS/SRTP согласуются, но реального микрофонного сигнала нет. Для диагностики кодеков или джиттера используйте реальные устройства иchrome://webrtc-internals/. - Мобильные клиенты. Инструмент работает только с веб-версией. Для iOS используйте консоль Xcode; для Android —
adb logcatс фильтрацией поVoIPиMediaCall. - Серверные баги, невидимые со стороны клиента. Проверяйте
journalctl -u rocketchatи запросы к коллекциямrocketchat_media_calls,_negotiations,_channelsв Mongo. - Статистику WebRTC в динамике. Инструмент делает однократный снимок
pc.getStats()по завершении. Для непрерывного мониторинга статистики во время вызова используйтеchrome://webrtc-internals/в обычном браузере.
Связанные проекты
Если вы управляете собственным инстансом Rocket.Chat, статья Rocket.Chat Deploy Toolkit охватывает развёртывание сервера, обновления и автоматизацию бэкапов. По настройке TURN/STUN-инфраструктуры, общей с XMPP, смотрите статью Snikket XMPP Server Manager про конфигурацию coturn и мультиплексирование SSLH.
Частые вопросы
Работает ли инструмент с версиями Rocket.Chat, отличными от 8.4?
Инжекция авторизации через PAT и перехваты WebRTC не зависят от версии. UI-селекторы для нажатия кнопки голосового вызова могут потребовать обновления для существенно отличающихся версий RC, но диагностический слой захвата (сбор ICE, классификация DDP-фреймов) работает независимо от версии RC.
Можно ли запускать на сервере без дисплея?
Да. Инструмент по умолчанию работает в headless-режиме (HEADLESS=true в .env). Флаг Chrome --use-fake-device-for-media-stream предоставляет синтетические аудио/видео-источники, поэтому физический микрофон или камера не нужны.
Почему используется системный Chrome, а не встроенный Chromium от Playwright?
Поведение WebRTC — особенно сбор ICE-кандидатов, поддержка протоколов TURN и согласование кодеков — может различаться между сборками Chromium. Использование той же версии Chrome, что и у реальных пользователей, гарантирует, что результаты диагностики отражают реальное поведение в продакшене, а не артефакты другой сборки браузера.
Безопасно ли использовать Personal Access Token?
PAT наследует полные права пользователя, который его создал. Файл .env добавлен в gitignore, а токен инжектируется только в изолированный экземпляр Playwright Chrome — он не затрагивает ваш обычный профиль браузера. После тестирования отзовите неиспользуемые токены через My Account → Personal Access Tokens в веб-интерфейсе RC.
Что значит «Invalid state for renegotiation request» в серверном логе?
Браузер раз за разом пытается пересогласовать вызов, который сервер уже завершил по таймауту. Эти сообщения — следствие бага с отсутствующим offer, а не причина. Они прекращаются, когда клиент сдаётся. Если вы видите их в journalctl — это дополнительное подтверждение того, что фронтенд так и не отправил исходный SDP offer.
Заключение
Когда WebRTC-вызовы не работают, первым делом проверяют сеть. Доступность TURN, правила файрвола, прохождение NAT. В RC 8.4.1 с этим всё было в порядке. Фактической причиной сбоя было состояние гонки в коде сигнализации фронтенда, из-за которого SDP offer так и не покидал браузер. Обычные DevTools могут показать состояние RTCPeerConnection, но они не умеют классифицировать сообщения DDP-потока и доказать, что конкретный тип сообщения так и не был отправлен. Перехват WebRTC на уровне конструктора и захват необработанных WebSocket-фреймов через CDP — единственный способ получить однозначный ответ.
Инструмент доступен с открытым исходным кодом на github.com/ilia-ae/rocketchat-voice-tester. Если вы используете RC 8.4.x с включёнными голосовыми вызовами, npm run test:ddp за тридцать секунд покажет, затронут ли ваш инстанс. Если в разбивке DDP есть offer: 1 — всё в порядке. Если нет — исправление будет в RC 8.5.0. Если вам нужна помощь с диагностикой голосовых вызовов на вашем Rocket.Chat, свяжитесь со мной.


