Раньше я мог спокойно общаться с друзьями, используя Discord, делиться игровым процессом в Factorio или RimWorld, или смотреть стримы прохождения какой-нибудь новинки (или старой игры) в Steam. Со временем Discord перестал работать без дополнительных манипуляций, а с появлением MAX накрылась и нормальная голосовая связь в Telegram (случайное совпадение). Можно использовать различные обходные решения, но я решил пойти другим путем и сделать что-то свое — чисто ради интереса и эксперимента.

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

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

Архитектура решения

Проект реализован на Rust и разделен на несколько взаимосвязанных модулей:

  • ntied-crypto — криптографический модуль, реализующий операции с ключами p256, цифровые подписи ECDSA и симметричное шифрование AES-GCM

  • ntied-transport — низкоуровневый транспортный протокол поверх UDP с поддержкой NAT traversal

  • ntied-server — минималистичный сервер для преодоления NAT, хранящий информацию о публичных адресах клиентов

  • ntied — основное приложение с бизнес-логикой, UI на iced и работой с аудио через cpal

Для хранения данных используется SQLite с шифрованием через sqlcipher. Хеширование паролей реализовано с помощью Argon2id. Высокоуровневый протокол обмена сообщениями построен на serde и bincode.

Peer-to-peer протокол

Транспортный протокол работает поверх UDP и обеспечивает надежную доставку данных между двумя участниками (на самом деле, надежность доставки достигается в более высокоуровневых абстракциях). Протокол состоит из нескольких типов пакетов, которые можно разделить на две категории.

Структура пакетов

Все пакеты транспортного уровня представлены следующим enum:

pub enum Packet {
    Handshake(HandshakePacket),
    HandshakeAck(HandshakeAckPacket),
    Encrypted(EncryptedPacket),
}

После установления соединения все пакеты шифруются и передаются внутри EncryptedPacket. Расшифрованное содержимое представляет собой:

pub enum DecryptedPacket {
    Heartbeat(HeartbeatPacket),
    HeartbeatAck(HeartbeatPacket),
    Data(DataPacket),
    Rotate(RotatePacket),
    RotateAck(RotatePacket),
}

Незашифрованные пакеты для handshake

Первая категория — пакеты для установления соединения. Они передаются в открытом виде, так как шифрование ещё не установлено.

Структура HandshakePacket:

pub struct HandshakePacket {
    pub source_id: u32,
    pub peer_address: Address,
    pub address: Address,
    pub public_key: Vec<u8>,
    pub ephemeral_public_key: Vec<u8>,
    pub signature: Vec<u8>,
}

Handshake — инициирует соединение, содержит source_id (уникальный идентификатор сессии), адреса обоих участников, долговременный публичный ключ, эфемерный публичный ключ для ECDH и цифровую подпись эфемерного ключа. Подпись позволяет убедиться, что эфемерный ключ действительно создан владельцем долговременного ключа.

HandshakeAck — подтверждение соединения, содержит аналогичный набор полей плюс target_id от инициатора. После обмена этими пакетами обе стороны вычисляют общий секрет через ECDH и переходят к защищенной коммуникации.

Зашифрованные пакеты

Вторая категория — пакеты внутри установленного соединения. Все они шифруются с помощью AES-GCM:

Heartbeat/HeartbeatAck — пакеты для проверки активности соединения. Отправляются каждые 10 секунд. Если в течение 30 секунд не получено ни одного пакета от собеседника, соединение считается оборванным.

Data — полезная нагрузка, содержит произвольные данные приложения (сообщения, аудио, метаданные звонков).

Rotate/RotateAck — механизм для ротации ключей шифрования. Этот механизм обеспечивает Perfect Forward Secrecy: даже если долговременный ключ будет скомпрометирован в будущем, расшифровать старые сообщения не получится.

Система эпох и ротация ключей

Для защиты от компрометации используется система эпох (epoch). Каждая эпоха — это период времени, в течение которого используется определенная пара эфемерных ключей для шифрования. При смене эпохи происходит следующее:

  1. Генерируется новая пара эфемерных ключей

  2. Публичный ключ подписывается долговременным ключом

  3. Отправляется пакет Rotate с новым публичным ключом и подписью

  4. Вторая сторона проверяет подпись, вычисляет новый общий секрет

  5. Отправляет RotateAck со своим новым эфемерным ключом

  6. Обе стороны переключаются на новую эпоху

