Мы с друзьями несколько лет сидели в Discord: вечером голосовая комната, кто-то стримит игру, кто-то просто фоном что-то делает. Потом захотелось своего. Без чужих правил, без подписок, без того, что в один день тебе скажут подтвердить возраст селфи (Discord, кстати, с 23 сентября именно это и раскатывает).

Готовые варианты я смотрел, но везде что-то не сходилось: то сервер нужен потяжелее, то голос и чат живут в разных программах, то установка рассчитана на человека, который сам админит серверы. А мне нужно было, чтобы всё было в одном окне, как в Discord, и чтобы сервер мог поднять любой из компании.

В итоге я написал своё. Называется Gomex, код открыт под AGPL. Расскажу, как он устроен и на какие грабли я наступил, пока доводил установку до состояния «справится человек, который ни разу не открывал терминал».

Текстовые каналы: ответы, упоминания, реакции, файлы
Текстовые каналы: ответы, упоминания, реакции, файлы

Что получилось

Сервер на одну компанию: текстовые каналы, голосовые комнаты, личка и групповые звонки, демонстрация экрана со звуком, камера, файлы, реакции. Вход только по приглашению. Почты и телефона не нужно: ник, пароль и ссылка от друга.

Голосовая комната
Голосовая комната

Есть приложения для Mac и Windows, которые висят в трее, ловят push-to-talk даже в свёрнутом окне и обновляются сами. На телефоне пока веб-версия, её можно добавить на экран «Домой».

Весь сервер в простое занимает около 120 МБ памяти. Хватает самого дешёвого VPS на 1 ГБ

Дизайн

Тут я потратил времени не меньше, чем на голос, и сделал больше откатов, чем где-либо ещё.

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

За основу структуры взял концепт Discord под visionOS, только без стекла. До этого было несколько заходов: стекло с градиентными обоями в духе iOS, плоский минимализм, попиксельная копия Discord. Всё мимо. В какой-то момент стало понятно, что проблема не в стиле, а в подложках: оттенков фона было слишком много, и глаз не понимал, что над чем лежит. Теперь их ровно два, окно и чуть более глубокий тон навигации. Всё, что нажимается, залито одним цветом.

Отдельно возился со скруглениями. Правило из гайдлайнов Apple: внутренний радиус равен внешнему минус отступ, иначе углы вложенных элементов выглядят кривыми, даже если никто не может сказать почему. У панелей радиус 36, у меню 24 с отступом 6, значит у пунктов меню 18. Из-за этого же иконки серверов остались кругами: дискордовский переход круга в квадрат при наведении ломает правило в углу.

Компоненты взяты из HeroUI v3, макеты я собирал в их же наборе для Figma. В теме переопределены только шрифт (Onest) и акцентный цвет, все отступы, радиусы и цвета привязаны к переменным. Благодаря этому макет и приложение не разъезжаются.

Ещё несколько решений, которые кажутся мелочами:

  • Тексты в интерфейсе — только названия и короткие описания. Никаких «Давай настроим…» и абзацев с объяснениями: интерфейс не разговаривает с человеком, он просто называет вещи.

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

  • Видео с камеры никогда не обрезается. Лучше чёрные поля, чем отрезанная половина лица.

  • Установка сервера занимает несколько минут, и смотреть на полосу загрузки скучно. Поэтому шаги установщика крутятся барабаном, как в часовом механизме, а рядом идут часы. Человек видит, что именно сейчас происходит.

  • Пустые экраны не пустые: на них белые изометрические рисунки в том же стиле, что и на сайте.

Дизайн
Дизайн

Стек и почему он такой маленький

Главное правило проекта, которое я себе поставил с самого начала: никаких новых сервисов. Никакого Redis, никакого Postgres, никаких очередей. Если фиче что-то нужно, она строится на том, что уже есть.

Получилось вот что:

  • Бэкенд: один процесс на Python. FastAPI для API, python-socketio для всего, что в реальном времени (сообщения, кто где сидит, кто говорит).

  • База: SQLite. Для одной компании в 10–80 человек это не компромисс, а лучший вариант. Бэкап это один файл, миграции простые, ничего не надо администрировать.

  • Файлы: просто на диске. Картинки перекодируются в WebP, заодно из фото с телефона вылетает EXIF с координатами.

  • Голос и видео: LiveKit.

  • Всё снаружи: Caddy, он же выпускает сертификаты.

  • Клиент: React 19, Vite, Tailwind 4, HeroUI. Десктоп на Electron.

