«Интеграция с телефонией» — фраза, за которой может стоять что угодно. От «раз в сутки подтягиваем список звонков» до «менеджер разговаривает с клиентом во вкладке браузера, а карточка клиента открылась сама, ещё до того как он сказал „алло“».
Мы делаем FARA CRM и прошли этот путь целиком. Сначала перенесли рабочий модуль Asterisk. Потом привели к одному контракту облачные АТС. Потом сделали трубку прямо в браузере. А под конец обнаружили, что без собственного TURN‑релея половина этой красоты «то соединяется, то нет» — и добавили релей в поставку.
В статье три вещи:
какие бывают конфигурации телефонии у компаний — их ровно четыре, и они смешиваются;
что именно можно интегрировать в CRM — пять степеней, они накапливаются;
как это устроено у нас внутри.
Основные примеры — на Asterisk/FreePBX: это наш эталонный провайдер, проверенный в бою. Облачные АТС (Sipuni, МегаФон, Билайн, Манго, МТС) устроены по тому же контракту, отличия называю отдельно.
Часть 1. Четыре архитектуры: чем люди звонят
Прежде чем говорить об интеграции, надо понять, что стоит у клиента на столах. Вариантов ровно четыре, и в одной компании они спокойно встречаются вперемешку.
1. Все телефоны стационарные
Классика. В офисе АТС — Asterisk/FreePBX на своём сервере или облачная. У каждого сотрудника аппарат Yealink или Grandstream с внутренним номером, городские номера заведены транками, входящие раскидываются ring group’ами и очередями.
За: качество связи, привычка, ничего не «отваливается» при закрытии вкладки.
Против: сотрудник привязан к столу. Аппараты на 50 человек — это деньги. Удалёнка требует VPN или проброса.
2. Корпоративные симки на менеджеров
Компания покупает симки и подключает «мобильную АТС» оператора. Звонки идут по обычной сотовой сети, а оператор отдаёт CRM события и статистику по API.
Как это выглядит в жизни. Менеджеру выдают телефон — или просто вторую симку в личный. Он больше не привязан к столу и может ответить откуда угодно. Разговор идёт через трубку, но в момент ответа в CRM прилетает событие «идёт разговор с таким‑то клиентом». Менеджер смотрит на экран и сразу видит, кто звонит, сколько у него заказов и что обсуждали в прошлый раз. Экран при этом любой: компьютер дома, ноутбук, тот же телефон.
За: работает везде, где ловит сеть. Оборудования не надо, если обходитесь второй симкой. Администрирования тоже: интеграцию настраивают один раз.
Против: возможности ограничены тем, что отдаёт оператор — обычно событие о звонке и запись, иногда с задержкой и во времени UTC. Своей логики маршрутизации не написать. Внутренние звонки между сотрудниками — обычные сотовые.
3. Софтфон на устройстве сотрудника
Тот же внутренний номер АТС, но поднят не аппаратом, а программой. MicroSIP на рабочем компьютере, Zoiper или Linphone на смартфоне. АТС видит ровно такой же SIP‑endpoint, как у железного телефона. Устройство любое: корпоративное или личное.
За: оборудование не нужно, сотрудник мобилен, вся логика АТС остаётся на месте.
Против: у каждого своя гарнитура и свои проблемы со звуком. Софтфон на смартфоне в фоне засыпает — нужны push‑уведомления (отдельная возня) или постоянный VPN до АТС. Настраивать звонилки на десятке разных устройств тоже отнимает время.
4. Звонки прямо из CRM, без оборудования и без SIP‑приложений
Ни аппаратов, ни установленных программ. Внутренний номер регистрируется прямо во вкладке браузера по WebRTC, микрофон — обычная гарнитура. Логин и пароль линии лежат в самой CRM и там же меняются. По сути SIP‑телефон встроен в CRM, а звук идёт по каналу WebRTC.
Частный случай, который встречается чаще, чем кажется: тот же логин и пароль сотрудник вбивает в MicroSIP на своём телефоне. Учётка линии одна, а поднять её можно чем угодно — браузером, софтфоном, аппаратом.
За: развернуть = завести сотрудника в CRM. Всё под рукой: набор, книжка, история, карточка клиента.
Против: жёстче требования к АТС (WebRTC, DTLS‑SRTP, WSS) и к сети — про сеть будет отдельная глава. И чуть больше возни с настройкой: на extension нужно включить шифрование и задать SIP‑пароль, а потом вбить этот пароль в CRM. Если у сотрудника уже стоит стационарный аппарат, для браузера придётся завести отдельный extension: один и тот же аппарату и вкладке не подойдёт (почему — в части 4). Дело нескольких минут, но пропустить этот шаг нельзя.
Главный вывод: CRM не должна знать про устройства
FARA поддерживает все четыре конфигурации, и это не четыре интеграции, а одна. Потому что во всех четырёх случаях в CRM существует одна и та же сущность — линия: внутренний номер, привязанный к сотруднику. Аппарат, симка, софтфон и вкладка браузера — это лишь способ её поднять.
┌──────── одна сущность в CRM ────────┐ │ phone_number: линия и сотрудник │ └──────────────────┬──────────────────┘ │ ┌──────────────┬───────────┴──────────┬──────────────┐ │ │ │ │ аппарат на симка в софтфон на вкладка столе (1) телефоне (2) смартфоне (3) браузера (4)
Ровно поэтому в FARA есть отдельная модель phone_number — реестр линий, который синхронизируется с АТС. От него дальше считается всё: направление звонка, наша линия, оператор, признак внутреннего звонка. Об этом в третьей части.
Часть 2. Пять степеней интеграции
Степени накапливаются: каждая следующая имеет смысл, только если сделана предыдущая.
Степень 1. История звонков
Журнал: кто, кому, когда, сколько говорили, чем закончилось.
Если телефония облачная, история берётся одним HTTP‑запросом. Так у всех провайдеров, тут всё просто.
С Asterisk сложнее, и вариантов два.
CDR — таблица в базе АТС, обычно MySQL. Плюс: можно запросить историю за любой период. Минус: в ней лежат не «звонки», а события — переадресации, перехваты, каждая нога отдельной строкой. Чтобы получить один звонок, строки надо схлопывать по uniqueid или linkedid.
Живые события — AMI или ARI. Слушаем АТС и сами видим, когда звонок начался и когда закончился. Плюс: всё в реальном времени. Минус: историю за прошлый месяц так не получишь, а событий в десятки раз больше, чем звонков — хранить их все, чтобы посчитать потом, дорого.
В FARA работают оба. ARI даёт живые события: по ним показываем менеджеру всплывающую карточку. А когда звонок закончился, идём в CDR за точными данными.

