В какой-то момент возникла задача: дать админу возможность подключаться к виртуальным машинам прямо на хосте, к которому он уже подключён. Зашёл на физический хост — видишь его VM как живые превью, кликнул — работаешь в консоли VM. И всё это без установки агента внутрь гостевой ОС.
Звучит просто. На практике это вылилось в путешествие через три RDP-сервера разной степени «соответствия стандарту», панику внутри чужой библиотеки и один таймаут, который перевернул всю архитектуру. Рассказываю с кодом и набитыми шишками.
На практике — это путешествие через три RDP-сервера разной степени «соответствия стандарту», панику внутри чужой библиотеки и один таймаут, который перевернул всю архитектуру. Рассказываю с кодом.
Зачем agentless
Классический сценарий: у клиента сломалась сеть в гостевой ОС, или VM ещё на этапе установки, или это Linux без RDP. Агента внутрь не поставить — сети нет. Но гипервизор-то VM видит. И VirtualBox, и Hyper-V умеют отдавать экран VM через свои механизмы. Значит, можно подключиться «снаружи», со стороны хоста, через API гипервизора.
Архитектурно у нас получилось два провайдера: VirtualBox — VRDE (встроенный RDP-сервер) поверх TCP+TLS и Hyper-V Enhanced Session (RDP) через брокер vmms
Оба, как видите, в итоге говорят на RDP. Это и стало ключом к пере использованию кода.
VirtualBox: VRDE + ironrdp
У VirtualBox есть VRDE (VirtualBox Remote Display Extension) — встроенный RDP-сервер. Включается одной командой:
pub fn enable_vrde(uuid: &str, port: u16, vm_running: bool) -> bool { let vbm = vboxmanage()?; if vm_running { vbm_run(&vbm, &["controlvm", uuid, "vrdeport", &port.to_string()]); vbm_run(&vbm, &["controlvm", uuid, "vrdeproperty", "Security/Method=tls"]); vbm_run(&vbm, &["controlvm", uuid, "vrde", "on"]); } // ...}
Дальше нужен RDP-клиент. Городить FFI к FreeRDP не хотелось — нашли ironrdp чистую Rust-реализацию RDP от Devolutions. Подключаемся к 127.0.0.1:port, делаем X.224-негоциацию, поднимаем TLS, и крутим ActiveStage, который декодирует bitmap-апдейты в RGBA:
let outputs = active.process(&mut image, action, &frame)?;for output in outputs { match output { ActiveStageOutput::GraphicsUpdate(_) => had_update = true, ActiveStageOutput::PointerBitmap(p) => { cursor_shape = Some(p); } // ... }}if had_update { send_frame(&image, &cursor_shape, cur_x, cur_y, &frame_tx);}
Красиво на бумаге. А теперь — реальность.
Шишка N1: VirtualBox шлёт пустой Font Map
Первое же подключение упало на финализации:
FontPdu decode: not enough bytes provided to decode: received 0 bytes, expected 8 bytes
VirtualBox VRDE завершает фазу подключения пакетом Font Map с нулевым телом. По спецификации там должно быть минимум 8 байт, и ironrdp честно отказывается это парсить. Пришлось написать обёртку над финализацией, которая ловит именно эту ошибку и считает её успешным завершением:
match single_sequence_step(framed, &mut connector, &mut buf) { Ok(()) => {} Err(e) => { // VirtualBox VRDE присылает пустой FontMap — для ironrdp это ошибка, // но фактически это конец финализации. Реконструируем результат. if error_chain_contains(&e, "FontPdu") { return Ok(reconstruct_connection_result(saved)); } return Err(e); }}
Важный момент, который мы поняли позже: мы скачали официальный референсный клиент ironrdp-viewer и натравили его на ту же VM. Он упал на том же месте. То есть это не наш косяк — VirtualBox реально нарушает протокол, и даже эталонный клиент Devolutions без обходного пути к нему не подключается. Это нас сильно успокоило.
Шишка N2: чёрный экран и «магия» сжатия
Подключение прошло — а экран чёрный. При этом счётчик графических апдейтов растёт. Декодер работает, но рисует пустоту.
Я долго копал и нашел в исходниках ironrdp-session:
// active_stage.rslet bulk_decompressor = connection_result.compression_type.and_then(|ct| { BulkCompressor::new(...)});
Мы передавали compression_type: None — мол, сжатие не нужно. Но VirtualBox всё равно слал сжатые Fast-Path обновления. А раз декомпрессора нет — сжатые байты молча скармливались декодеру как несжатые. Ни ошибки, ни картинки.
Лечится одной строкой в конфиге:
// VirtualBox VRDE шлет bulk-compressed Fast-path графику независимо от того,// что мы объявляем. Без compression_type ironrdp не создаёт BulkCompressor,// и сжатые апдейты тихо превращаются в мусор.compression_type: Some(CompressionType::K64),
Картинка появилась.
Шишка N3: декодер падает в панику внутри библиотеки
Картинка появилась, но каждые несколько секунд сессия рвалась. Долго не могли понять причину — поток просто «умирал». Добавили перехват паники прямо в рабочем потоке:
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { vrde_thread(...);}));if let Err(payload) = result { let msg = downcast_panic_message(&payload); log_line(&format!("session thread PANICKED: {msg}"));}
И увидели правду:
PANIC at ironrdp-session/src/image.rs:694:index out of bounds: the len is 8294400 but the index is 8297092
Паника внутри библиотеки. Функция применения битмапа rect_fits() проверяет, что объявленный прямоугольник обновления влезает в границы экрана — но не проверяет, что реальных данных битмапа не больше, чем заявлено в прямоугольнике. VirtualBox иногда присылает строк данных больше, чем декларирует, chunks_exact нарезает лишние строки, и индекс вылетает за пределы массива.
Проверили свежий код на GitHub — баг не исправлен. Поэтому завендорили крейт в проект и добавили защиту во все функции применения битмапа:
let rectangle_height = usize::from(update_rectangle.height());bgr24 .chunks_exact(rectangle_width * SRC_COLOR_DEPTH) .rev() .take(rectangle_height) // <- наш фикс: не вылезаем за объявленную высоту .enumerate() .for_each(|(row_idx, row)| { /* ... */ });
Подключается через [patch.crates-io] в Cargo.toml:
[patch.crates-io]ironrdp-bulk = { path = "vendor/ironrdp-bulk" }ironrdp-session = { path = "vendor/ironrdp-session" }
Это уже третий баг на стороне VirtualBox/ironrdp, который мы нашли и обошли. Mstsc.exe от Microsoft при этом работал с VRDE идеально — потому что Microsoft за 25 лет научил свой RDP-клиент терпеть кривые сторонние серверы. Молодая независимая реализация на Rust такого запаса толерантности пока не накопила.
Изящное решение: как понять, что поток умер
Отдельная история — детектор «зависаний». Мы перепробовали кучу подходов на таймерах («нет кадра N миллисекунд → реконнект»), и все они давали ложные срабатывания: статичный экран легитимно не присылает кадры, и отличить «тихо, потому что нечего обновлять» от «тихо, потому что сломалось» по времени невозможно.
Правильное решение оказалось без таймеров вообще. Когда рабочий поток завершается (по любой причине — ошибка, паника, штатный выход), его mpsc::Sender дропается, и канал закрывается. Это 100% надёжный сигнал:
pub enum Poll<T> { Item(T), Empty, Dead, // поток завершился — канал закрыт}impl<T> From<Result<T, mpsc::TryRecvError>> for Poll<T> { fn from(r: Result<T, mpsc::TryRecvError>) -> Self { match r { Ok(v) => Poll::Item(v), Err(mpsc::TryRecvError::Empty) => Poll::Empty, Err(mpsc::TryRecvError::Disconnected) => Poll::Dead, } }}
Живой поток никогда не даст Dead. Никаких эвристик, никаких ложных срабатываний — реконнект происходит ровно тогда, когда поток реально умер, и мгновенно. Один тип, исчерпывающий match, и невозможно по ошибке прочитать «мёртв», не обработав при этом последнее реальное сообщение.
И ещё одна тонкость, которую легко упустить: таймаут стоял только на чтение сокета, но не на запись. Зависший write_all (переполненный буфер отправки на полумёртвом соединении) блокировал поток навсегда — и ни один детектор это не ловил, потому что поток физически не выполнял код. Одна строчка спасла:
let _ = spy.inner.get_ref().set_write_timeout(Some(Duration::from_secs(5)));
Hyper-V: поворот, который всё упростил
С VirtualBox разобрались. Берёмся за Hyper-V Enhanced Session — это тоже RDP, но «поверх VMBus». Логично предположить: гость слушает RDP на Hyper-V-сокете (AF_HYPERV), подключаемся напрямую к VmId + ServiceId.
Написали транспорт через AF_HYPERV, подключаемся… и получаем:
HV-RDP: VMBus connect: ... (os error 10060)
Таймаут. 30 секунд впустую. Прямого hv_sock RDP-листенера в госте по этому ServiceId нет.
Покопались, как на самом деле работает vmconnect.exe, и оказалось красивее. Хост Hyper-V запускает брокер VM-подключений (vmms), который слушает обычный TCP на 127.0.0.1:2179. Клиент:
-
Открывает обычный TCP к этому порту.
-
Шлёт первым же пакетом Preconnection Blob V2 с GUID нужной VM — брокер по нему понимает, в какую VM проксировать.
-
Дальше — совершенно стандартный RDP-хендшейк; брокер сам прокидывает его в RDP-сервер гостя через VMBus.
Я бы сказал что транспорт — обычный TCP+TLS, как у VirtualBox, и единственное отличие — один служебный пакет в начале. А ironrdp уже умеет кодировать Preconnection Blob:
let mut tcp = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?;let pcb = PreconnectionBlob { version: PcbVersion::V2, id: 0, v2_payload: Some(vm_guid.clone()), // GUID нужной VM};tcp.write_all(&ironrdp_core::encode_vec(&pcb)?)?;// дальше — обычный ironrdp handshake, как для VRDElet mut connector = ClientConnector::new(config, client_addr);let mut framed = Framed::new(tcp);let should_upgrade = connect_begin(&mut framed, &mut connector)?;
Главный профит: один движок на оба провайдера
Поскольку оба провайдера после установки соединения говорят на чистом RDP, весь движок переиспользуется. VirtualBox и Hyper-V отличаются только способом получить байтовый поток (TCP к VRDE-порту против TCP к брокеру + preconnection blob). Всё, что после, — общее: декод кадров, кодирование ввода, отрисовка.
Helperы ввода в ironrdp generic над S: Read + Write, так что их хватило сделать pub(crate) и звать из обоих модулей:
// hyperv_rdp.rs переиспользует ровно те же функции, что и vbox_rdp.rsuse crate::vbox_rdp::{ Poll, VrdeCmd, char_to_rdp_scancode, composite_cursor, emit_key_event, emit_mouse_event, emit_unicode_event, is_ignorable_pdu_error, is_transient_read_error, sanitize_desktop_size,};
Даже команды ввода (VrdeCmd::MouseMove, KeyDown, MouseWheel, …) общие — UI собирает их одним блоком и шлёт в ту сессию, которая активна:
for cmd in input_cmds { if let Some(vrdp) = &self.vbox_vrde_session { vrdp.send(cmd); } else if let Some(rdp) = &self.hyperv_rdp_session { rdp.send(cmd); }}
Чему научились
-
«Стандарт» — это очень растяжимо. VirtualBox VRDE нарушает RDP в трёх местах (пустой FontMap, лишние строки битмап, усечённые control-PDU). Эталонный клиент Devolutions падает на том же. Реальный мир грязнее спецификации, и зрелость клиента — это в основном накопленная толерантность к чужим багам.
-
Не бойтесь вендорить и патчить зависимости. Баг с паникой в
ironrdp-sessionнельзя было обойти из своего кода — только форк.[patch.crates-io]делает это безболезненно. -
Сигнал должен нести информацию. Таймер «нет кадра N мс» в принципе не отличает тишину от поломки. Закрытие канала при смерти потока — отличает на 100%. Иногда правильное решение — не крутить чувствительность датчика, а сменить датчик.
-
Неверная архитектурная гипотеза стоит дёшево, если проверять рано. Один таймаут
10060сэкономил нам недели возни сAF_HYPERVи привёл к более простому и правильному решению через брокерvmms.
В итоге получили agentless-доступ к VM обоих гипервизоров одним движком, без агентов в гостях, прямо из remote-desktop клиента. Hyper-V-путь оказался даже стабильнее — Windows-гость отдаёт «чистый» RDP без тех болячек, что мы лечили у VirtualBox.
Фиксы — можно забрать в апстрим
Наши патчи к ironrdp — это не хаки «лишь бы у нас заработало», а исправления реальных багов, которые до сих пор не исправлены в апстриме:
-
защита от выхода за границы массива в
apply_*_bitmap(ironrdp-session/src/image.rs) — паника прямо в библиотеке при «лишних» строках битмапа; -
терпимость к усечённым/нестандартным control-PDU при финализации и реактивации;
-
самовосстановление MPPC-декодера в
ironrdp-bulk.
Все три бага я описал выше достаточно подробно, чтобы воспроизвести и поправить — где именно ломается, при каких условиях и почему. Так что если кто-то из сообщества хочет довести их до pull request в IronRDP — берите и вносите сами, описания для этого хватает. Сам репозиторий я не выкладываю, но если нужны готовые патчи под конкретную версию крейтов — напишите в личку, пришлю diff.
Баги общие: от того, что они уедут в апстрим, выиграют все, кто пробует подключаться ironrdp к VirtualBox VRDE или другим «не-эталонным» RDP-серверам.
На этом всё. Если статья оказалась полезной или у вас есть свои истории борьбы с «не-эталонными» RDP-серверами — буду рад обсуждению в комментариях.
Спасибо, что дочитали.
ссылка на оригинал статьи https://habr.com/ru/articles/1052170/