В какой-то момент возникла задача: дать админу возможность подключаться к виртуальным машинам прямо на хосте, к которому он уже подключён. Зашёл на физический хост — видишь его 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.rs
let 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, как для VRDE
let 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.rs
use 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-серверами — буду рад обсуждению в комментариях.
Спасибо, что дочитали.
Автор: vaalimusic