Степень 2. Записи разговоров
Запись лежит на АТС или у оператора. CRM должна её забрать и положить рядом со звонком, чтобы слушать прямо из браузера. Для Asterisk мы написали отдельный агент с открытым кодом: он живёт на сервере АТС, имеет доступ к файлам записей и отдаёт их в CRM по запросу.
Формат записи определяем по содержимому файла, а не по расширению: FreePBX пишет MixMonitor в WAV, облачные провайдеры обычно отдают mp3. Иначе браузерный плеер получает неверный Content‑Type и молча ничего не играет.

Степень 3. Связь звонка с сущностями CRM
Звонок сам по себе — строчка в журнале. Ценность появляется, когда он привязан к партнёру, лиду, сделке. Сюда же лидогенерация: незнакомый номер превращается в нового партнёра и лид.
У нас звонок проходит тот же путь, что и входящее сообщение из мессенджера: номер → контакт → партнёр → лид. Буквально тот же код, см. часть 3.
Честно про маркетинговую атрибуцию. Звонок хранит нашу линию (phone_number_id), поэтому отчёт «сколько звонков пришло на какой номер» строится напрямую по таблице: номер с сайта, номер из Яндекса и номер с визиток вы увидите отдельно. А динамической подмены номера — call tracking в чистом виде, с UTM на конкретный звонок — мы не делаем.
Степень 4. Онлайн‑события: карточка на экране
Менеджер снял трубку — на экране всплыла карточка: кто звонит, какой партнёр, какой лид, ссылки на них. Это не «удобство». Это 10–15 секунд на каждом звонке и отсутствие «а вы, простите, кто?».
Две тонкости, которые мы поняли не сразу.
Карточку надо показывать на ответе, а не на дозвоне. Пока идёт дозвон, неизвестно, кто возьмёт трубку: ring group звонит пятерым. Показать всем пятерым и потом погасить четверым — мигание, которое раздражает сильнее, чем отсутствие карточки.
Карточка идёт только тому, чья линия ответила — не всем менеджерам сразу. Никакого «распределения звонков» в CRM при этом нет и не нужно: кто ответил, решает АТС, а CRM про это просто узнаёт.

