Доступ к VirtualBox и Hyper-V без агента в гостевой ОС — на примере клиента EvertyDesk Lite

от автора

В какой-то момент возникла задача: дать админу возможность подключаться к виртуальным машинам прямо на хосте, к которому он уже подключён. Зашёл на физический хост — видишь его VM как живые превью, кликнул — работаешь в консоли VM. И всё это без установки агента внутрь гостевой ОС.

Звучит просто. На практике это вылилось в путешествие через три RDP-сервера разной степени «соответствия стандарту», панику внутри чужой библиотеки и один таймаут, который перевернул всю архитектуру. Рассказываю с кодом и набитыми шишками.

VM Секция - Rust+EGUI

VM Секция — Rust+EGUI

На практике — это путешествие через три 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. Клиент:

  1. Открывает обычный TCP к этому порту.

  2. Шлёт первым же пакетом Preconnection Blob V2 с GUID нужной VM — брокер по нему понимает, в какую VM проксировать.

  3. Дальше — совершенно стандартный 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::MouseMoveKeyDownMouseWheel, …) общие — 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);    }}

Чему научились

  1. «Стандарт» — это очень растяжимо. VirtualBox VRDE нарушает RDP в трёх местах (пустой FontMap, лишние строки битмап, усечённые control-PDU). Эталонный клиент Devolutions падает на том же. Реальный мир грязнее спецификации, и зрелость клиента — это в основном накопленная толерантность к чужим багам.

  2. Не бойтесь вендорить и патчить зависимости. Баг с паникой в ironrdp-session нельзя было обойти из своего кода — только форк. [patch.crates-io] делает это безболезненно.

  3. Сигнал должен нести информацию. Таймер «нет кадра N мс» в принципе не отличает тишину от поломки. Закрытие канала при смерти потока — отличает на 100%. Иногда правильное решение — не крутить чувствительность датчика, а сменить датчик.

  4. Неверная архитектурная гипотеза стоит дёшево, если проверять рано. Один таймаут 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/