Concord: сервер, совместимый с Discord — два плана вместо одного
«Совместимость с Discord» звучит как маркетинг, пока не посмотришь, из чего сделаны сами клиенты. Заметная часть открытых десктоп-клиентов Discord вообще ничего не реализует: это Electron-обёртка, которая грузит настоящий сайт Discord в Chromium и разрешает свой IPC-мост по захардкоженному списку хостов. Если сервер отвечает так же, как настоящий, перенаправить такой клиент — правка двух строк в форке.
Из этого и родился Concord: clean-room реализация Discord API (REST v9/v10), Gateway v10 и Voice Gateway v8 с собственным SFU. Не «похожий чат», а сервер, к которому подключаются чужие клиенты и боты. Ниже — про то, как это устроено внутри, и про несколько мест, где протокол диктует архитектуру.
#Два плана: управляющий и медийный
Первое и самое дорогое решение — разделить систему на два плана.
Причина простая: они ломаются и масштабируются по-разному.
- ▸REST не имеет состояния. Реплики множатся тривиально, деплой — обычная замена контейнера.
- ▸Шлюз держит сокеты. Состояние есть, но оно восстановимо: клиент переподключится и заново соберёт
READY. - ▸Голос держит медиа. Вот это состояние нереплицируемо в принципе. Нельзя «перевыкатить» узел с идущим звонком без обрыва — как ни старайся.
Если сложить это в один бинарник, самое частое действие (выкатка правки в REST) начнёт ронять самое чувствительное (живые звонки). Разделение — это не про масштаб, а про радиус поражения при деплое.
#Как изменение доезжает до клиента
Второе решение вытекает из первого: REST-план вообще не знает про сокеты. Он пишет в хранилище и публикует событие в шину; какая реплика шлюза держит нужное соединение — не его дело.
События адресуются топиками (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 или пустыми заглушками — по правилу выше.
#Что из этого стоит забрать
- ▸Разделяйте по радиусу поражения деплоя, а не по красоте слоёв: вместе живут только те части, которые не жалко перезапустить одновременно.
- ▸Адресуйте события логическими топиками, а не соединениями — тогда реплики становятся взаимозаменяемыми бесплатно.
- ▸Чужой протокол — это набор ограничений, а не пожеланий. Каждое из них стоит выписать вместе с тем, что оно заставило сделать.
- ▸Про то, чего нет, писать прямо. Расплывчатая формулировка про шифрование — это не осторожность, а обещание, которое система не выполняет.