Степень 5. Звонилка в CRM
Исходящие и входящие со звуком прямо в браузере: набор номера, книжка контактов, приём входящего, mute, DTMF, плашка разговора.
Ограничение честное, и назову его сразу: это возможно только там, где АТС умеет WebRTC. У нас это Asterisk/FreePBX. Облачные АТС отдают события и историю, то есть степени 1–4, но своей трубки в браузере через них нет — WebRTC‑плечо они не дают.

Сводка
Степень |
Asterisk / FreePBX |
Sipuni, МегаФон, Билайн, Манго и прочие облачные |
|---|---|---|
1. История звонков |
✅ CDR + живые ARI‑события |
✅ API истории |
2. Записи разговоров |
✅ |
✅ в объёме, который отдаёт провайдер |
3. Партнёр / лид / линия |
✅ |
✅ |
4. Живая карточка на ответе |
✅ |
✅ |
5. Трубка в браузере |
✅ WebRTC |
❌ |
Внутренние сотрудник↔сотрудник |
✅ и вообще без АТС |
✅ и вообще без АТС |
Последняя строка не опечатка: внутренние звонки между сотрудниками работают даже у клиента, у которого телефонии нет в принципе. Про это часть 5.
Часть 3. Как это устроено внутри
3.1. Транспорт: агент рядом с АТС
┌──────────┐ ARI-события (POST на webhook) ┌──────────────┐ │ Asterisk │ ────────────────────────────────> │ │ │ FreePBX │ │ FARA CRM │ │ │ <──────────────────────────────── │ │ └────┬─────┘ REST: CDR, записи, номера └──────────────┘ │ (HTTP Basic-auth) ┌────┴───────────┐ │ asterisk-agent │ FastAPI рядом с АТС: слушает ARI по веб-сокету, │ (отдельный) │ ходит в базу CDR, читает файлы записей с диска └────────────────┘
Почему агент, а не прямое подключение CRM к АТС. ARI‑веб‑сокет требует постоянного соединения. База CDR — доступа к MySQL FreePBX. Записи — доступа к каталогу на диске АТС. Держать всё это хозяйство внутри CRM мы пробовали и отказались.
В итоге транспорт у Asterisk стал таким же, как у облачных провайдеров — мы как будто сделали его облачным. Событие приходит на тот же универсальный webhook, что у Манго или Билайна. Телефония перестала быть особенной.
3.2. Контракт провайдера — четыре метода
Стратегия провайдера — это только транспорт: разобрать формат и сходить в API. Решений «что делать со звонком» в ней нет.
class PhoneStrategyBase(ChatStrategyBase): """Стратегия — ТРАНСПОРТ. Решения по событию принимает IncomingCallPipeline.""" async def fetch_numbers(self, connector) -> list[dict]: """Линии провайдера в унифицированном виде.""" async def fetch_call_history(self, connector, start_date, end_date) -> list[dict]: """История за период — в том же виде, что и webhook-события.""" async def final_call_records(self, connector, env, adapter) -> list[dict] | None: """None — событие самодостаточно. Asterisk до-запрашивает CDR.""" async def _download_call_record(self, connector, adapter) -> bytes | None: """Скачать запись разговора."""
Главное здесь: история и живое событие разбираются одним и тем же адаптером. Строка CDR приводится к той же форме, что и webhook‑событие, поэтому отдельного кода «для истории» просто нет.
Всё остальное живёт в базовом классе: кнопки «Проверить соединение», «Синхронизировать номера» и «Прочитать историю за период», фоновый крон и живая карточка. Добавить нового провайдера = написать адаптер события и два‑четыре хука.
3.3. Реестр линий: какие номера наши
phone_number — это то, что настроено в АТС: extension, транк, ring group, очередь. Синхронизируется кнопкой и кроном. Линии всех провайдеров приводятся к одному виду:
records.append({ "external_id": resource, # ключ upsert-а вместе с connector_id "kind": "number", # number / trunk / group / queue "number": resource, "extension": resource, "name": resource, "user_key": resource, # чем искать контакт сотрудника "raw": rec, })
К линии привязан сотрудник. Дальше CRM знает, что 201 — это Иванов, а +7 918 — кто‑то снаружи.
3.4. Звонок — это не сообщение
В FARA уже были чаты и мессенджеры, и звонок напрашивался как сообщение особого типа в чате партнёра. Красиво: вся история общения с клиентом в одной ленте.
Не получилось по простой причине. Сообщения живут в чатах, значит на каждый звонок надо создавать чат. В том числе на внутренний 201 → 100 и на «позвонили с незнакомого номера и сразу бросили трубку». Это избыточно.
А в истории клиент хочет видеть 100% звонков, включая мусорные: по ним считают пропущенные.
Поэтому звонок — самостоятельная таблица, и все связи в ней необязательные.
class Call(AuditMixin, DotModel): __table__ = "call" connector_id: "ChatConnector" = Many2one(...) uniqueid: str = Char(index=True) # ключ дедупликации direction: str = Selection([("incoming", "Входящий"), ("outgoing", "Исходящий")]) is_internal: bool = Boolean(default=False) # сотрудник↔сотрудник disposition: str = Selection([...]) # отвечен / пропущен / занято / … number_from: str | None = Char() number_to: str | None = Char() started_at: datetime | None = Datetime() duration: int = Integer(default=0) duration_talk: int = Integer(default=0) # Все связи опциональны — звонок пишется даже без привязки: phone_number_id: "PhoneNumber" = Many2one(...) # наша линия partner_id: "Partner" = Many2one(...) # клиент lead_id: "Lead" = Many2one(...) record_id: "Attachment" = Many2one(...) # запись разговора
В ленту чата партнёра звонок всё‑таки попадает, но подмешивается уже на чтении: Call.list_for_chat отдаёт его как виртуальное сообщение. Хранение — в своей таблице, показ — там, где нужно.
3.5. Что происходит, когда звонок закончился
Простой пример: клиент с номера +7 918 111–22-33 позвонил на внутренний 201, поговорили 40 секунд.
АТС присылает событие «звонок завершён».
Смотрим обе стороны в реестре линий. 201 наша, +7 918 чужая — значит звонок входящий, наша линия 201, клиент +7 918.
Ищем контакт +7 918 и его партнёра. Не нашли — заводим нового.
Заводим лид или переиспользуем свежий.
Пишем строку в
call, а запись разговора забираем в фоне.
Шаги 3 и 4 — буквально те же методы, что у входящего сообщения из Telegram. Телефонный адаптер прикидывается сообщением (номер клиента = автор), поэтому пайплайн звонка наследует пайплайн сообщений. Один раз чиним правило «новый лид или старый» — чинится везде.
Направление считаем сами, от своей линии. В Asterisk нет понятия «входящий/исходящий»: это свойство точки зрения, а не звонка. Правило простое — смотрим, где оказался наш номер:
Звонок |
Наш номер |
Что записываем |
|---|---|---|
8918 → 201 |
вызываемый |
входящий, линия 201 |
201 → 8918 |
звонящий |
исходящий, линия 201 |
201 → 100 |
обе стороны наши |
внутренний |
8918 → 8925 |
ни одной нашей |
входящий, линии нет (транзит через АТС) |
Клиент — всегда та сторона, которой нет в реестре линий.
История приезжает по тому же пути. Кнопка «Прочитать историю за период» и ночной крон гоняют строки CDR через тот же пайплайн, с двумя отличиями: карточку не показывают (звонок давно закончился) и лиды по старым звонкам заводят, только если попросили. Повторный импорт безопасен — звонок пишется upsert‑ом по uniqueid.
3.6. Живая карточка
Транспорт карточки — три строки, и это правильно: он ничего не решает и в базу не ходит.
class CallCard: """Карточка эфемерная: в БД не пишется, звонок попадёт в реестр отдельно.""" @classmethod async def show(cls, env, user_id: int, call: dict): await env.apps.chat.chat_manager.send_to_user( user_id, {"type": "call.incoming", "call": call}) @classmethod async def hide(cls, env, user_id: int, number: str): await env.apps.chat.chat_manager.send_to_user( user_id, {"type": "call.ended", "call": {"number": number}})
Карточка едет по тому же веб‑сокету, что и сообщения чата: отдельного канала для телефонии нет. На фронте минимум состояния — подписка на два события, минимальное время показа 10 секунд (чтобы короткий звонок не мигнул) и авто‑скрытие через 30 минут, если call.ended потерялся.
Часть 4. Трубка в браузере
Библиотека
JsSIP 3.13.8 (MIT). Альтернатива — SIP.js, но его последний релиз 0.21.2 датирован 27 октября 2022 года, а у JsSIP коммиты этого года. Типы отдельно ставить не надо: @types/jssip помечен deprecated именно потому, что библиотека везёт свои.
Топология: SIP‑сокет проксирует сам бэкенд
браузер ──wss://crm/ws/sip──> FARA ──ws://10.0.0.5:8088/ws──> Asterisk │ (только SIP-сигналинг) │ └──────────────── RTP/DTLS: звук идёт НАПРЯМУЮ ───────────────┘
Почему не напрямую из браузера в АТС:
CSP. У проекта
connect-src 'self' wss://$host— браузер просто заблокирует соединение на домен АТС;не через nginx — тогда адрес АТС пришлось бы хардкодить в шаблон конфига, который собирается на деплое. В бэкенде адрес живёт в настройках коннектора и меняется из интерфейса;
не через агент — RTP/DTLS согласуется в SDP напрямую с Asterisk и идёт мимо любого прокси, то есть проксируется только сигналинг. Зато агент из «поставщика данных» стал бы точкой отказа для самих разговоров.
Сам прокси — это две задачи, перекладывающие кадры:
@router_public.websocket("/ws/sip") async def sip_ws_proxy(websocket: WebSocket): ... # Каким коннектором регистрируемся — говорит браузер. Проверяем, что линия # сотрудника в нём действительно есть: иначе через нас можно было бы # достучаться до любой чужой АТС. connector_id = int(websocket.query_params.get("connector") or 0) lines = await _my_lines(env, sessions[0].user_id.id) ... # JsSIP и Asterisk договариваются по субпротоколу 'sip' — эхо ОБЯЗАТЕЛЬНО, # иначе браузер разорвёт соединение сразу после рукопожатия. await websocket.accept(subprotocol="sip") async with ws_connect(url, subprotocols=[Subprotocol("sip")]) as pbx: await _pipe(websocket, pbx)
Трафика тут мало: SIP‑сообщения текстовые, и их единицы на звонок. Звук через CRM не идёт.
Что настроить на FreePBX
Чек‑лист мы вывели прямо в форму коннектора, чтобы не пересказывать его в переписке:
на extension включить WebRTC:
webrtc=yes. Это разом включаетrtcp_mux,use_avpf,ice_support,use_received_transportи ставитmedia_encryption=dtls;WSS‑транспорт с доверенным сертификатом (самоподписанный wss не поднимет), TCP 8089 наружу;
UDP 10000–20000 с АТС наружу — всегда;
сама CRM по https, иначе
navigator.mediaDevicesв браузере просто не существует;сотруднику завести контакт со значением его extension и синхронизировать номера, иначе звонки из браузера не привяжутся к нему.
Отдельный extension для браузера нужен, если у сотрудника есть ещё и аппарат на том же номере. Причина не в max_contacts, как обычно пишут, а в том, что медиа‑профиль задаётся на эндпоинте: обычная трубка на plain RTP/AVP не поднимет endpoint с DTLS‑SRTP. Вторая причина слабее, но тоже реальна. При max_contacts > 1 штатный Dial(PJSIP/${EXTEN}) во FreePBX звонит только на последнее зарегистрировавшееся устройство. Чтобы звонили все, нужен Dial(${PJSIP_DIAL_CONTACTS(${EXTEN})}), то есть правка диалплана.
Часть 5. Внутренние звонки — вообще без АТС
Приятная штука: сотрудники могут звонить друг другу, не задействуя телефонию вообще. Никакой АТС, никаких extension. WebRTC один на один, сигналинг идёт по тому же веб‑сокету, что и чат, а кто сейчас в сети — оттуда же. Работает даже у клиента, у которого телефонии нет в принципе.
В трубке в шапке пункт «Внутренний (сотруднику)» есть всегда, а телефонные каналы добавляются, если настроены. Сделано это отдельно от SIP‑звонилки, и намеренно: там адресат — номер, здесь — пользователь CRM. Общего кода почти нет, а объединять их значило бы переписать работающую логику ради красоты.
Часть 6. Встроенный TURN — то, без чего половина этого не соединяется
Зачем
WebRTC собирает соединение перебором кандидатов. В дружественной сети хватает своих адресов и STUN. Не хватает в двух очень частых случаях:
симметричный NAT — адрес, который узнал STUN, не подходит для другого участника;
офис с закрытым UDP наружу — совершенно типовая корпоративная политика.
В обоих случаях нужен релей: сервер с публичным адресом, через который идёт звук. Без него звонки «то соединяются, то нет». Это худший вид проблемы, потому что воспроизводится не у всех и не всегда.
Вариант «а вы поднимите себе coturn» мы отвергли: это перекладывание на клиента задачи, которую он не умеет диагностировать. Релей должен ехать в коробке.
Почему coturn, а не свой на Python
Мы всерьёз считали свой. По нагрузке проходило: около 6k pps на 30 одновременно говорящих, для Python это немного. Но:
TCP/TLS‑плечо — это половина работы, и именно оно закрывает главный кейс, офис с закрытым UDP. UDP‑релей без него бесполезен ровно там, где релей и нужен;
релей смотрит в интернет, и написать его самому — это своими руками сделать дыру во внутреннюю докер‑сеть.
coturn: примерно день против недели с лишним. Тот случай, когда «написать самим» — соблазн, а не решение.
Единая точка выдачи ICE
Было плохо: у внутренних звонков захардкоженный Google STUN, у SIP‑звонилки строка в настройках коннектора. Отсюда классическое «у одних работает, у других нет» и починка в двух местах по‑разному.
Стало: одна ручка GET /ice/servers и один хук useIceConfig() на оба сценария.
Схема кредов — TURN REST API. Сервер релея не знает про пользователей вообще, ему достаточно общего секрета:
def make_credentials(secret: str, ttl: int, user_id: int): """username = "<unixtime истечения>:<user_id>" credential = base64(HMAC-SHA1(secret, username))""" expires_at = int(time.time()) + max(ttl, 60) username = f"{expires_at}:{user_id}" digest = hmac.new(secret.encode(), username.encode(), hashlib.sha1).digest() return username, base64.b64encode(digest).decode("ascii"), expires_at
user_id идёт в username открытым текстом. Это не секрет, зато в логах релея видно, чья аллокация. Заводить и удалять сотрудников на релее не нужно вообще.
Транспорты отдаём все сразу, браузер сам переберёт и возьмёт первый рабочий:
urls = [f"turn:{host}:{port}?transport=udp", f"turn:{host}:{port}?transport=tcp"] if settings.tls_port: urls.append(f"turns:{host}:{tls_port}?transport=tcp")
Что нужно сделать руками
Почти ничего. Релей едет в том же docker‑compose и стартует вместе с остальными сервисами. Адрес по умолчанию берётся из домена, на котором открыта CRM: релей стоит на той же машине, второй раз его прописывать незачем. Адрес, порты и срок жизни кредов правятся в системных настройках и применяются со следующего звонка, без перезапуска.
Единственный обязательный шаг — открыть порты в фаерволе: 3478/udp, 3478/tcp и диапазон релей‑портов. Релей работает в host‑сети, а она правила ufw не обходит.
В форме коннектора есть кнопка «Проверить релей». Она делает настоящую аллокацию настоящими кредами, а не просто стучится в порт. Зелёный ответ означает «релей жив и секрет совпал».
Безопасность релея
Релей смотрит в интернет и по определению умеет пересылать трафик. Два правила, без которых он превращается в подарок:
# Релей ЧУЖОГО TCP (RFC 6062) не нужен никому: WebRTC передаёт медиа по UDP, # а TURN-over-TCP — это транспорт ДО релея, он остаётся. Без этой строки любой # сотрудник с выданными кредами превращает сервер CRM в открытый TCP-прокси. no-tcp-relay denied-peer-ip=10.0.0.0-10.255.255.255 denied-peer-ip=172.16.0.0-172.31.255.255 denied-peer-ip=192.168.0.0-192.168.255.255 ... # Те же приватные сети, записанные как IPv4-mapped IPv6 (::ffff:10.0.0.1). # Если сервер слушает IPv6, такой адрес — обходной путь к тем же машинам. denied-peer-ip=::ffff:10.0.0.0-::ffff:10.255.255.255
Второе правило тонкое: запретить 10.0.0.0/8 и забыть про ::ffff:10.0.0.0 значит не запретить ничего. Иначе любой, кто получил креды — а получает их каждый сотрудник, — дотянется через релей до postgres и до соседей по докер‑сети.
Ещё одна вещь, которую часто считают неправильно: диапазон релей‑портов считается не по звонкам, а по аллокациям. Браузер берёт отдельную аллокацию на каждый turn‑URL, а udp, tcp и tls — это три разных. Значит один участник разговора может занять до трёх портов. 500 портов — это примерно 160 одновременных участников с релеем.
И отдельно: если АТС стоит в той же приватной сети, её адрес надо вернуть обратно через --allowed-peer-ip, иначе браузер не сможет говорить с ней через релей.
Коротко
Реестр линий — единственное, что CRM обязана знать про телефонию. Аппарат, симка, софтфон и вкладка браузера — это способы поднять линию, а не четыре разные интеграции.
Звонок — самостоятельная сущность, все связи необязательные. В историю он попадает всегда, в ленту чата подмешивается на чтении.
Обработка звонка — наследник обработки сообщения. Путь «незнакомый номер → партнёр → лид» не должен существовать в двух экземплярах.
Стратегия провайдера — только транспорт. Четыре хука, а история и живое событие идут одним и тем же путём.
Релей — часть поставки. Иначе всё вышеперечисленное иногда не соединяется, и никто не знает почему.
Если у вас есть свои грабли по этой теме — особенно по WebRTC за корпоративным NAT — пишите в комментариях. Это ровно та область, где чужой опыт экономит недели.