Номер эпохи занимает один байт, что позволяет обрабатывать до 127 эпох (верхние биты зарезервированы для идентификации типа пакета). После достижения максимального значения счетчик циклически возвращается к единице.

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

Преодоление NAT

Прямое UDP-соединение между клиентами за NAT невозможно без предварительного обмена адресами. Для этого реализован вспомогательный сервер с публичным IP-адресом. Клиент регистрируется на сервере, отправляя свой публичный ключ. Сервер сохраняет соответствие между публичным ключом и UDP-адресом клиента.

Когда клиент A хочет соединиться с клиентом B, он отправляет запрос на сервер с публичным ключом B. Сервер одновременно отправляет обоим клиентам информацию об адресах и публичных ключах друг друга. После этого клиенты инициируют Handshake напрямую. Благодаря тому, что оба клиента одновременно отправляют UDP-пакеты друг другу, NAT создает соответствующие маппинги портов, и пакеты начинают проходить.

Сервер также поддерживает heartbeat-механизм для обновления информации об адресах клиентов, поскольку NAT-маппинги могут меняться со временем.

Интерфейс транспортного уровня

Для работы с транспортом реализованы два основных типа: Transport и Connection.

Интерфейс Transport предоставляет методы для создания и принятия соединений:

impl Transport {
    pub async fn bind(
        addr: impl ToSocketAddrs,
        address: Address,
        private_key: PrivateKey,
        server_addr: SocketAddr,
    ) -> Result<Self, Error>;

    pub async fn connect(&self, address: Address) -> Result<Connection, Error>;

    pub async fn accept(&self) -> Result<Connection, Error>;

    pub fn local_addr(&self) -> SocketAddr;

    pub fn address(&self) -> Address;
}

Метод bind создает UDP-сокет и регистрируется на вспомогательном сервере. Метод connect инициирует соединение с удаленным участником по его адресу (публичному ключу), а accept ожидает входящие соединения.

После установления соединения работа происходит через Connection:

impl Connection {
    pub async fn send(&self, data: impl Into<Vec<u8>>) -> Result<(), Error>;

    pub async fn recv(&self) -> Result<Vec<u8>, Error>;

    pub fn peer_addr(&self) -> SocketAddr;

    pub fn peer_address(&self) -> &Address;

    pub fn peer_public_key(&self) -> &PublicKey;
}

Методы send и recv предоставляют простой интерфейс для обмена данными. Внутри send автоматически происходит шифрование с генерацией nonce, а recv возвращает уже расшифрованные данные. Соединение поддерживается автоматически через heartbeat-пакеты и закрывается при таймауте или явном drop.

Хранилище данных

Система хранения построена на двух файлах: meta.json и data.db. Такая архитектура обусловлена требованиями безопасности и производительности.

Для работы с базой данных используется библиотека tokio-sqlite — это собственная разработка, предоставляющая асинхронный интерфейс к SQLite. Важной особенностью является то, что при подключении зависимости пришлось явно прокинуть features bundled и bundled-sqlcipher (о которых раньше не приходилось задумываться), чтобы получить поддержку sqlcipher:

tokio-sqlite = { version = "0.*", features = ["bundled", "bundled-sqlcipher"] }

Без этих features библиотека не сможет работать с зашифрованными базами данных, так как стандартный SQLite не включает поддержку шифрования.

Файл meta.json

Этот файл содержит параметры для получения ключа шифрования базы данных:

{
  "hash": {
    "type": "Argon2id",
    "m_cost": 65536,
    "t_cost": 3,
    "p_cost": 2
  },
  "salt": "..."
}

Здесь хранятся параметры функции Argon2id и случайная соль. Argon2id — это функция хеширования паролей, устойчивая к атакам на GPU и ASIC. Параметры настроены так, чтобы обеспечить баланс между безопасностью и производительностью: операция занимает около 64 МБ памяти и выполняется примерно секунду на современном процессоре.

