Задача звучала невинно: два браузера, кнопка «Call», голос в обе стороны. Под капотом - Asterisk на VPS, WebRTC в Chrome, SIP поверх WebSocket. Что может пойти не так?
Всё. Пять разных багов с одним и тем же симптомом - «нет звука». Это детективная история одного звонка: все баги реальны, все грабли - наши. IP-адреса и креды в примерах заменены на документационные.

Глава 1. Архитектура, которую мы заслужили
Представьте себе схему. VPS с публичным IP 203.0.113.10. На нём крутится Docker-контейнер с Asterisk 22.8 - опенсорсной телефонной станцией, которая умеет всё: от классических SIP-звонков до WebRTC. Рядом - Coturn, STUN/TURN-сервер, тоже в Docker, в host-режиме.
На стороне клиента - два HTML-файла. index.html - звонящий (абонент 3001). 3002.html - принимающий с авто-ответом. Оба используют JsSIP - JavaScript-библиотеку, которая реализует SIP-стек прямо в браузере. SIP-сигнализация идёт по WSS (WebSocket Secure) через порт 8089. Медиа - по RTP (Real-time Transport Protocol), зашифрованному в DTLS-SRTP, через порты 18000–18100 UDP.

Кодек - Opus (48 kHz, 2 канала). Не MP3, не AMR, не G.711. Opus - это кодек, рождённый для реального времени: адаптивный битрейт, встроенная коррекция ошибок (FEC), задержка от 5 мс. В мире VoIP есть десятки кодеков - G.711 (μ-law/A-law, он же PCMU/PCMA) для совместимости с PSTN, G.722 для HD-голоса, AMR в мобильных сетях, G.729 с его платными лицензиями. Но для WebRTC Opus - король. Chrome даже не умеет договариваться о чём-то другом первым.
Asterisk тоже поддерживает Opus (через allow=opus в pjsip.conf), плюс PCMU и PCMA как fallback. Процесс выбора кодека происходит во время SDP-обмена (Session Description Protocol): каждая сторона заявляет список поддерживаемых кодеков с приоритетами, и они находят пересечение. Вот типичная строка из SDP:
a=rtpmap:111 opus/48000/2 a=fmtp:111 minptime=10;useinbandfec=1
Это Opus с payload type 111, частотой дискретизации 48 кГц, стерео, минимальный ptime 10 мс и включённая внутриполосная коррекция ошибок.
Звучит стройно. Нажимаем Call.
Тишина.
Глава 2. Первый звонок: «180 Ringing» и мёртвая тишина
Asterisk принимает INVITE от 3001, маршрутизирует его по dialplan (extensions.conf) и отправляет новый INVITE к 3002. Браузер 3002 получает входящий звонок, JsSIP стреляет событием newRTCSession. Наш код вызывает session.answer(). Asterisk видит 180 Ringing.
Но 200 OK не приходит.
Двадцать секунд. Nobody picked up in 20000 ms. Asterisk отправляет CANCEL, и звонок умирает.
Первая мысль - getUserMedia(). Этот вызов запрашивает разрешение на микрофон, и если браузер показывает диалог «Разрешить?» - он блокирует всё. Решение: вызывать ensureMic() до старта JsSIP UA, чтобы к моменту входящего звонка микрофон уже был готов.
Помогло? Нет. 180 Ringing есть, 200 OK - нет.
Глава 3. ICE, или Почему два компьютера не могут просто поговорить
Чтобы понять, что происходит, нужно понять ICE - Interactive Connectivity Establishment. Это не просто аббревиатура, это целый фреймворк, который решает фундаментальную проблему интернета: большинство устройств сидят за NAT и не знают своего публичного адреса.
ICE работает так:
-
Браузер собирает кандидатов - адреса, по которым до него можно достучаться:
host - локальные IP (192.168.x.x, 10.x.x.x)
srflx (server reflexive) - публичный IP, полученный через STUN
relay - адрес на TURN-сервере, который будет проксировать трафик
Кандидаты вставляются в SDP (Session Description Protocol)
Обе стороны проводят connectivity checks - пингуют каждого кандидата противника
Побеждает пара с лучшей связностью
STUN (Session Traversal Utilities for NAT) - лёгкий протокол. Браузер отправляет запрос на STUN-сервер, тот отвечает: «Твой публичный IP - такой-то, порт - такой-то». Это как спросить прохожего, как ты выглядишь со стороны.
TURN (Traversal Using Relays around NAT) - тяжёлая артиллерия. Когда прямое соединение невозможно (symmetric NAT, файрвол), TURN-сервер становится посредником: все медиа-пакеты проходят через него. Медленнее, но работает всегда.

