Сеть и обнаружение
Как два устройства, подключённых к одному Wi-Fi, находят друг друга? Ведь они не знают IP-адреса друг друга. Здесь на помощь приходит mDNS и библиотека libp2p.
Что такое libp2p
libp2p — это набор инструментов для создания P2P-сетей. «P2P» означает peer-to-peer (равный-к-равному) — устройства общаются напрямую, без центрального сервера.
Стек 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 каждый рой объединяет два поведения:
pub struct SendBehaviour {
pub mdns: mdns::tokio::Behaviour, // Обнаружение соседей
pub transfer: json::Behaviour<Message, Message>, // Обмен сообщениями
}Создание роя
Есть два варианта:
// Вариант 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 — широковещательный запрос.
Частота проб
При старте mDNS отправляет пробы часто (500 мс → 1 с → 2 с → ..., exponential backoff), затем стабилизируется на query_interval (по умолчанию 5 минут). Когда пир перестаёт отвечать на пробы, libp2p генерирует Event::Expired.
Архитектура csend
Прежде чем погружаться в детали, полезно увидеть общую картину. В csend одновременно работают несколько swarm'ов:
| Компонент | Сколько | Время жизни | Задача |
|---|---|---|---|
| Discovery Swarm | 1 | Всё время работы TUI | Обнаружение пиров, обмен метаданными раздач |
| Sender P2P Swarm | 0..N | От нажатия [s] до завершения/отмены | Передача файлов конкретному получателю |
| Receiver P2P Swarm | 0..N | От ввода кода до завершения/отмены | Приём файлов от конкретного отправителя |
Каждый swarm работает в своей tokio-задаче и имеет свой цикл tokio::select!, который одновременно ждёт mDNS-события, входящие сообщения и команды от TUI.
Discovery Task — фоновое обнаружение
Discovery Task работает всё время, пока открыт TUI. Его задача — знать, кто есть в сети, и обмениваться метаданными раздач.
Состояние
/// Одна активная раздача, объявленная в сеть.
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
}Как отвечает на запросы
| Входящий запрос | Ответ | Дополнительное действие |
|---|---|---|
WhoWantsToSend | Ack | Отправить каждый наш transfer как WhoWantsToReceive |
WhoWantsToReceive | AdvertiseReceive | Уведомить TUI: SenderFound |
NoLongerSending | Ack | Уведомить TUI: SenderRetracted |
| Всё остальное | Ack | — |
Жизненный цикл пиров
Появление
Когда mDNS обнаруживает новое устройство, Discovery Task:
- Дедупликация — если пир уже в
known_peers, ничего не происходит. - Сохранение адреса —
swarm.add_peer_address(peer_id, addr). - Знакомство —
introduce_to_new_peer()отправляет пиру все наши активные раздачи какWhoWantsToReceive. Еслиquiet = false, также отправляетWhoWantsToSend, чтобы узнать о раздачах пира.
Исчезновение
Когда mDNS генерирует Event::Expired, Discovery Task удаляет пира из known_peers и отправляет SenderRetracted { transfer_id: None } в TUI — все раздачи этого пира считаются недоступными.
Есть два варианта удаления раздач:
transfer_id | Источник | Что произошло |
|---|---|---|
Some("a8c3f1...") | Входящее NoLongerSending | Отправитель отменил конкретную раздачу |
None | Event::Expired | Пир пропал из сети — все его раздачи недоступны |
Почему не нужен периодический опрос
Каждое изменение обрабатывается точечно:
| Событие | Реакция |
|---|---|
| Новый пир в сети | introduce_to_new_peer — обмен раздачами |
| Новая раздача | AdvertiseFiles → send_to_all_peers |
| Отмена раздачи | ClearTransfer → NoLongerSending |
| Пир пропал | Event::Expired → TUI убирает из списка |
| Запрос «кто раздаёт?» | WhoWantsToSend → ответ нашими раздачами |
Нет ситуации, когда информация «зависает» и нужно опрашивать сеть заново.
Как отправитель публикует раздачу
Когда пользователь выбирает файлы и нажимает [s], запускается цепочка через outbox-паттерн:
| Шаг | Кто | Что делает |
|---|---|---|
| 1 | NavigatorPane | Генерирует кодовую фразу, формирует label, кладёт StartSend в outbox |
| 2 | App | Забирает StartSend, вызывает SendPane::start_transfer() |
| 3 | SendPane | Генерирует transfer_id, запускает P2P sender swarm (получает sender_peer_id), кладёт FilesReady в outbox. P2P task вычисляет code_hash из переданной фразы |
| 4 | App | Забирает FilesReady, отправляет AdvertiseFiles в Discovery Task |
| 5 | Discovery Task | Сохраняет передачу в our_transfers, рассылает WhoWantsToReceive всем известным пирам |
Три идентификатора
На шаге 3 создаются два публичных идентификатора, а секретный code_hash вычисляется внутри P2P task из кодовой фразы:
transfer_id и sender_peer_id — публичные, не связаны с кодовой фразой. Наблюдатель в сети видит, что Алиса раздаёт файлы, но не может узнать фразу и не может принять файлы.
Теперь, когда мы увидели путь раздачи от отправителя до сети, рассмотрим обратный путь — как получатель обнаруживает раздачу и подключается к отправителю.
Как получатель принимает файлы
Путь получателя начинается в ReceivePane, где отображаются раздачи, обнаруженные через Discovery.
| Шаг | Что происходит |
|---|---|
| 1 | Discovery присылает SenderFound — ReceivePane показывает раздачу в списке |
| 2 | Пользователь выбирает раздачу (Enter), вводит кодовую фразу |
| 3 | ReceivePane запускает Receiver P2P Task с target_peer_id = sender_peer_id из Discovery |
| 4 | P2P Task обнаруживает целевого пира через mDNS, отправляет Handshake только ему |
| 5 | Отправитель проверяет code_hash, отвечает Offer со списком файлов |
| 6 | TUI показывает модальное окно, пользователь нажимает [y] → Accept |
| 7 | Начинается передача: шифрованные чанки, по одному Ack на каждый чанк |
Как получатель находит нужного пира
В сети может быть много swarm'ов — Discovery-свормы других устройств, P2P-свормы других передач. Получатель не перебирает их все, а точно знает PeerId нужного отправителя из Discovery-сообщения WhoWantsToReceive:
mDNS переоткрывает пиров периодически (см. частоту проб выше). Если при первом обнаружении соединение не установилось — следующая проба повторит попытку.
P2P Task — передача файлов
P2P Task создаётся для каждой конкретной передачи. У каждого свой swarm с уникальным PeerId.