Файл data.db

Это зашифрованная база данных SQLite с использованием sqlcipher. Ключ шифрования получается путем применения Argon2id к паролю пользователя с параметрами из meta.json. SQLCipher шифрует каждую страницу базы данных отдельно, используя AES-256 в режиме CBC.

Инициализация и разблокировка базы данных

При открытии базы данных происходит следующий процесс:

  1. Чтение meta.json и извлечение параметров Argon2id

  2. Вычисление ключа шифрования из пароля

  3. Подключение к data.db через tokio-sqlite

  4. Разблокировка базы через PRAGMA-команду

Разблокировка базы выполняется SQL-запросом с передачей ключа в шестнадцатеричном формате:

let hex_key = hex::encode(key).to_uppercase();
let pragma_key = format!("PRAGMA key = \"x'{}'\"", hex_key);
connection.query(pragma_key, Vec::<Value>::new()).await?;

SQLCipher использует этот ключ для расшифровки страниц базы данных по мере их чтения. Если ключ неверный, запросы к базе будут возвращать ошибки о поврежденной базе данных.

При смене пароля используется аналогичная команда PRAGMA rekey:

let hex_key = hex::encode(new_key).to_uppercase();
let pragma_key = format!("PRAGMA rekey = \"x'{}'\"", hex_key);
connection.query(pragma_key, Vec::<Value>::new()).await?;

Команда rekeyперешифровывает все страницы базы данных новым ключом. Это происходит атомарно и не требует временных копий. Такая двухфайловая архитектура (meta.json + data.db) позволяет изменять параметры хеширования пароля независимо от содержимого базы данных.

Проверка пароля

Для быстрой проверки правильности пароля при открытии базы хранится SHA-256 хеш пароля в памяти после успешной аутентификации. Это позволяет проверять пароль при смене без повторного обращения к базе данных.

Структура базы данных включает таблицы для контактов, сообщений и конфигурации. Все операции с базой выполняются асинхронно через tokio-sqlite.

Работа с чатами

Система обмена сообщениями построена на трех типах пакетов высокого уровня, которые сериализуются с помощью bincode и передаются внутри Data-пакетов транспортного уровня:

Message — содержит message_id (UUID v7), log_id (последовательный номер для разрешения конфликтов) и тип сообщения. Сейчас поддерживается только текст, но архитектура позволяет легко добавить файлы, изображения или голосовые сообщения.

MessageAck — подтверждение получения сообщения. Отправитель помечает сообщение как доставленное только после получения этого пакета.

Conflict — уведомление о конфликте log_id. В peer-to-peer системе оба участника могут одновременно отправить сообщения с одинаковым log_id. При обнаружении такой коллизии отправляется Conflict-пакет, и обе стороны пересогласовывают нумерацию.

Разрешение конфликтов

Каждое сообщение в чате имеет два идентификатора: глобальный message_id и локальный log_id. UUID v7 используется как глобальный идентификатор, поскольку он включает временную метку и гарантирует уникальность. log_id нужен для упорядочивания сообщений в пользовательском интерфейсе.

При возникновении конфликта log_id сообщение с более поздним временем создания получает приоритет и сохраняет свой номер, а другое сообщение пронумеровывается заново. Это гарантирует консистентность истории чата на обеих сторонах.

Хранение сообщений

В базе данных создается таблица message со следующей структурой: id (автоинкремент), contact_id (внешний ключ на контакт), message_id (UUID), log_id (порядковый номер), incoming (флаг входящего сообщения), kind (тип сообщения), content (содержимое), create_time (время создания), receive_time (время получения), read_time (время прочтения).

Индексы построены по парам (contact_id, log_id) и (contact_id, create_time) для быстрого поиска и сортировки сообщений.

Интерфейс ChatManager

Для управления чатами реализован ChatManager, предоставляющий следующий API:

impl ChatManager {
    pub async fn new(
        storage: Arc<TokioMutex<Storage>>,
        contact_manager: Arc<ContactManager>,
    ) -> Result<Self, anyhow::Error>;