Мы начали без STUN/TURN:
const PC_CONFIG = { iceServers: [] };
Браузер 3002 собрал только host-кандидатов: 192.168.1.98, 10.8.0.10 (VPN), fdbc:8196:... (IPv6 link-local). Все приватные. Asterisk, сидящий на VPS, не может до них достучаться. ICE → failed. Звонок → тишина.
Глава 4. Google STUN - заблокирован
Первый рефлекс: добавить публичные STUN-серверы Google.
iceServers: [ { urls: "stun:stun.l.google.com:19302" }, { urls: "stun:stun1.l.google.com:19302" } ]
Заработало? Нет. stun.l.google.com оказался заблокирован в сети пользователя. Браузер не получил srflx-кандидата, ICE снова нашёл только приватные адреса.
Глава 5. Поднимаем свой Coturn
Если Google STUN недоступен - поднимем свой. На том же VPS, где живёт Asterisk:
docker run -d --name coturn --network host coturn/coturn \ -n --log-file=stdout \ --listening-ip=0.0.0.0 \ --external-ip=203.0.113.10 \ --min-port=49152 --max-port=49252 \ --lt-cred-mech --realm=voip.local --fingerprint \ --user webrtc:S3cr3t_turn
Ключевые моменты:
--network host- контейнер использует сеть хоста напрямую, без Docker NAT--external-ip- Coturn знает свой публичный IP для relay-кандидатов--lt-cred-mech- Long-Term Credential Mechanism (RFC 5389), аутентификация для TURN--min-port/--max-port- диапазон портов для relay-аллокаций
Теперь у нас свой STUN+TURN на 203.0.113.10:3478. Обновляем конфиг клиентов:
iceServers: [ { urls: "stun:203.0.113.10:3478" }, { urls: "turn:203.0.113.10:3478?transport=udp", username: "webrtc", credential: "S3cr3t_turn" } ]
Глава 6. Ловушка TCP TURN и ошибка 701
Звоним. ICE gathering начинается... и зависает. В консоли Chrome:
ice-candidate-error: url=turn:203.0.113.10:3478?transport=tcp code=701 text=Address not associated with the desired network interface.
Мы добавили TCP TURN наряду с UDP. Chrome честно пытается аллоцировать relay через TCP - и получает ошибку 701. Но вот подвох: Chrome не завершает ICE gathering, пока не отработает TCP TURN. Внутренний таймаут - десятки секунд.
А JsSIP ждёт iceGatheringState === "complete", чтобы собрать все кандидаты в SDP и отправить его. Пока gathering не завершён - SDP не уходит, 200 OK не отправляется, Asterisk считает до 20 и вешает трубку.
Решение: убрать TCP TURN, оставить только UDP.
urls: "turn:203.0.113.10:3478?transport=udp"
Глава 7. event.ready() - 40 секунд впустую
TCP TURN убран. Звоним. ICE gathering... всё ещё висит. Relay-кандидат появляется за секунду, но SDP уходит только через 40 секунд.
Добавляем таймлайн в лог:
23:01:43- relay-кандидат собран23:02:23- SDP сгенерирован
40 секунд разницы. Где они теряются?
Оказывается, в JsSIP есть хитрость. Событие icecandidate на сессии (не на RTCPeerConnection!) передаёт объект с методом event.ready(). Если вы подписались на это событие и не вызвали ready() - JsSIP интерпретирует это как «подожди, я ещё думаю». И ждёт. Свой внутренний таймаут - ~40 секунд.
Наш код:
session.on("icecandidate", (ev) => { log("ICE candidate generated"); // Забыли ev.ready()! });
Мы просто логировали кандидата и не говорили JsSIP, что он может продолжать. Библиотека терпеливо ждала 40 секунд, потом сдавалась и отправляла SDP.

Исправление - одна строка:
session.on("icecandidate", (ev) => { log("ICE candidate generated"); if (ev && typeof ev.ready === "function") ev.ready(); });
После этого INVITE и 200 OK стали улетать за 1–2 секунды.
Глава 8. ice_host_candidates - секция, а не ключ
Звонок поднимается! session accepted, session confirmed. Но out_packets=0. Тишина в эфире.
Смотрим SDP, который Asterisk отправляет клиенту 3002:
c=IN IP4 192.168.0.129 a=candidate:Hc0a80081 1 UDP 2130706431 192.168.0.129 18008 typ host a=candidate:Hac110001 1 UDP 2130706431 172.17.0.1 18008 typ host
192.168.0.129 - приватный IP VPS (интерфейс ens3). 172.17.0.1 - Docker bridge. Оба недоступны из интернета. Браузер пытается отправить RTP на приватные адреса - и, разумеется, ничего не доходит.
В rtp.conf у нас было:
[general] ice_host_candidate=203.0.113.10
Неправильно. Читаем оригинальный rtp.conf.sample из репозитория Asterisk и обнаруживаем: ice_host_candidates - это отдельная секция, а не ключ в [general]. И формат маппинга:
[ice_host_candidates] 192.168.0.129 => 203.0.113.10
Это говорит Asterisk: «Когда ты видишь свой локальный адрес 192.168.0.129 в ICE-кандидатах - замени его на публичный 203.0.113.10».
После исправления SDP стал красивым:
a=candidate:H59684ac5 1 UDP 2130706431 203.0.113.10 18008 typ host
Глава 9. relay-only и петля 403
К этому моменту мы уже устали от долгого ICE gathering (Chrome перебирал host, srflx, relay кандидаты по всем интерфейсам - WiFi, VPN, IPv6). Решение: iceTransportPolicy: "relay" - форсировать браузер использовать только TURN relay.
const PC_CONFIG = { iceTransportPolicy: "relay", iceServers: [{ urls: "turn:203.0.113.10:3478?transport=udp", ... }] };
ICE gathering стал молниеносным: один relay-кандидат, готово. Звонок поднимается за секунды. Идеально?
Нет. out_packets=0. Опять.
Включаем RTP debug на Asterisk и видим в логах:
pjproject: CreatePermission failed for IP 203.0.113.10: 403/Forbidden IP
Что произошло: мы также добавили turnaddr=127.0.0.1:3478 в rtp.conf Asterisk, чтобы он тоже получал relay-кандидаты. Asterisk аллоцировал relay на Coturn (адрес 203.0.113.10:49160). Браузер тоже аллоцировал relay на том же Coturn (203.0.113.10:49202).
Теперь Asterisk через свой TURN relay хочет отправить пакет на 203.0.113.10:49202 (relay браузера). Для этого он просит Coturn: «Создай permission на IP 203.0.113.10». Coturn отвечает: 403 Forbidden. Почему? Потому что 203.0.113.10 - это собственный IP Coturn. TURN-сервер не разрешает relay-ить трафик самому себе - это защита от петель.
Мы попали в ловушку: оба клиента (Asterisk и браузер) использовали один и тот же TURN-сервер, и их relay-адреса были на одном IP. TURN не умеет проксировать трафик между двумя аллокациями на себе самом.

Решение: убрать TURN у Asterisk (ему не нужен relay - он на том же сервере, что и Coturn) и убрать relay-only у браузеров. Asterisk рекламирует свой публичный IP через ice_host_candidates, браузер получает srflx-кандидат через STUN, и они находят друг друга напрямую.
Глава 10. Аудио пошло
Финальная конфигурация:
Клиенты (браузер):
const PC_CONFIG = { iceServers: [ { urls: "stun:203.0.113.10:3478" }, { urls: "turn:203.0.113.10:3478?transport=udp", username: "webrtc", credential: "S3cr3t_turn" } ] };
Asterisk (rtp.conf):
[general] rtpstart=18000 rtpend=18100 [ice_host_candidates] 192.168.0.129 => 203.0.113.10
Звоним. Смотрим pjsip show channelstats:
BridgeId Channel UpTime Codec Recv Lost Tx Lost RTT fcc03999 3001 00:24 opus 1132 2% 1141 2% 0.022 fcc03999 3002 00:24 opus 1152 1% 1132 2% 0.022
RTP debug:
Got RTP packet from 198.51.100.23:65488 (type 111, seq 003634, len 049) Sent RTP packet to 198.51.100.23:56199 (via ICE) (type 111, seq 064699, len 049)
Пакеты идут в обе стороны. Opus, 48 kHz. Потери ~1–2% (нормально для интернета). RTT 22 мс. Голос слышен.

Эпилог: Чему нас научил один звонок
Один WebRTC-звонок - это десятки протоколов, работающих в унисон:
Слой |
Протокол |
Роль |
|---|---|---|
Сигнализация |
SIP поверх WSS |
Установка/завершение звонка |
Описание сессии |
SDP |
Обмен кодеками, кандидатами, fingerprint'ами |
NAT-traversal |
ICE + STUN + TURN |
Поиск маршрута для медиа |
Шифрование |
DTLS (хэндшейк) + SRTP (медиа) |
Конфиденциальность |
Медиа-транспорт |
RTP / RTCP |
Доставка и мониторинг аудио |
Кодек |
Opus (48 kHz, FEC, адаптивный) |
Сжатие голоса |
И на каждом слое может сломаться что угодно:
SDP без публичного IP - ICE не найдёт маршрут
Забытый
event.ready()- JsSIP зависнет на 40 секундTCP TURN за файрволом - Chrome не завершит ICE gathering
Два relay на одном TURN - петля с 403 Forbidden
Неправильный формат конфига - Asterisk молча проигнорирует строку
Каждый из этих багов выглядел как «нет звука». Один симптом, пять разных причин. Классика VoIP.
Шпаргалка: словарь терминов
WebRTC - Web Real-Time Communication, API браузера для аудио/видео/данных в реальном времени
SIP - Session Initiation Protocol, протокол установки и управления VoIP-сессиями
SDP - Session Description Protocol, текстовый формат описания медиа-параметров
ICE - Interactive Connectivity Establishment, фреймворк поиска оптимального маршрута
STUN - Session Traversal Utilities for NAT, определение публичного адреса
TURN - Traversal Using Relays around NAT, проксирование медиа через сервер
Coturn - опенсорсная реализация STUN/TURN-сервера
RTP/RTCP - Real-time Transport Protocol / RTP Control Protocol
DTLS-SRTP - шифрование медиа в WebRTC (DTLS для ключей, SRTP для потока)
Opus - кодек реального времени, стандарт WebRTC (RFC 6716)
G.711 (PCMU/PCMA) - классический телефонный кодек, 64 kbps, без сжатия
G.722 - широкополосный кодек (HD Voice), 48/56/64 kbps
AMR - Adaptive Multi-Rate, кодек мобильных сетей (GSM/3G)
Asterisk - опенсорсная телефонная станция (PBX)
PJSIP - SIP-стек внутри Asterisk
JsSIP - JavaScript SIP-библиотека для браузера
WSS - WebSocket Secure, шифрованный WebSocket для SIP-сигнализации
NAT - Network Address Translation, трансляция адресов (причина большинства VoIP-проблем)
Offer/Answer model - модель SDP-обмена: одна сторона предлагает (offer), другая отвечает (answer)
Написано по мотивам реального дебага в 23:00, когда единственным источником света были два терминала с docker logs.