Всё это крутится в Docker. Контейнер приложения запускается от непривилегированного пользователя, с файловой системой только для чтения (кроме папки с данными) и без capabilities. Секреты генерируются прямо на сервере при установке и в репозитории не живут.

Почему LiveKit, а не P2P

Это, наверное, главное решение во всём проекте.

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

LiveKit работает как SFU: каждый отправляет свой поток один раз на сервер, а сервер раздаёт его остальным. Ничего не перекодирует, просто пересылает, поэтому процессора и памяти ему нужно мало. Узким местом становится канал сервера, а у VPS он обычно 100 Мбит/с и больше.

Примерный расчёт: голос это около 40 кбит/с на человека, демонстрация экрана в 1080p около 2–3 Мбит/с на каждого зрителя. Десять человек и одна демонстрация дают где-то 25–30 Мбит/с исходящего трафика. Нормальных нагрузочных тестов я пока не гонял, это следующий шаг, так что честно говорю: это оценка, а не замер.

Один порт 443 на всё

Самая интересная инженерная часть.

Бывают сети, где UDP режется полностью. Офисный Wi-Fi, гостиничный, мобильный оператор с причудами. WebRTC в таких сетях падает на TURN: звонок идёт через сервер по TCP, обёрнутый в TLS. Чтобы это работало максимально везде, TURN хочется держать на 443 порту. Но на 443 уже сидит сайт.

Решение — Caddy с модулем layer4. Он принимает соединение на 443, смотрит на SNI в TLS-хэндшейке и решает, куда его отправить:

layer4 {
	:443 {
		@turn tls sni {$TURN_HOST}.{$DOMAIN}
		route @turn {
			tls
			subroute {
				@web regexp ^[A-Z][A-Z][A-Z] 3
				route @web {
					proxy 127.0.0.1:8081
				}
				route {
					proxy 127.0.0.1:5349
				}
			}
		}
		route {
			proxy {
				proxy_protocol v2
				upstream 127.0.0.1:8443
			}
		}
	}
}

Всё, что пришло не на поддомен TURN, уходит дальше к обычному Caddy с сайтом и приложением. Через PROXY protocol, чтобы приложение видело настоящие IP посетителей: на них завязаны лимиты на попытки входа.

А вот внутри TURN-поддомена пришлось изобретать. После TLS туда может прийти как звонок, так и обычный браузер (кто-то просто открыл адрес). Сначала я взял встроенный HTTP-матчер caddy-l4, и звонки начали рваться ровно через три секунды. Оказалось, матчер ждёт полную строку запроса, а STUN-сообщение такой строки не содержит, и через три секунды он сдаётся и рвёт соединение.

В итоге различаю по трём байтам. HTTP-запрос начинается с метода заглавными буквами: GET, POST, PRI для HTTP/2. А первый байт STUN-сообщения всегда от 0x00 до 0x03. Регулярка ^[A-Z][A-Z][A-Z] по первым трём байтам отделяет одно от другого, и браузер получает простую страницу вместо ошибки соединения, а звонок спокойно уходит в TURN.

UDP 443 при этом отдан TURN-серверу LiveKit, поэтому HTTP/3 на сайте выключен. Невелика потеря.

Установка без терминала

Технически поставить всё это несложно: одна команда на чистом Ubuntu или Debian, дальше мастер спрашивает домен и всё делает сам.

curl -fsSL https://github.com/gomex-inc/gomex/releases/latest/download/install.sh | sh

Но у меня была цель, чтобы сервер для компании мог поднять тот друг, который в жизни не открывал терминал. Для него есть экран «Свой сервер» прямо в десктоп-приложении. Ему нужны только IP и пароль root, которые хостер присылает на почту после оплаты.

Экран «Свой сервер» в приложении
Экран «Свой сервер» в приложении

Дальше приложение:

  1. Подключается к серверу по SSH прямо с компьютера пользователя, без посредников.

  2. Смотрит, что за система, сколько памяти и диска.

  3. Регистрирует бесплатный адрес на dedyn.io через API deSEC.

  4. Генерирует свой SSH-ключ, запускает тот же установщик и показывает его шаги.

  5. Установщик добавляет ключ приложения и отключает вход по паролю.