    pub async fn list_contact_chats(&self) -> Vec<ChatHandle>;

    pub async fn add_contact_chat(
        &self,
        address: Address,
        public_key: PublicKey,
        name: String,
        local_name: Option<String>,
    ) -> Result<ChatHandle, anyhow::Error>;

    pub async fn get_contact_chat(&self, address: Address) -> Option<ChatHandle>;

    pub async fn remove_contact_chat(&self, address: Address) -> Result<(), anyhow::Error>;
}

При инициализации ChatManager загружает все контакты из базы данных и создает для каждого ChatHandle — объект для работы с конкретным чатом. Каждый ChatHandle содержит ссылку на хранилище и автоматически синхронизирует сообщения при отправке и получении.

Метод add_contact_chat создает новый контакт в базе данных и устанавливает соединение через ContactManager. После этого можно отправлять сообщения и получать уведомления о входящих через механизм listener'ов.

Работа со звонками

Голосовые звонки реализованы поверх установленного peer-to-peer соединения. Протокол звонков состоит из нескольких типов пакетов:

Start — инициация звонка, содержит call_id (UUID) для идентификации сессии.

Accept/Reject — ответ на входящий звонок.

End — завершение активного звонка любой из сторон.

CodecOffer/CodecAnswer — согласование параметров аудиокодека. Инициатор отправляет список поддерживаемых кодеков и предпочитаемые параметры, принимающая сторона выбирает подходящий вариант из списка.

AudioData — пакет со сжатым аудио, содержит call_id, порядковый номер (sequence), временную метку (timestamp), тип кодека, количество каналов и закодированные данные.

Работа с аудиоустройствами

Для захвата и воспроизведения звука используется библиотека cpal, которая предоставляет кроссплатформенный доступ к аудиоустройствам. После принятия звонка создается AudioState — структура, содержащая:

  • Encoder и Decoder для выбранного кодека

  • CaptureStream для захвата аудио с микрофона

  • PlaybackStream для воспроизведения на динамики

  • Несколько асинхронных задач для обработки потоков

Захваченный звук помещается в кольцевой буфер, откуда его читает задача кодирования. Закодированные фреймы отправляются через сеть. Принятые фреймы декодируются и помещаются в выходной кольцевой буфер для воспроизведения.

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

CallManager следит за тем, чтобы одновременно был активен только один звонок. Попытка инициировать второй звонок возвращает ошибку. При завершении звонка автоматически останавливаются все задачи обработки аудио и освобождаются ресурсы.

Для каждого активного соединения с контактом запускается задача опроса пакетов (polling task). Она читает Data-пакеты из транспортного соединения, десериализует их и маршрутизирует в соответствующий обработчик в зависимости от типа (чат или звонок).

Кодеки

Архитектура поддерживает различные аудиокодеки. Согласование происходит через обмен CodecOffer/CodecAnswer пакетами. Каждая сторона сообщает о поддерживаемых кодеках и их параметрах (частота дискретизации, битрейт). Выбирается кодек, поддерживаемый обеими сторонами, с предпочтением более эффективных вариантов.

Реализована поддержка переключения аудиоустройств во время звонка. При смене устройства создается новый CaptureStream или PlaybackStream, а старые потоки корректно останавливаются. Также доступно управление громкостью и отключение микрофона.

Интерфейс CallManager

Управление звонками осуществляется через CallManager:

impl CallManager {
    pub fn new(contact_manager: Arc<ContactManager>) -> Arc<Self>;

    pub async fn start_call(&self, address: Address) -> Result<CallHandle, anyhow::Error>;

    pub async fn accept_call(&self, address: Address) -> Result<(), anyhow::Error>;

    pub async fn reject_call(&self, address: Address) -> Result<(), anyhow::Error>;

    pub async fn end_call(&self, address: Address) -> Result<(), anyhow::Error>;

    pub async fn get_current_call(&self) -> Option<CallHandle>;

    pub async fn is_in_call(&self) -> bool;

    pub async fn is_muted(&self) -> Result<bool, anyhow::Error>;

    pub async fn toggle_mute(&self) -> Result<bool, anyhow::Error>;

