Skip to content

Сеть и обнаружение

Как два устройства, подключённых к одному Wi-Fi, находят друг друга? Ведь они не знают IP-адреса друг друга. Здесь на помощь приходит mDNS и библиотека libp2p.

Что такое libp2p

libp2p — это набор инструментов для создания P2P-сетей. «P2P» означает peer-to-peer (равный-к-равному) — устройства общаются напрямую, без центрального сервера.

plantuml Diagram

Стек libp2p в csend

libp2p — это конструктор из нескольких слоёв:

СлойЧто делает
TCPБазовое сетевое соединение
NoiseШифрует транспорт (дополнительно к ChaCha20 шифрованию файлов)
YamuxПозволяет пускать много «потоков» через одно TCP-соединение
request-responseСтруктурированный обмен: запрос → ответ
mDNSОбнаружение устройств без настройки

Два уровня шифрования? Да! Noise шифрует сам канал связи (никто не видит даже заголовки сообщений), а ChaCha20 шифрует содержимое файлов (даже если Noise взломан, файлы всё равно защищены).

Swarm — «Рой»

Swarm — это основная единица libp2p. Один swarm = одно «виртуальное устройство» в P2P-сети. У swarm есть:

  • Свой PeerId (уникальный ID)
  • Свои адреса (IP:порт)
  • Своё поведение (Behaviour — что делать с сообщениями)

PeerId — уникальный идентификатор

Каждый swarm имеет уникальный PeerId — хеш публичного ключа Ed25519. PeerId выглядит как 12D3KooW... и генерируется заново при каждом запуске.

PeerId нужен для того, чтобы отличать одно устройство от другого, даже если у них одинаковый IP-адрес (например, два роя на одном компьютере).

SendBehaviour — поведение роя

В csend каждый рой объединяет два поведения:

rust
pub struct SendBehaviour {
    pub mdns: mdns::tokio::Behaviour,              // Обнаружение соседей
    pub transfer: json::Behaviour<Message, Message>, // Обмен сообщениями
}

Создание роя

Есть два варианта:

rust
// Вариант 1: случайный ключ (для Discovery swarm и receiver P2P swarm)
pub fn build_send_swarm() -> Result<Swarm<SendBehaviour>> {
    build_send_swarm_with_keypair(Keypair::generate_ed25519())
}

// Вариант 2: предгенерированный ключ (для sender P2P swarm —
// чтобы узнать PeerId до запуска и передать его в Discovery)
pub fn build_send_swarm_with_keypair(keypair: Keypair) -> Result<Swarm<SendBehaviour>> { ... }

Sender P2P swarm использует вариант 2: keypair генерируется в spawn_send_task(), из него извлекается PeerId, а затем keypair передаётся в build_send_swarm_with_keypair(). Это позволяет включить sender_peer_id в Discovery-сообщение до запуска асинхронной задачи.

Что такое mDNS

mDNS (multicast DNS) — это способ обнаружения устройств в локальной сети без центрального сервера.

Представь, что ты в комнате с людьми и хочешь найти программистов. Ты кричишь: «Кто тут программист?» Все программисты поднимают руки. Это и есть mDNS — широковещательный запрос.

plantuml Diagram

Частота проб

При старте mDNS отправляет пробы часто (500 мс → 1 с → 2 с → ..., exponential backoff), затем стабилизируется на query_interval (по умолчанию 5 минут). Когда пир перестаёт отвечать на пробы, libp2p генерирует Event::Expired.

Архитектура csend

Прежде чем погружаться в детали, полезно увидеть общую картину. В csend одновременно работают несколько swarm'ов:

plantuml Diagram
КомпонентСколькоВремя жизниЗадача
Discovery Swarm1Всё время работы TUIОбнаружение пиров, обмен метаданными раздач
Sender P2P Swarm0..NОт нажатия [s] до завершения/отменыПередача файлов конкретному получателю
Receiver P2P Swarm0..NОт ввода кода до завершения/отменыПриём файлов от конкретного отправителя

Каждый swarm работает в своей tokio-задаче и имеет свой цикл tokio::select!, который одновременно ждёт mDNS-события, входящие сообщения и команды от TUI.

Discovery Task — фоновое обнаружение

Discovery Task работает всё время, пока открыт TUI. Его задача — знать, кто есть в сети, и обмениваться метаданными раздач.

plantuml Diagram

Состояние

rust
/// Одна активная раздача, объявленная в сеть.
struct ActiveTransfer {
    transfer_id: String,      // Публичный ID раздачи
    sender_peer_id: String,   // PeerId P2P-роя отправителя
    label: String,            // Метка ("photos", "3 items")
    files: Vec<FileInfo>,     // Список файлов
    total_size: u64,          // Общий размер
}

