javascript

Как я случайно создал голосовой чат, который невозможно прослушать

  • четверг, 9 июля 2026 г. в 00:00:08
https://habr.com/ru/articles/1056888/

Сразу разочарую любителей громких фраз: я не делал «защищённый мессенджер», я не думал ни про слежку, ни про прослушку, ни про приватность вообще.

Изначально я хотел лишь одного: иметь возможность быстро и без препятствий созвониться с кем‑либо. А получился голосовой чат, разговор в котором физически нельзя прослушать со стороны, и понял я это уже сильно позже, так что «невозможно прослушать» в заголовке — не цель, а побочка.

Дальше расскажу, как оно вышло и где у этой невозможности честные границы.


Началось всё очень банально: обычный вечер, обычный разговор в Discord, но в какой‑то момент прилетела задержка доставки голоса в 3–5 секунд, разговаривать стало невозможно.

Минут сорок мы пытались исправить ситуацию: меняли серверы VPN, перезапускали «Запрет» и меняли стратегии, а отчаявшись — переустанавливали сам Discord. Задержка порой пропадала, но всегда возвращалась.


Пошли искать альтернативы: Телемост со своим корпоративным интерфейсом, существующий только в вебе — отмели почти сразу. Дальше веселее: в РФ большая часть альтернатив без VPN просто не открывается (не считая откровенно позорных клонов вышеупомянутого Discord), а у TeamSpeak порог входа такой, что проще приехать к собеседнику лично: зарегистрируйся, поставь приложение, найди публичный сервер и разберись, как подключиться. Часа за полтора мы перебрали всё, что попадалось на глаза, и ни один из сервисов не смог закрыть самую простую задачу: прямо сейчас созвониться и услышать друг друга без задержки и необходимости дирижировать VPN/сервисами обхода.

В конце концов, последние надежды канули:

Раз альтернатив нет — сделаем.

За ночь накидал прототип — задумка была примитивная, «ничего лишнего»: зашёл и говоришь. Без регистрации с кодом подтверждения на почту, без онбординга, без поп‑апов «разрешите уведомления», а через пару недель дошло, что это самое «ничего лишнего» и есть главная фишка. Эфемерность и отсутствие всей современной мишуры, которую я выкинул из лени и минимализма, оказались ровно тем, чего у остальных нет.

Проект был назван «Void», сейчас он уже крутится в проде и выполняет ровно ту функцию, для которой и был создан. Дальше расскажу, как он устроен внутри, почему из‑за одного решения он вышел одновременно быстрым, приватным и неудобным для прослушки, и на чём я по дороге обжёгся.


Почему вообще лагало

Если разобраться, откуда вообще взялась задержка — ответ будет довольно скучным. Дело всё в том, что голос идёт не напрямую к собеседнику, а делает крюк через сервер где‑то далеко не в России. Мой голос летел на дата‑центр Discord, оттуда — другу, а его ответ возвращался тем же путём. Лишние тысячи километров в каждую сторону, а VPN эту историю только усугублял, добавляя ещё один крюк сверху.

И вот эта штука, медиасервер посреди разговора — это корень вообще всего. Раз весь звук идёт через него, значит технически голос полностью в их руках: хочешь пиши, хочешь анализируй, а если вежливо попросят — поделись. К тому же, раз есть аккаунт — есть за что зацепить историю, модерацию, а с недавних пор и проверку возраста по паспорту. Убери сервер из середины разговора, и половина этих проблем отваливается разом, просто потому что исчезает их источник.

Так что вопрос был чисто инженерный: как сделать, чтобы двое говорили напрямую, а сервер только сводил их — и более не участвовал. Оказалось, ровно для этого в браузере давно есть WebRTC, нужно только всё собрать.


Сервер сводит — и больше не участвует

Серверная часть Void — это Node.js, WS и немного Express. Пока идёт звонок, сервер делает ровно одну вещь: сводит участников. Принимает от одного описание соединения (SDP), передаёт другому, гоняет между ними ICE‑кандидатов — то есть сообщения в духе «вот так до меня можно дозвониться». Пара килобайт служебного текста на установление связи, после этого сервер из разговора выпадает.

Базы данных — нет. Не «есть, но мы туда правда не смотрим», а нет как явления. Комнаты лежат в обычном Map в памяти процесса: код комнаты и список тех, кто в ней сидит. Вышел последний участник — с ним пропала и запись из памяти. На диск пишется один json с обезличенной статистикой: сколько комнат за день, сколько соединений встало напрямую, сколько ушло через relay. Ни ников, ни сообщений, ни записей звонков там нет, потому что до сервера они не доезжают.

Исходный код

Если совсем буквально, вся «база данных» сервера — это один Map в памяти процесса:

Map {
  roomCode => {
    users: Map { userId => { ws, nickname, mic, sound, screen } },
    cleanupTimer: setTimeout id | null
  }
}