    pub async fn get_current_input_device(&self) -> Option<String>;

    pub async fn get_current_output_device(&self) -> Option<String>;

    pub async fn switch_input_device(
        &self,
        device_name: Option<String>,
    ) -> Result<(), anyhow::Error>;

    pub async fn switch_output_device(
        &self,
        device_name: Option<String>,
    ) -> Result<(), anyhow::Error>;

    pub async fn set_capture_volume(&self, volume: f32) -> Result<(), anyhow::Error>;

    pub async fn set_playback_volume(&self, volume: f32) -> Result<(), anyhow::Error>;

    pub async fn get_capture_volume(&self) -> Result<f32, anyhow::Error>;

    pub async fn get_playback_volume(&self) -> Result<f32, anyhow::Error>;
}

CallManager гарантирует, что одновременно активен только один звонок. При попытке начать второй звонок метод start_call вернет ошибку. После принятия звонка через accept_call автоматически инициализируется AudioState с захватом микрофона и воспроизведением на динамики.

Методы для управления устройствами позволяют переключаться между разными микрофонами и динамиками прямо во время звонка, что критично для работы с USB-гарнитурами или Bluetooth-устройствами.

Результаты и планы

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

Из технически интересных моментов стоит отметить комбинацию долговременных и эфемерных ключей для обеспечения Perfect Forward Secrecy, систему разрешения конфликтов в peer-to-peer чатах и архитектуру с разделением на независимые модули.

Чему удалось научиться

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

Криптография на эллиптических кривых. Реализация ECDH для обмена ключами и ECDSA для цифровых подписей заставила разобраться в математике за этими алгоритмами. Понятно, что с нуля ничего не надо реализовывать, однако почитать несколько статей про то, как работает криптография на эллиптических кривых было очень увлекательным занятием.

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

NAT traversal. Оказалось, что заставить два компьютера за NAT соединиться напрямую — нетривиальная задача. Как же классно было на практике добиться того, что два компьютера в нескольких сотнях километров друг от друга смогли соединиться друг с другом без необходимости в использовании белых IP-адресов.

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

Опыт использования LLM

При разработке проекта активно использовались большие языковые модели: Claude Sonnet 4 и Claude Opus 4 на начальных этапах, а затем Claude Sonnet 4.5 и GPT-5 после их выхода.

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

Критичные модули. В реализации транспортного протокола и криптографического модуля от LLM-реализации практически ничего не осталось. Эти части системы требовали особой тщательности: ошибка в обработке пакетов или криптографии могла привести к серьезным проблемам с безопасностью и стабильностью. Код был полностью переписан вручную с акцентом на корректность, безопасность и чистоту архитектуры.

UI-код. А вот интерфейс на iced почти полностью реализован LLM, и я до сих пор не осилил взяться переписать некоторые части адекватно. По коду разница между реализацией интерфейса и реализацией протокола сильно заметна:

  • Транспортный протокол качественно разбит по модулям и сущностям, везде используется single source of truth для состояния, чистое разделение ответственности. Функции компактные, каждая выполняет определенную роль.

  • UI-код выглядит совсем иначе: раздутые функции обработки событий, проблемы с синхронизацией состояний между компонентами интерфейса. Все известные баги были закостылены вручную, но архитектурные проблемы остались. Это хороший пример того, что LLM может быстро сгенерировать работающий код, но качество и поддерживаемость такого кода оставляют желать лучшего.

Выводы. LLM отлично подходит как инструмент для ускорения разработки и получения идей, но критичный код требует ручного пересмотра и рефакторинга. Особенно это касается модулей, где важна безопасность, производительность или долгосрочная поддерживаемость.

Планы развития

Текущая реализация покрывает базовые потребности, но есть несколько направлений для дальнейшего развития:

Улучшение безопасности протокола. Сейчас цифровая подпись проверяет только эфемерный ключ. Планирую переделать механизм так, чтобы подпись покрывала весь handshake-пакет целиком, включая адреса и идентификаторы. Это защитит от потенциальных атак с подменой полей пакета.