Установка по шагам
Установка по шагам

Пароль живёт только в памяти окна установки. Ключ хранится на компьютере, зашифрованный через системное хранилище (Keychain на Mac, DPAPI на Windows). На сервере остаётся только публичная часть, одна строка в authorized_keys с пометкой gomex-app. Её можно удалить сразу после установки, сервер продолжит работать.

Грабли с адресом

Домен оказался главной проблемой. Купить домен и прописать DNS — это ровно тот шаг, на котором отваливается не-технический человек. Я перебрал несколько вариантов.

sslip.io: адрес вида 1-2-3-4.sslip.io, ничего не надо регистрировать. Отказался: IP сервера виден прямо в имени.

Свой домен для всех установок (выдавать поддомены на gomex.что-то): удобно, но тогда все серверы светятся в журналах сертификатов под одним доменом, и один домен становится точкой отказа для всех.

DuckDNS: бесплатно, но имя и токен нужно руками получать на их сайте через вход Google или GitHub. Я прошёл это глазами друга, которому собирался отправить инструкцию, и понял, что он бросит на втором шаге.

deSEC в итоге. Это некоммерческий DNS-хостинг из Германии с нормальным API и бесплатными адресами на dedyn.io. Регистрация через API, подтверждение письмом, дальше всё автоматически.

Но и тут было весело. Капча при регистрации через API для адресов dedyn.io не принимается, ответ «conflicts with an existing domain, or is disallowed by policy». Пришлось регистрироваться без капчи, и тогда deSEC спрашивает её уже на странице активации из письма. А ещё на один аккаунт полагается один адрес, и повторная настройка с той же почтой молча ничего не создаёт.

Отдельная история с DNS на стороне пользователя. Свежий адрес только что зарегистрирован, а macOS успела закэшировать ответ «такого имени нет», и окно приложения после установки показывает чёрный экран. Теперь все проверки DNS приложение делает на самом сервере по SSH, через DNS-over-HTTPS, а не с компьютера пользователя.

Восстановление без почты

Раз нет почты, нет и «забыли пароль? мы пришлём ссылку». Сделал два пути.

Первый: при регистрации человек получает ключ восстановления, который показывается один раз. Примерно как seed-фраза у криптокошелька.

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

Обновления и цепочка поставки

Если ставишь чужой код на свой сервер, хочется понимать, что ставишь. Поэтому:

  • Установщик сверяет SHA-256 архива релиза. Docker-образы тянутся по digest, а не по тегу.

  • У установщика есть зеркало в npm, и curl-команда на сайте берёт его через jsDelivr. Если GitHub недоступен, установка всё равно пройдёт.

  • Обновления десктоп-приложения подписаны Ed25519. Приложение проверяет подпись и только потом ставит.

  • Каждую ночь сервер делает бэкап: консистентную копию базы через SQLite backup API и файлы через rsync --link-dest, чтобы неизменившиеся картинки не копировались заново, а жёстко линковались с прошлой ночи. Хранится 14 дней. Если на диске меньше гигабайта, бэкап пропускается, чтобы не забить диск и не положить чат.


Чего нет

Честно о том, что пока не сделано:

  • Нативных приложений для iOS и Android. Веб-версия на телефоне работает, но это не то же самое.

  • Ролей и прав доступа. Есть админ и все остальные.

  • Ботов и вебхуков.

  • Поиска по истории сообщений. SQLite FTS5 тут напрашивается, руки пока не дошли.

  • Сквозного шифрования. Сервер ваш, но сообщения на нём лежат в открытом виде.

Что дальше

Ближайшее: нагрузочный тест на LiveKit CLI, чтобы вместо оценки была цифра «до N человек с демонстрацией на 1 ГБ». Потом переезд сервера в один шаг (копия со старого VPS разворачивается на новом прямо из установщика) и поиск.

Если поставите и споткнётесь, пишите в комментариях или в issues на GitHub. Установку я сейчас довожу до ума, так что любой отчёт «вот тут я не понял, что делать» для меня ценнее звёздочки.

Комментарии (1)


  1. hardtop
    21.10.2026 16:14

    Достойно, просто и понятно. Спасибо