struct DiscoveryState {
    device_name: String,                       // Имя этого устройства
    known_peers: HashSet<PeerId>,              // Известные соседи
    our_transfers: Vec<ActiveTransfer>,         // Наши активные раздачи
    quiet: bool,                               // Не рассылать WhoWantsToSend
    tx: mpsc::UnboundedSender<DiscoveryEvent>, // Канал к TUI
}

Как отвечает на запросы

Входящий запросОтветДополнительное действие
WhoWantsToSendAckОтправить каждый наш transfer как WhoWantsToReceive
WhoWantsToReceiveAdvertiseReceiveУведомить TUI: SenderFound
NoLongerSendingAckУведомить TUI: SenderRetracted
Всё остальноеAck

Жизненный цикл пиров

Появление

Когда mDNS обнаруживает новое устройство, Discovery Task:

  1. Дедупликация — если пир уже в known_peers, ничего не происходит.
  2. Сохранение адресаswarm.add_peer_address(peer_id, addr).
  3. Знакомствоintroduce_to_new_peer() отправляет пиру все наши активные раздачи как WhoWantsToReceive. Если quiet = false, также отправляет WhoWantsToSend, чтобы узнать о раздачах пира.
plantuml Diagram

Исчезновение

Когда mDNS генерирует Event::Expired, Discovery Task удаляет пира из known_peers и отправляет SenderRetracted { transfer_id: None } в TUI — все раздачи этого пира считаются недоступными.

Есть два варианта удаления раздач:

transfer_idИсточникЧто произошло
Some("a8c3f1...")Входящее NoLongerSendingОтправитель отменил конкретную раздачу
NoneEvent::ExpiredПир пропал из сети — все его раздачи недоступны

Почему не нужен периодический опрос

Каждое изменение обрабатывается точечно:

СобытиеРеакция
Новый пир в сетиintroduce_to_new_peer — обмен раздачами
Новая раздачаAdvertiseFilessend_to_all_peers
Отмена раздачиClearTransferNoLongerSending
Пир пропалEvent::Expired → TUI убирает из списка
Запрос «кто раздаёт?»WhoWantsToSend → ответ нашими раздачами

Нет ситуации, когда информация «зависает» и нужно опрашивать сеть заново.

Как отправитель публикует раздачу

Когда пользователь выбирает файлы и нажимает [s], запускается цепочка через outbox-паттерн:

plantuml Diagram
ШагКтоЧто делает
1NavigatorPaneГенерирует кодовую фразу, формирует label, кладёт StartSend в outbox
2AppЗабирает StartSend, вызывает SendPane::start_transfer()
3SendPaneГенерирует transfer_id, запускает P2P sender swarm (получает sender_peer_id), кладёт FilesReady в outbox. P2P task вычисляет code_hash из переданной фразы
4AppЗабирает FilesReady, отправляет AdvertiseFiles в Discovery Task
5Discovery TaskСохраняет передачу в our_transfers, рассылает WhoWantsToReceive всем известным пирам

Три идентификатора

На шаге 3 создаются два публичных идентификатора, а секретный code_hash вычисляется внутри P2P task из кодовой фразы:

plantuml Diagram

transfer_id и sender_peer_id — публичные, не связаны с кодовой фразой. Наблюдатель в сети видит, что Алиса раздаёт файлы, но не может узнать фразу и не может принять файлы.


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

Как получатель принимает файлы

Путь получателя начинается в ReceivePane, где отображаются раздачи, обнаруженные через Discovery.

plantuml Diagram
ШагЧто происходит
1Discovery присылает SenderFound — ReceivePane показывает раздачу в списке
2Пользователь выбирает раздачу (Enter), вводит кодовую фразу
3ReceivePane запускает Receiver P2P Task с target_peer_id = sender_peer_id из Discovery
4P2P Task обнаруживает целевого пира через mDNS, отправляет Handshake только ему
5Отправитель проверяет code_hash, отвечает Offer со списком файлов
6TUI показывает модальное окно, пользователь нажимает [y]Accept
7Начинается передача: шифрованные чанки, по одному Ack на каждый чанк

Как получатель находит нужного пира

В сети может быть много swarm'ов — Discovery-свормы других устройств, P2P-свормы других передач. Получатель не перебирает их все, а точно знает PeerId нужного отправителя из Discovery-сообщения WhoWantsToReceive:

plantuml Diagram

mDNS переоткрывает пиров периодически (см. частоту проб выше). Если при первом обнаружении соединение не установилось — следующая проба повторит попытку.

P2P Task — передача файлов

P2P Task создаётся для каждой конкретной передачи. У каждого свой swarm с уникальным PeerId.

Sender

plantuml Diagram

Receiver

plantuml Diagram