golang

Self-hosted связь со своей семьей

  • четверг, 11 декабря 2025 г. в 00:00:10
https://habr.com/ru/articles/975304/

Я сделал видеосвязь для семьи: один бинарник, домен, 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: Безопасность и приватность

Когда создаёшь мессенджер для семьи, безопасность — это не опция, а обязательное требование. Хотя она все равно ограничивается знанием домена, который вы использовали и названия вашего аккаунта. Но, вы всегда можете просто отклонить звонок:)

Я сделал шифрование почти везде и ограничил доступ к ключам, а также ограничил интересующихся лиц:

  1. HTTPS/TLS: Весь трафик между клиентом и сервером шифруется через TLS 1.2+ с сертификатами Let's Encrypt

  2. WebRTC DTLS-SRTP: Медиапотоки шифруются end-to-end через DTLS-SRTP (это встроено в WebRTC, работает автоматически)

  3. JWT токены: API защищён JWT токенами с автоматически генерируемым секретом. Правда, без времени протухания, это не критично

  4. Файловые права: Все ключи сохраняются с правами 0600 (только владелец может читать/писать)

  5. 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: встраиваемая простейшая БД

И все это запаковано в один бинарник.

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

  1. Работает из одного бинарника — скачал, запустил, работает. Нужен Root, чтобы биндить порты <10K, можете собрать из сорцов (предложения по выпиливанию необходимости рута приветствуются)

  2. Автоматически настраивается — сертификаты, ключи, TURN-сервер всё генерируется само

  3. Работает за NAT — встроенный TURN-сервер обеспечивает соединение даже в сложных сетевых условиях

  4. Шифрует всё, кроме TURN — HTTPS + DTLS-SRTP для полной защиты

  5. Не требует внешних зависимостей — всё работает локально на вашем сервере

  6. Дешёвое в эксплуатации — нужен только домен (~500₽/год) и VPS (~200₽/месяц)

Мысли на будущее

  • Автообновление: Сейчас для обновления нужно вручную скачать новый бинарник и перезапустить сервер. Если вдруг этот софт будет востребованным, то я это сделаю

  • Мобильные приложения: Сейчас это PWA, но нативные приложения для iOS/Android были бы удобнее, там можно было бы переключать громкую связь, но опять же, удобно ли было это нашим родственникам

  • Групповые звонки: Сейчас поддерживаются только звонки один-на-один


Как это использовать организатору семьи

  1. Купить домен (можно за 500₽/год на reg.ru или timeweb)

  2. Арендовать VPS (можно за 200₽/месяц на, опять же, timeweb, selectel и т.д.)

  3. Настроить DNS: A-запись домена на IP VPS

  4. Скачать бинарник и запустить на сервере из под root в screen-сессии

  5. Открыть домен в браузере — сервер сам получит SSL-сертификат

  6. Зарегистрироваться как первый пользователь (станет организатором семьи)

  7. Пригласить семью через ссылки-приглашения

Как это использовать родственнику

  1. Перейти по ссылке-приглашению

  2. Нажать на звонок или видеокамеру

Всё. Больше ничего не нужно. Никаких docker-compose, никаких nginx, никаких внешних сервисов.

Это я
Это я

Dungeons ahead!

Это ранняя версия, MVP. Могут быть баги, не все функции реализованы, документация может быть неполной.

Новые идеи и улучшения приветствуются! Если у вас есть идеи, как улучшить проект, или вы нашли баг — создавайте issue или pull request. Вся помощь будет полезна.

Репозиторий: GitHub

Лицензия: GPLv3

Если у вас есть вопросы о технических деталях или идеи по улучшению — пишите в комментариях! Также приглашаю хостинг-провайдеров, чтобы мы сделали установку одной кнопкой, это было бы здорово!