Демонстрация экрана. Архитектура уже поддерживает передачу видеокадров через VideoDataPacket, но требуется реализация захвата экрана и эффективное сжатие. Планируется использовать дифференциальное кодирование для передачи только изменившихся областей экрана.

Групповые звонки. В более отдаленной перспективе хотелось бы добавить поддержку конференций. Это потребует серьезного пересмотра архитектуры: вместо прямого соединения между двумя участниками нужна будет mesh-топология или выделенный узел для микширования аудиопотоков.


Исходный код проекта: github.com/udovin/ntied

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


  1. MAXH0
    26.11.2025 03:59

    Ну что сказать... Если это не фейк и будет опубликовано под вменяемой лицензией, то ДАЙТЕ ДВА


    1. wilcot Автор
      26.11.2025 03:59

      В принципе, а почему бы и сейчас не открыть. Точно не фейк, можно проверить


      1. MAXH0
        26.11.2025 03:59

        Вы меня переоцениваете. Я тут юзер, а не программер :)


    1. Kelbon
      26.11.2025 03:59

      я недавно сделал такой же на С++, правда совершенно с другими решениями, но всё же. Наверное менее замороченный в плане шифрования и более низкоуровневый. Например в статье совсем не упоминается как кодируется звук, вероятно готовая библиотека используется


      1. wilcot Автор
        26.11.2025 03:59

        Использовалась библиотека sea, но возникли проблемы с линковкой зависимостей под windows. Сейчас там 2 кодека - raw и ADPCM, реализацию можно найти в репозитории.


        1. Kelbon
          26.11.2025 03:59

          Если это вручную написанные, т.е. как я понимаю raw это то что захвачено с микрофона, а ADPCM это вручную написанная модуляция, то моя реализация в этом плане должна быть продвинутее. Использую OPUS


  1. d3d11
    26.11.2025 03:59

    Грандиозно! Ждем открытия проекта.


  1. kilfoy
    26.11.2025 03:59

    Я бы расширил список хабов, хотя бы добавил Криптографию. Иначе никто не увидит статью.


  1. DjUmnik
    26.11.2025 03:59

    На парковке будет ловить?


  1. 0x1b6e6
    26.11.2025 03:59

    Почему решили писать свое решение для обхода NAT вместо готовых решений, таких как libp2p? Он поддерживает больше вариантов обхода, включая udp hole punching, который может не работать в силу другой реализации NAT на роутере.

    Ещё не совсем понятно как создать чат с нужным человеком. Будет поиск по имени, только по username, по user-id, или по public key (привет tox)?


    1. Gorthauer87
      26.11.2025 03:59

      libp2p, когда я на нее смотрел, была жестоким оверинжинерингом. Это от туда потом пошли мемы про Томаку


    1. wilcot Автор
      26.11.2025 03:59

      Я рассматривал libp2p, но на момент рассмотрения у меня не было глубоких знаний (проверенных на практике) и хотелось получить минимальный рабочий вариант без сложных зависимостей, с максимальным контролем и пониманием происходящих процессов. В текущей реализации есть один изьян - соединения не работают при включенном VPN (возможно у меня такой попался, но это вроде распространенный случай). Сейчас планирую добавить TURN, это должно покрыть все возможные случаи, а дальше уже понять, что нужно ли переходить на libp2p (библиотека очень обширная).

      Насчет создания нужного чата - нужно знать публичный ключ собеседника (точнее хеш от ключа). Дальше собеседник решит принять от вас запрос или нет. Тут да, как в tox или jami. Метаинформация о контактах нигде, кроме как между собеседниками не хранится, и искать другими способами не получится.


  1. tanderus
    26.11.2025 03:59

    Спасибо! С большим интересом почитал.

    А не рассматривали QUIC как вариант транспорта поверх udp?


    1. wilcot Автор
      26.11.2025 03:59

      Когда реализовывал транспорт я смотрел на спецификацию quic чтобы почерпнуть немного идей. Однако руки еще не дошли изучить как обстоят дела с реализацией quic для rust и насколько его реалистично адаптировать к p2p.


      1. V1tol
        26.11.2025 03:59

        Cloudflare достаточно давно открыли библиотеку quiche, совсем недавно они выкатили адаптер tokio-quiche.


  1. Gorthauer87
    26.11.2025 03:59

    Прикольно, но я бы дальше отказался от bincode, это хорошо для прототипа, но в нем ты хрен сделаешь forward и backward compatibility.

    Тут что-то типа protobuf подойдёт, ну или надо посмотреть что нового в расте есть, postcard вот неплох, но это аналог bincode


  1. K0Jlya9
    26.11.2025 03:59

    Такое уже есть. Вряд ли получится наколхозить что то лучшее чем https://jami.net/ru/


    1. wilcot Автор
      26.11.2025 03:59

      А вы им пользовались? Перед тем как что-то колхозить, были попытки подружиться в том числе и с Jami. К сожалению, с простыми звонками ничего не вышло: у меня и у собеседника были те же проблемы - постоянно надо дергать аудио, чтобы нужное устройство ввода/вывода использовалось, прерывания звонков. Для моих целей Jami как и Tox, не подошел на тот момент.


      1. K0Jlya9
        26.11.2025 03:59

        Это понятно но сможешь ли ты сделать лучше не полагаясь целиком и полностью на свой маленький релей сервер? Стоит ли заморачиваться с п2п если всё равно всё зависит от сервера на 146%.


        1. d3d11
          26.11.2025 03:59

          К тому же p2p позволяет узнать IP пользователя всем заинтересованным.


          1. wilcot Автор
            26.11.2025 03:59

            Нигде в статье не указывается, что месенджер анонимный. Анонимность и нормально работающие звонки (в идеале с видеосигналом) - крайне нетривиальная задача.


        1. wilcot Автор
          26.11.2025 03:59

          сможешь ли ты сделать лучше не полагаясь целиком и полностью на свой маленький релей сервер

          Да, в моем случае уже получилось лучше, чем Jami, в плане работоспособности. Если говорить про OpenDHT, то интеграция возможна, но это задача низкого приоритета, поскольку мой текущий подход уже решает основные проблемы.

          Стоит ли заморачиваться с п2п

          Стоит, ведь P2P подразумевает прямое соединение между собеседниками (в случаях когда это возможно), которое ограничено только пропускной способностью канала собеседников, а не сервера.


  1. d3d11
    26.11.2025 03:59

    Что Вы скажете насчет Tox?
    Проблема только со звонками?
    Как он в целом?


    1. wilcot Автор
      26.11.2025 03:59

      Ничего плохого сказать не хочу. Из моего опыта - нестабильная работа (креши) и проблемы со звонками (неявные разрывы). При неправильном использовании интерфейса - переход в такое состояние, из которого можно выйти только перезапуском приложения. Функция "поделиться экраном" сделана довольно оригинально - через видеозвонки и переопределение в настройках устройства камеры на область экрана. Правда, помимо оригинальности, смотреть демонстрацию было довольно проблематично. Вполне возможно, что за два месяца проблемы исправили, или же проблемы были именно у меня, а не в приложении.


      1. d3d11
        26.11.2025 03:59

        Ну я не про конкретное приложение (подозреваю, Вы пробовали qTox - я пользуюсь другим, там нет ни крашей, ни зависаний). А про сам протокол и всю систему в целом.


  1. domix32
    26.11.2025 03:59

    Запускать-то сервер, наверное хорошо, но как пользоваться этим непонятно. Ну вот оно висит на 8080 порту и куда дальше-то? Смотреть как загораются тесты?

    Ну, и автор кажется не очень понимает как работает лицензирование. MIT/Apache явным образом разрешают коммерческое использование и добавление Common Clause приводит к легальному противоречию, что фактически инвалидирует его. Не проще было сделать код каким-нибудь GPL в таком случае для некоммерческого использования (например, двойное лицензирование AGPL + коммерческая лицензия)?


    1. wilcot Автор
      26.11.2025 03:59

      Запустить приложение можно так:
      cargo run --bin ntied

      За лицензирование отдельное спасибо, обязательно разберусь.


      1. domix32
        26.11.2025 03:59

        Ещё бы ИИшную генерацию пофиксить в README

        # Clone the repository
        git clone https://github.com/yourusername/ntied.git

        yourusername


  1. SergeyProkhorenko
    26.11.2025 03:59

    А почему не используются UUIDv7 "для упорядочивания сообщений в пользовательском интерфейсе"? Они же последовательные, если генерятся одним процессом! Для Rust рекомендую Uuid::now_v7(). См. также статью разработчика этого метода.

    А вот log_id - это явно лишний идентификатор.


    1. wilcot Автор
      26.11.2025 03:59

      Есть две проблемы, почему здесь не используется только message_id (который генерируются UUIDv7):

      1. В распределенной системе нет гарантии, что у обоих участников часы синхронизированы.

      2. Сообщение может быть составлено и отправлено на устройстве пользователя в момент плохого соединения или вообще, отсутствия соединения с собеседником. В результате сообщение может быть доставлено со значительной задержкой.

      log_id позволяет сериализовать запись каждого сообщения не зависимо от времени, гарантируя что у каждого собеседника история является append-only.


      1. SergeyProkhorenko
        26.11.2025 03:59

        Остается ощущение чрезмерно избыточного количества разных типов идентификаторов в сообщении (id, message_id и log_id). Я совершенно уверен, что можно было бы обойтись только message_id (UUIDv7), а процедура разрешения конфликтов при этом была бы вообще не нужна.

        Я хочу напомнить, что таймстемп в UUIDv7 - это по сути Всемирное координированное время (UTC), не зависящее от часового пояса, но только отсчитываемое не от 0 года, а от 1970 года в миллисекундах. В каких бы точках планеты не находились участники переписки, у них на часах устройства одно и то же время UTC. Поэтому не нужна никакая синхронизация часов участников - сейчас все компьютеры и телефоны синхронизированы с UTC достаточно точно для мессенджера. Максимальная погрешность в 100 миллисекунд воспринимается человеком как мгновение.

        Если сообщения отсортировываются в интерфейсе по message_id (UUIDv7), то это значит, что они отсортированы по моменту отправки. Если сообщение пришло со значительной задержкой, то оно встанет в середину беседы, и его можно выделять как непрочитанное, пока пользователь не уберет его с экрана.

        Насколько я понял, процедура разрешения конфликтов полагается на то же самое время UTC отправки сообщения. То есть результат (последовательность сообщений) точно такой же, как и при сортировке по message_id (UUIDv7).


  1. Dhwtj
    26.11.2025 03:59

    Аська

    Как следствие вопросы:

    Как peer узнает про другой peer? Совсем serverless не получится.

    Что делать если destination peer offline? Не принимать? Аська такие к себе на сервер отправляла.

    Если это постоянный socket, то при постоянном соединении с сотней других peer спать сервис не будет никогда, то есть для смартфона не применимо.


    1. wilcot Автор
      26.11.2025 03:59

      Чтобы peer узнал о другом peer, нужен третий участник (в данной статье это ntied-server). Serverless не получится, я даже не знаю, возможно ли здесь достичь serverless, при отсутствии статического белого IP хотя бы у одного участника.

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


  1. GENISUV
    26.11.2025 03:59

    А будет ли это приложение работать в фоне? Я протестировал Jami, и там эта функция реализована через Google Firebase. И в чём отличие вашей архитектуры звонков от Jami и Tox? Ведь там они тоже p2p


    1. wilcot Автор
      26.11.2025 03:59

      Не будет работать в фоне. Архитектурно отличия скорее нет, оно есть только в деталях реализации. У меня не получилось с Jami или Tox из-за нестабильности звонков и самого приложения.


  1. okhsunrog
    26.11.2025 03:59

    Ещё бы скриншотов добавить в README на гитхабе и в статью, и было бы прекрасно)


  1. blackyblack
    26.11.2025 03:59

    Клиента на веб страницу получится воткнуть? Если нет, то рекомендую подумать в сторону создания JS биндингов.


    1. wilcot Автор
      26.11.2025 03:59

      Можете попробовать, вдруг получится. У меня в приоритете - добавить недостающие базовые функции, улучшить архитектуру, повысить надежность и качество приложения.