~/potatohd.ruEN
← Все статьи
12 августа 2026·10 мин

Concord: сервер, совместимый с Discord — два плана вместо одного

GoWebRTCDiscord APIsystem design

«Совместимость с Discord» звучит как маркетинг, пока не посмотришь, из чего сделаны сами клиенты. Заметная часть открытых десктоп-клиентов Discord вообще ничего не реализует: это Electron-обёртка, которая грузит настоящий сайт Discord в Chromium и разрешает свой IPC-мост по захардкоженному списку хостов. Если сервер отвечает так же, как настоящий, перенаправить такой клиент — правка двух строк в форке.

Из этого и родился Concord: clean-room реализация Discord API (REST v9/v10), Gateway v10 и Voice Gateway v8 с собственным SFU. Не «похожий чат», а сервер, к которому подключаются чужие клиенты и боты. Ниже — про то, как это устроено внутри, и про несколько мест, где протокол диктует архитектуру.

#Два плана: управляющий и медийный

Первое и самое дорогое решение — разделить систему на два плана.

Управляющий план из REST и WebSocket поверх Valkey, Postgres и ScyllaDB; отдельный медийный план, к которому клиент идёт напрямую по UDP мимо Traefik
Раздельные планы: перевыкатка REST не должна ронять живые сокеты и звонки

Причина простая: они ломаются и масштабируются по-разному.

  • REST не имеет состояния. Реплики множатся тривиально, деплой — обычная замена контейнера.
  • Шлюз держит сокеты. Состояние есть, но оно восстановимо: клиент переподключится и заново соберёт READY.
  • Голос держит медиа. Вот это состояние нереплицируемо в принципе. Нельзя «перевыкатить» узел с идущим звонком без обрыва — как ни старайся.

Если сложить это в один бинарник, самое частое действие (выкатка правки в REST) начнёт ронять самое чувствительное (живые звонки). Разделение — это не про масштаб, а про радиус поражения при деплое.

#Как изменение доезжает до клиента

Второе решение вытекает из первого: REST-план вообще не знает про сокеты. Он пишет в хранилище и публикует событие в шину; какая реплика шлюза держит нужное соединение — не его дело.

Сообщение пишется в хранилище и публикуется в топик Valkey, на который подписаны все реплики шлюза; каждая рассылает событие своим сокетам
Адресация по топику, а не по соединению — реплики остаются взаимозаменяемыми

События адресуются топиками (guild.<id>, user.<id>, channel.<id>), а не идентификаторами соединений. Это то самое решение, которое позволяет добавлять и убирать реплики шлюза, не ведя нигде реестр «кто где сидит». Цена — каждая реплика получает события, которые ей, возможно, не нужны; при разумных объёмах это дешевле, чем поддерживать актуальную карту соединений.

#Мелочи, которые диктует чужой протокол

Самое интересное в совместимой реализации — не крупные блоки, а места, где нельзя выбрать «как удобнее».

Идентификаторы не влезают в число JSON. Discord-снежинки и битовые маски прав давно перевалили за 2⁵³, а это предел точности числа в JSON. Значит, на проводе они обязаны быть строками, иначе клиент молча округлит их и начнёт путать объекты. Внутри они лежат в BIGINT: формально снежинка беззнаковая 64-битная, но знакового хватает примерно до 2084 года — тот же размен, что сделал сам Discord.

Настоящий IP нельзя брать из заголовка на веру. Готовый middleware, который переписывает адрес клиента из X-Forwarded-For, спуфится тривиально: заголовок присылает сам клиент. А раз лимитер логина считает попытки по IP, это превращается в неограниченный перебор паролей. Поэтому заголовку верят только от явно заданного доверенного прокси и читают его справа налево. Один конфиг-параметр, за которым стоит целый класс атак.

Голос не может ходить через Traefik. Traefik — HTTP-прокси, а RTP — сырой UDP. Медийный узел публикует диапазон портов напрямую и объявляет свой публичный адрес в ICE-кандидатах. Отдельная ловушка: если туда попадёт внутренний адрес контейнера, сервис поднимется без единой ошибки в логах, а голос не будет работать ни у кого снаружи.

Логин обязан быть синхронным. Нормальный современный вход — редирект в провайдера идентичности. Но контракт Discord — обычный POST /auth/login, который отвечает токеном прямо в ответе; редиректить некуда, клиент чужой и переписать его нельзя. Пришлось строить мост: пароль проверяется в ZITADEL (grant с логином и паролем — тот случай, для которого он и остался легальным), а токен сессии Concord выпускает свой. В базе Concord нет колонки с паролем вообще.

Ограничение протоколаЧто оно заставляет сделать
JSON-числа теряют точность за 2⁵³ID и права — строками на проводе, BIGINT внутри
Клиент не переписатьсинхронный логин через проверку пароля в IdP
RTP — это UDPмедийный узел мимо обратного прокси, публичный IP в ICE
Клиент падает на неожиданном ответе при стартена пути загрузки — пустые валидные ответы, а не 501

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

#Ограничение лимитера — и почему оно принято

Рейт-лимиты в Concord считаются в каждой реплике отдельно. При N репликах реальный лимит — N-кратный. Это не забытая задача, а осознанный размен: лимиты нужны, чтобы клиенты вели себя прилично и случайный цикл не выносил сервис, а не как квота на продажу. Точный глобальный счётчик стоит одного похода в Redis на каждый запрос — на этой нагрузке цена выше пользы.

Такие вещи полезно писать словами в документации проекта. Незаписанный компромисс через полгода превращается в баг, который кто-то «чинит» ценой удвоенной латентности.

#Чего в Concord нет

Голос не сквозно зашифрован. Discord-овский DAVE не реализован, SFU терминирует SRTP и видит медиа. Транспорт зашифрован, end-to-end — нет. Это ровно тот пункт, который заманчиво описать расплывчато, поэтому он вынесен в README отдельной строкой: описывать голос Concord как E2EE нельзя.

Не реализованы также вебхуки, интеракции и слэш-команды приложений, discovery и монетизация. Всё это отвечает 501 или пустыми заглушками — по правилу выше.

#Что из этого стоит забрать

  • Разделяйте по радиусу поражения деплоя, а не по красоте слоёв: вместе живут только те части, которые не жалко перезапустить одновременно.
  • Адресуйте события логическими топиками, а не соединениями — тогда реплики становятся взаимозаменяемыми бесплатно.
  • Чужой протокол — это набор ограничений, а не пожеланий. Каждое из них стоит выписать вместе с тем, что оно заставило сделать.
  • Про то, чего нет, писать прямо. Расплывчатая формулировка про шифрование — это не осторожность, а обещание, которое система не выполняет.

// свободен для заказов

Нужен сайт или сервис под ключ?