Self-hosted связь со своей семьей
- четверг, 11 декабря 2025 г. в 00:00:10
Я сделал видеосвязь для семьи: один бинарник, домен, 200 рублей в месяц — и всё работает
Когда российское правительство начало блокировать звонки один за другим, я понял, что нужно что-то делать. Семья должна оставаться на связи — это не обсуждается. Но все популярные решения либо заблокированы, либо требуют VPN, либо сложны для установки, а также не дают полного контроля над данными.
Я решил создать свой собственный нано-сервис для видео и аудиозвонков. + который:
Работает из одного бинарника (никаких зависимостей, никаких установок)
Требует только домен и дешёвый VPS
Не будет заблокирован (потому что это ваш личный сервер)
Полностью самодостаточен: встроенный TURN-сервер, автоматические сертификаты, генерация ключей
Шифрует всё (или почти все)
Казалось бы, простая задача. Но оказалось, что даже в 2025 году создать полностью автономное решение для видеозвонков — это целое приключение.

Проблема №1: Всё должно быть в одном бинарнике
Первое требование было железным: один бинарник, который можно запустить где угодно. Никаких npm install, никаких pip install, никаких docker-compose. Скачал, запустил — ��аботает. Сервер забанят - перенес - работает снова, ничего перенастраивать не надо.
Go идеально подходит для таких задач. Я использовал embed.FS для встраивания всего веб-приложения прямо в бинарник.
Вся фронтенд-часть (HTML, CSS, JavaScript), переводы, манифест PWA — всё это компилируется прямо в исполняемый файл. Размер бинарника получается около 20-25 МБ, что вполне приемлемо.
Но тут возникла проблема: как обновлять кэш service worker, если всё встроено в бинарник? Браузер кэширует статические файлы, и если просто заменить бинарник, пользователи могут не получить обновления.
Решение нашлось в инжекции timestamp при компиляции:
const buildTimestamp = time.Now().Unix()
// При отдаче service-worker.js инжектируем timestamp в имя кэша
cacheName := fmt.Sprintf("familycall-v3-%d", buildTimestamp)
swStr = strings.ReplaceAll(swStr,
`const CACHE_NAME = 'familycall-v3';`,
fmt.Sprintf(`const CACHE_NAME = '%s';`, cacheName))Теперь каждый билд получает уникальное имя кэша, и браузер автоматически обновляет приложение.
Проблема №2: WebRTC шифрование и TURN-сервер
WebRTC по умолчанию использует DTLS-SRTP для шифрования медиапотоков. Это хорошо — шифрование работает из коробки, без дополнительных настроек. Но есть нюанс: WebRTC требует либо прямого peer-to-peer соединения, либо TURN-сервера для обхода NAT. TURN шифрования не имеет, поэтому, в целом, можно узнать когда вы звонили, но о чем конкретно общались - нет.
Большинство решений используют внешние TURN-серверы (Google, Twilio и т.д.). Но это означает зависимость от третьих сторон и потенциальные проблемы с блокировками.
Я решил встроить TURN-сервер прямо в приложение, используя библиотеку Pion TURN:
s, err := turn.NewServer(turn.ServerConfig{
Realm: realm,
AuthHandler: simpleAuthHandler(creds.Username, creds.Password),
PacketConnConfigs: []turn.PacketConnConfig{
{
PacketConn: udpListener,
RelayAddressGenerator: &turn.RelayAddressGeneratorStatic{
RelayAddress: publicIP,
Address: "0.0.0.0",
},
},
},
})TURN-серверу нужно знать публичный IP адрес сервера для релея. Я реализовал автоматическое определение через ipify.org:
func getPublicIP() net.IP {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://api.ipify.org")
// ... парсинг IP
return ip
}Если определение не удалось, используется локальный IP как fallback. В большинстве случаев это работает.
Учётные данные TURN генерируются автоматически при первом запуске и сохраняются в keys/turn-username.key и keys/turn-password.key:
func loadOrGenerateCredentials() Credentials {
// Пытаемся загрузить существующие
if usernameData, err := os.ReadFile(usernameFile); err == nil {
// ... загрузка
}
// Генерируем новые
password := generatePassword() // 16 случайных байт в hex
// Сохраняем с правами 0600
}Проблема №3: Автоматические SSL-сертификаты
HTTPS обязателен для WebRTC и push-уведомлений. Но покупать сертификаты каждый год — это лишние расходы и головная боль. Да и так уже никто, совсем никто, не делает.
Я использовал golang.org/x/crypto/acme/autocert для автоматического получения и обновления сертификатов:
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: func(ctx context.Context, host string) error {
// Проверяем, что запрос идёт на наш домен
normalizedHost := normalizeDomain(host)
if normalizedHost != normalizedDomain {
return fmt.Errorf("host not configured")
}
return nil
},
Cache: autocert.DirCache(certsDir),
}Сертификаты Let's Encrypt действуют 90 дней. Нужно обновлять ��х заранее. Я реализовал фоновую горутину, которая проверяет срок действия каждый месяц:
func startCertificateRenewal(m *autocert.Manager, domain string, certsDir string) {
ticker := time.NewTicker(30 * 24 * time.Hour) // Каждый месяц
defer ticker.Stop()
for range ticker.C {
checkAndRenewCertificate(m, domain, certsDir)
}
}
func checkAndRenewCertificate(m *autocert.Manager, domain string, certsDir string) {
cert, err := m.GetCertificate(&tls.ClientHelloInfo{ServerName: domain})
// Парсим сертификат и проверяем срок действия
if daysUntilExpiry < 30 {
// Триггерим обновление
m.GetCertificate(&tls.ClientHelloInfo{ServerName: domain})
}
}Теперь сервер также сам следит за сертификатами и обновляет их до истечения срока.
Проблема №4: Push-уведомления без внешних сервисов
Push-уведомления критически важны для видеозвонков — пользователь должен получать уведомления даже когда приложение закрыто. Хотя в целом данный софт работает по принципу - договорились пообщаться в телеграмме (или любом другом полуживом мессенжере без видео и аудио звонков) - вы зашли по ссылке как организатор семьи - ваш дедушка/бабушка зашли по ссылке-приглашению и нажали там на видеокамеру или на трубку - все, больше никаких действий от них не требуется.
Web Push API требует VAPID (Voluntary Application Server Identification) ключей. Обычно их генерируют один раз и хранят в конфиге. Я сделал автоматическую генерацию:
func loadVAPIDKeys() *VAPIDKeys {
// Пытаемся загрузить из файлов
if publicKeyData, err := os.ReadFile(publicKeyFile); err == nil {
// ... загрузка существующих ключей
}
// Генерируем новые ECDSA ключи
privateKeyECDSA, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
// Извлекаем сырые байты приватного ключа (32 байта для P-256)
privateKeyBytes := make([]byte, 32)
privateKeyECDSA.D.FillBytes(privateKeyBytes)
// Кодируем в base64 URL-safe
privateKeyBase64 := base64.RawURLEncoding.EncodeToString(privateKeyBytes)
// Сохраняем в keys/vapid-*.key с правами 0600
}Ключи генерируются автоматически при первом запуске и сохраняются в keys/ директории. Публичный ключ отдаётся клиентам через API, приватный используется для подписи push-уведомлений.
Проблема №5: Безопасность и приватность
Когда создаёшь мессенджер для семьи, безопасность — это не опция, а обязательное требование. Хотя она все равно ограничивается знанием домена, который вы использовали и названия вашего аккаунта. Но, вы всегда можете просто отклонить звонок:)
Я сделал шифрование почти везде и ограничил доступ к ключам, а также ограничил интересующихся лиц:
HTTPS/TLS: Весь трафик между клиентом и сервером шифруется через TLS 1.2+ с сертификатами Let's Encrypt
WebRTC DTLS-SRTP: Медиапотоки шифруются end-to-end через DTLS-SRTP (это встроено в WebRTC, работает автоматически)
JWT токены: API защищён JWT токенами с автоматически генерируемым секретом. Правда, без времени протухания, это не критично
Файловые права: Все ключи сохраняются с правами 0600 (только владелец может читать/писать)
Domain validation: Сервер принимает запросы только для настроенного домена (защита от certificate abuse) - чтобы не лезли, не зная вашего домена - получат обычный SSL Error
Дополнительно, чтобы интересующимся взломать ваш личный аудиосервис было неинтересно, я свел к минимуму хранимую информацию:
Медиапотоки: WebRTC работает peer-to-peer, сервер не видит и не записывает звонки
История звонков: Сервер только инициирует звонки, но не логирует их содержимое
Минимум данных: Только username, user ID, связи между пользователями (кто кого пригласил)
Проблема №6: Backup и Restore
Когда всё работает на одном сервере, важно иметь возможность сделать backup и восстановить всё на новом сервере. Или же когда заинтересуются конкретно вами.
Я реализовал функцию backup/restore для организатора семьи (первого аккаунта):
func (h *Handlers) Backup(c *gin.Context) {
// Проверяем, что пользователь — первый пользователь (организатор)
// Создаём ZIP архив с:
// - keys/ директорией (JWT secret, VAPID keys, TURN credentials)
// - certs/ директорией (Let's Encrypt сертификаты, domain.txt)
// - database файлом (SQLite база данных)
zipWriter := zip.NewWriter(zipFile)
// ... добавление файлов в ZIP
}ZIPом можно делиться и развернуть копию за одну команду и одну загрузку файла на другой сервер.
Backup создаётся через веб-интерфейс (кнопка в меню), restore — через загрузку ZIP файла. После restore нужно перезапустить сервер, чтобы все компоненты подхватили восстановленные данные.
Итого, архитектура решения получилась следующая:
Gin: HTTP фреймворк для API и статики
GORM: ORM для работы с SQLite
Pion TURN: TURN-сервер для WebRTC
Gorilla WebSocket: WebSocket для real-time уведомлений
Autocert: Автоматическое управление Let's Encrypt сертификатами
PWA: Прогрессивное веб-приложение, устанавливается на телефон/планшет, vanilla JS
WebRTC API: Нативные браузерные API для видеозвонков
Web Push API: Push-уведомления через service worker
WebSocket: Real-time коммуникация для сигналинга WebRTC
SQLite: встраиваемая простейшая БД
И все это запаковано в один бинарник.
Что получилось
Работает из одного бинарника — скачал, запустил, работает. Нужен Root, чтобы биндить порты <10K, можете собрать из сорцов (предложения по выпиливанию необходимости рута приветствуются)
Автоматически настраивается — сертификаты, ключи, TURN-сервер всё генерируется само
Работает за NAT — встроенный TURN-сервер обеспечивает соединение даже в сложных сетевых условиях
Шифрует всё, кроме TURN — HTTPS + DTLS-SRTP для полной защиты
Не требует внешних зависимостей — всё работает локально на вашем сервере
Дешёвое в эксплуатации — нужен только домен (~500₽/год) и VPS (~200₽/месяц)
Мысли на будущее
Автообновление: Сейчас для обновления нужно вручную скачать новый бинарник и перезапустить сервер. Если вдруг этот софт будет востребованным, то я это сделаю
Мобильные приложения: Сейчас это PWA, но нативные приложения для iOS/Android были бы удобнее, там можно было бы переключать громкую связь, но опять же, удобно ли было это нашим родственникам
Групповые звонки: Сейчас поддерживаются только звонки один-на-один
Как это использовать организатору семьи
Купить домен (можно за 500₽/год на reg.ru или timeweb)
Арендовать VPS (можно за 200₽/месяц на, опять же, timeweb, selectel и т.д.)
Настроить DNS: A-запись домена на IP VPS
Скачать бинарник и запустить на сервере из под root в screen-сессии
Открыть домен в браузере — сервер сам получит SSL-сертификат
Зарегистрироваться как первый пользователь (станет организатором семьи)
Пригласить семью через ссылки-приглашения
Как это использовать родственнику
Перейти по ссылке-приглашению
Нажать на звонок или видеокамеру
Всё. Больше ничего не нужно. Никаких docker-compose, никаких nginx, никаких внешних сервисов.

Dungeons ahead!
Это ранняя версия, MVP. Могут быть баги, не все функции реализованы, документация может быть неполной.
Новые идеи и улучшения приветствуются! Если у вас есть идеи, как улучшить проект, или вы нашли баг — создавайте issue или pull request. Вся помощь будет полезна.
Репозиторий: GitHub
Лицензия: GPLv3
Если у вас есть вопросы о технических деталях или идеи по улучшению — пишите в комментариях! Также приглашаю хостинг-провайдеров, чтобы мы сделали установку одной кнопкой, это было бы здорово!