Как пробросить свой UDP-транспорт через RustDesk без патча сервера

от автора

Привет, Хабр!
Есть 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-сервером и возможность ускорять медиа-трафик без форка всей инфраструктуры.

Итого

Чтобы пробросить свой транспорт через чужую сигнальную инфраструктуру, не нужно её патчить. Нужно:

  1. Найти у неё канал, который релеит данные прозрачно (у RustDesk это E2E-PeerMessage).

  2. Расширить сообщение своими полями в незанятом диапазоне tag’ов (protobuf простит неизвестные поля).

  3. Сложить то, что уже знаешь (IP от punch-hole), с тем, что передал (порт), — и пробовать прямой путь оппортунистически, с откатом на relay.

Сервер RustDesk при этом остаётся стоковым — он даже не подозревает, что внутри его трубы поехал чужой UDP-транспорт.

Спасибо за внимание. Если интересны детали самого EVRT-протокола (адаптивная буферизация, система давления) — это тема для отдельной статьи.

ссылка на оригинал статьи https://habr.com/ru/articles/1052332/