ИТ

Диагностика голосовых вызовов Rocket.Chat 8.4: headless Chrome и WebRTC-перехват

В 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, который этот инструмент обнаружил.

Проблема: веб-звонки не работают, а мобильные — работают

Архитектура диагностики Rocket.Chat Voice Tester: headless Chrome с Playwright, WebRTC-перехват, захват CDP-фреймов и DDP-сигнализация

После включения голосовых вызовов на инстансе Rocket.Chat 8.4.1 с корректно настроенным coturn TURN-сервером характер сбоя был стабильным и воспроизводимым:

ИндикаторЗначение
Звонки мобильный → мобильныйРаботают — hangupReason: normal
Звонки веб → мобильныйНе работают — hangupReason: timeout-remote-sdp
Mongo acceptedAtУстановлен (вызываемый ответил)
Mongo offer в negotiationsОтсутствует
Браузер signalingStatehave-local-offer
Браузер connectionStatenew (дальше не продвинулся)
Собранные 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, свяжитесь со мной.

Ilia Arestov, Fractional CTO | Dubai Airport Free Zone (DAFZ), Dubai, UAE | Almaty, Zenkov Street 59, Kazakhstan | +971-585-930-600 | https://t.me/getmonolith
Оцените статью