Привет, Хабр!
Есть RustDesk-инфраструктура: rendezvous-сервер (hbbs) для NAT traversal и relay (hbbr) для проброса трафика, когда P2P не получился. И есть свой UDP-транспорт реального времени для видео (у меня это EVRT), который хочется гнать напрямую между пирами, минуя relay — ради задержки. Вопрос: как двум пирам договориться о прямом UDP-канале, если они общаются только через RustDesk?
Ответ короткий: RustDesk relay — это «тупая труба». Он передаёт зашифрованные PeerMessage между пирами и не парсит их содержимое. Значит, в эти сообщения можно подложить свои данные — и сервер прозрачно их пробросит, ничего не зная про твой протокол.
Шаг 1. Расширяем Misc своими полями
Desk-сообщения — это protobuf. Внутри PeerMessage есть Misc с oneof-полем union. Туда и добавляем свои варианты — с большими tag-номерами, заведомо вне диапазона, который использует апстрим RustDesk:
// rustdesk_proto.rs — наш форк .protooneof union { // ... стандартные поля RustDesk (tag 1..40) ... /// EVRT: хост сообщает свой UDP-порт для прямого стриминга. /// tag 100 — вне диапазона стандартных RustDesk полей. #[prost(uint32, tag = "100")] EvrtUdpPort(u32), /// EVRT: список IP:порт кандидатов хоста (LAN + VPN + внешний). /// Формат: "ip1:port,ip2:port,...". Клиент пробует каждый (mini-ICE). #[prost(string, tag = "101")] EvrtEndpoints(String),}
Почему tag 100+ — критично. protobuf — формат с прямой совместимостью: неизвестные поля просто игнорируются при декодировании. RustDesk-сервер релеит наш PeerMessage как непрозрачный блоб (он его даже не расшифровывает — это E2E между пирами), но если бы и парсил — поля с tag 100/101 он бы пропустил, не сломавшись. А мы на своей стороне (оба пира — наш клиент) их видим. Берёшь незанятый диапазон — и расширяешь протокол, не трогая ни сервер, ни апстрим.
Шаг 2. Хост анонсирует свой UDP-порт
Хост уже подключён к клиенту через RustDesk (relay или P2P-TCP — неважно). Он биндит свой EVRT UDP-сокет и отправляет порт обычным PeerMessage по тому же каналу:
fn evrt_port_message(port: u16) -> PeerMessage { PeerMessage { union: Some(peer_message::Union::Misc(Misc { union: Some(misc::Union::EvrtUdpPort(port as u32)), })), }}
Это уходит в тот же зашифрованный поток, что и кадры/ввод. Для RustDesk-сервера это просто очередной байтовый блоб от хоста к клиенту.
Шаг 3. Клиент собирает адрес и пробует P2P до relay
Тут вся соль. У клиента уже есть IP пира — его дал rendezvous-сервер в PunchHoleResponse.socket_addr при пробивке NAT. А порт только что прилетел в Misc{EvrtUdpPort}. Складываем — получаем полный адрес для прямого UDP:
Some(misc::Union::EvrtUdpPort(port)) => { let p = (*port).min(65535) as u16; if p > 0 { // IP уже есть от punch-hole, порт — отсюда. Адрес собран за один запрос. *evrt_port_out = Some(p); }}
И запускаем попытку прямого EVRT-соединения раньше, чем начнём гнать видео через relay:
// transport.rs — EVRT адрес собран: IP от hbbs punch-hole + порт из Misc.if let Some(host_addr) = evrt_host_addr { thread::spawn(move || { let udp = std::net::UdpSocket::bind("0.0.0.0:0").unwrap(); crate::evrt_client::try_evrt_before_relay( &Arc::new(udp), host_addr, // <- IP:port пира для прямого UDP evrt_token, &events, evrt_stop, ull, // ultra-low-latency при 60fps ); });}
try_evrt_before_relay шлёт UDP-пробу на собранный адрес. Если прошла — видео идёт напрямую, relay используется только под control-канал (мышь/клава/clipboard — там байт мало). Если не прошла за таймаут — спокойно остаёмся на relay, ничего не сломалось. То есть это оппортунистический апгрейд: пробуем лучшее, откатываемся к рабочему.
Зачем второе поле — EvrtEndpoints
Один порт хорош, пока у хоста один IP. Но если хост за VPN или мультихоумный — у него несколько адресов, и угадать «правильный» нельзя. Поэтому есть второй вариант: хост шлёт список кандидатов ip1:port,ip2:port,..., а клиент пробует каждый — мини-ICE на минималках:
Some(misc::Union::EvrtEndpoints(list)) => { for addr in parse_evrt_endpoints(list) { if !evrt_candidates_out.contains(&addr) { evrt_candidates_out.push(addr); // потом try_evrt_candidates(...) } }}
Тот же приём, та же «тупая труба» — просто строка вместо числа.
Зачем это нужно.
В RustDesk уже есть готовая и надёжная сигнальная инфраструктура: rendezvous, relay, NAT punch-hole, E2E-канал между пирами. Писать всё это заново ради своего видеотранспорта — долго, дорого и рискованно. Гораздо практичнее использовать RustDesk как «служебную шину»: пусть он по-прежнему устанавливает соединение, договаривается о пирах и держит control-канал, а тяжёлый поток видео мы при первой возможности выносим в свой UDP-транспорт. Так мы получаем лучшее из двух миров: совместимость со стоковым RustDesk-сервером и возможность ускорять медиа-трафик без форка всей инфраструктуры.
Итого
Чтобы пробросить свой транспорт через чужую сигнальную инфраструктуру, не нужно её патчить. Нужно:
-
Найти у неё канал, который релеит данные прозрачно (у RustDesk это E2E-
PeerMessage). -
Расширить сообщение своими полями в незанятом диапазоне tag’ов (protobuf простит неизвестные поля).
-
Сложить то, что уже знаешь (IP от punch-hole), с тем, что передал (порт), — и пробовать прямой путь оппортунистически, с откатом на relay.
Сервер RustDesk при этом остаётся стоковым — он даже не подозревает, что внутри его трубы поехал чужой UDP-транспорт.
Спасибо за внимание. Если интересны детали самого EVRT-протокола (адаптивная буферизация, система давления) — это тема для отдельной статьи.
ссылка на оригинал статьи https://habr.com/ru/articles/1052332/