Вышел последний участник —rooms.delete(code), и от комнаты не осталось ровным счётом ничего.

Отсюда и «невозможно прослушать» из заголовка. Прослушать разговор со стороны попросту негде, его нет на сервере. В базе, которой нет, тоже нет. А между собой собеседники шифруют поток напрямую: в WebRTC это DTLSSRTP, ключи согласуются между браузерами, посередине их никто не держит. Даже когда пара из‑за кривой сети идёт через relay, релей гоняет уже зашифрованные пакеты и расшифровать их не может. Метаданные, кто с кем соединился, сервер видит в момент знакомства, но никуда не пишет: комната живёт в памяти и исчезает с последним участником.

И тут — честная сноска: «невозможно прослушать» относится к пути между собеседниками, а не к их устройствам. Если чьё‑то устройство заражено, никакой P2P не спасёт: звук снимут прямо там, где он звучит или произносится, ещё до всякого шифрования. Криптография защищает канал, а не дырявый эндпоинт. Void убирает возможность прослушать в пути, но если противник уже сидит у тебя в системе — он услышит разговор, и архитектурой это не лечится.


Почему в комнате мало народу

Раз сервер звук не микширует, участники обязаны соединяться друг с другом сами. Для двоих это одно соединение, для группы «каждый с каждым» число соединений растёт как N×(N−1)/2: впятером это 10, вдесятером уже 45, и каждый шлёт свой звук всем остальным отдельным потоком.

Поэтому комнаты держатся маленькими: потолок = десять человек, и это, к сожалению, цена решения. Если нужна комната на сотню — придётся ставить SFU в середину, а он опять начнёт видеть поток. SFU я ставить не хотел, ради него пришлось бы выкинуть ровно то, из‑за чего всё затевалось.


Perfect negotiation, или где WebRTC кусается

Во время тестов оказались нередкими ситуации, когда обе стороны могут одновременно решить обновить соединение и одновременно швырнуть друг другу offer. Называется это glare, и не ожидающий такого явления код в этот момент погибает: оба ждут ответа, который никто не пришлёт.

Лечится штукой из спеки W3C под названием perfect negotiation. Каждому соединению назначается роль: вежливый и невежливый, и считается она симметрично из сравнения id — у двух сторон роли выходят противоположными без всякой договорённости между ними.

Исходный код

Чей ID «больше» как строка, тот и вежливый. У собеседника сравнение зеркальное, так что вежливым выходит ровно один:

peer._polite = String(clientId) > String(userId);

Вежливый при столкновении откатывает свой offer и принимает чужой, а невежливый — продавливает свой:

const offerCollision =
    peer.signalingState !== "stable" && !peer._isSettingRemoteAnswerPending;
const ignoreOffer = !peer._polite && offerCollision;
if (ignoreOffer) return;              // невежливый: держим свой offer

await peer.setRemoteDescription(new RTCSessionDescription(data.offer));
await peer.setLocalDescription();     // вежливый: уступаем, implicit rollback

ДальшеsetLocalDescription()без аргументов сам понимает, что сейчас уместно, offer или answer.

Без perfect negotiation групповой звонок разваливается ровно в тот момент, когда двое одновременно что‑то переключили (а они переключат), и я наступил на это классически: на двух вкладках всё работает, а в реальной комнате — зависшие коннекты на ровном месте. Вывод довольно банальный: «работает у меня» и «работает в реальности» в WebRTC — это совершенно разные вещи.


NAT, VPN и почему до собеседника ещё надо дозвониться

Чтобы двое соединились напрямую, каждому надо узнать свой внешний адрес и продавить NAT. Этим занимаются STUN‑серверы, лёгкие и публичные, они просто отвечают «вот как ты выглядишь снаружи». Дома у большинства этого хватает, и звук идёт по кратчайшему пути, который вообще есть между вами. Кратчайший путь — это и есть ключ к минимальной задержке: слышно так, будто человек в соседнем доме, потому что звук не мотается через дата‑центр в другой стране.

Но всё ломается на симметричном NAT и особенно на CG‑NAT у мобильных операторов, где за одним внешним адресом сидят сотни абонентов, там напрямую установить соединение попросту невозможно. По моей статистике после десятка часов тестов на таких сетях ~23% пар не соединяются напрямую и уходят на запасной вариант — TURN‑сервер (у меня это coturn), который ретранслирует трафик, когда прямого пути нет. Да, для такой пары звонок физически идёт через мой сервер. Но coturn пересылает уже зашифрованные пакеты (DTLS‑SRTP) и расшифровывать их не умеет. Можно, конечно, выключить TURN совсем, но в таком случае на STUN‑only отваливаются те самые ~23% сложных пар, поэтому в проде он поднят.

Исходный код

Отдельной базы под TURN тоже нет: credentials короткоживущие и считаются на лету по стандартной схеме coturn:

const expiry = Math.floor(Date.now() / 1000) + 3600;   // TTL = 1 час
const username = `${expiry}:${userId}`;
const credential = crypto
    .createHmac("sha1", TURN_SECRET)
    .update(username)
    .digest("base64");

И про VPN: если сам сайт заблокирован и открывается он только с помощью VPN — туннель будет использоваться только чтобы открыть страницу и обменяться сигналингом — всего пара секунд при подключении. Сам разговор дальше идёт напрямую и VPN его не трогает: пинг звонка не зависит от того, через какую страну у тебя туннель, потому что голос по нему не идёт.


Когда сеть подводит — как отрабатывать?

P2P-соединение хрупкое, и причин тому может быть масса: переключился вайфай, потерялись пакеты, оператор перекинул маршрут, связь рвётся. Без механизма отработки таких случаев это был бы обрыв звонка, поэтому поверх WebRTC пришлось построить машину восстановления.

Работает это примерно так: статус disconnected — это ещё не конец, мы терпеливо ждём пару секунд, часто сеть чинится сама. Если не помогло — сторона делает ICE‑restart, ищет маршрут заново. Не помогло за несколько секунд — пересобирает соединение с нуля, вторая сторона по флагу делает то же самое. Отдельно висит watchdog на дохлые коннекты: раз в две секунды смотрит счётчик входящих пакетов, и если соединение по статусу живое, а пакетов ноль дольше пяти секунд, не ждёт таймаут в полминуты, а пересобирает сразу. На практике это ловит «вроде подключено, а связи нет» заметно быстрее.

Стоит это одной неприятной секунды: в момент пересборки звук проваливается на секунду‑две. Микрофон при этом не теряется, разговор продолжается.


Что досталось от браузера почти даром

Раз всё живёт в вебе, часть вещей вышла копеечной ценой. Шумодав — это RNNoise, собранный в WASM и крутящийся в AudioWorklet: клавиатура, кулер и улица режутся прямо на клиенте, ещё до отправки собеседнику. Чат и файлы идут по тому же P2P, через RTCDataChannel, бинарными кусками по 60 КБ (чуть ниже потолка SCTP), файлы до 100 МБ. Демонстрация экрана со звуком и в FullHD, тем же прямым каналом. На сервер ничего из этого не заходит, гарантии те же, что и у голоса.


Что в итоге не идеально

Без раздела с минусами — никуда:

  1. Mesh не тянет масштаб. Десять человек это потолок, и поднять его без перехода на SFU нельзя, а SFU убьёт главное свойство. Это не «руки не дошли», это развилка, и я сознательно свернул в сторону приватности.

  2. UserId подделываемый. Клиент сам придумывает себе id, сервер только проверяет формат. Теоретически можно назваться чужим id. Практически же воровать нечего: между сессиями никакого состояния нет, аккаунтов нет. Строить крепость вокруг пустого сарая я не стал.

  3. Пушей в вебе нет. Закрыл вкладку — звонка не услышишь, тебя нечем «дёрнуть», потому что нет ни аккаунта, ни твоей сессии на сервере. В десктопной версии частично лечится треем, в вебе — никак.

  4. Рестарт сервера убивает комнаты. Обратная сторона отсутствия БД. Для созвона на двадцать минут ок, но попал в момент деплоя — заходишь заново.

  5. Десктоп только на Windows. Приложение на Tauri есть под винду, macOS и Linux пока нет. Веб работает везде, где работает WebRTC.

Всё из списка выше — не провал, а скорее ценник за конструкцию, в которой серверу про тебя нечего знать. Но и говорить, что всё идеально, было бы враньём.

Честная оговорка — это не «убийца Discord»

Discord — про сообщества, сервера, роли, вечный онлайн, и это отдельная задача, которую Void не решает и не пытается. Void — это скорее «быстро созвонился и разошлись», ближе к телефонному звонку, чем к соцсети.


Зачем это всё?

Весь проект вырос из одной фундаментальной проблемы: хотелось просто поговорить, а пришлось воевать с задержкой и искать, куда сбежать. Так что делал я его в первую очередь для себя и пары друзей, а «приватность» получилась побочным эффектом, ведь выкинув из схемы всё лишнее, вышло что собирать нечего — потому что нечем.

Потрогать Void можно в браузере на void‑room.space, без установки и регистрации: создаёшь комнату, отправляешь код собеседнику — и говорите. Ровно то, чего мне тогда и не хватило.

Код лежит открыто на GitHub — буду рад звёздам и issue.

P.S.

За кулисами осталось много вещей, которые не уместились в статью: recovery‑машина при обрывах, эхо, screencast‑ducking, десктоп‑версия на Tauri. Проблем за месяцы разработки набралось прилично, и если будет спрос — с удовольствием разберу самые интересные по отдельности. Peace!