From 08e16f8a8cdbff798d1e512a85b622ffaab3e7a6 Mon Sep 17 00:00:00 2001 From: irving ou Date: Tue, 11 Nov 2025 13:13:03 -0500 Subject: [PATCH] Add vmconnect support to RDP clients --- Cargo.lock | 31 +- Cargo.toml | 1 + crates/iron-remote-desktop/src/lib.rs | 6 +- crates/ironrdp-async/Cargo.toml | 1 + crates/ironrdp-async/src/connector.rs | 154 ++--- crates/ironrdp-async/src/lib.rs | 3 +- crates/ironrdp-async/src/vmconnector.rs | 57 ++ crates/ironrdp-blocking/src/connector.rs | 33 +- crates/ironrdp-client/Cargo.toml | 1 + crates/ironrdp-client/src/config.rs | 42 ++ crates/ironrdp-client/src/rdp.rs | 319 +++++++---- crates/ironrdp-connector/Cargo.toml | 2 +- crates/ironrdp-connector/src/connection.rs | 69 ++- crates/ironrdp-connector/src/credssp.rs | 156 ++--- crates/ironrdp-connector/src/lib.rs | 40 +- crates/ironrdp-rdcleanpath/src/lib.rs | 24 +- .../tests/rdcleanpath.rs | 4 +- .../tests/server/fast_path.rs | 186 +++++- crates/ironrdp-testsuite-extra/tests/mod.rs | 11 +- crates/ironrdp-tokio/Cargo.toml | 2 +- crates/ironrdp-vmconnect/Cargo.toml | 25 + crates/ironrdp-vmconnect/src/config.rs | 60 ++ crates/ironrdp-vmconnect/src/connector.rs | 250 ++++++++ crates/ironrdp-vmconnect/src/credssp.rs | 114 ++++ crates/ironrdp-vmconnect/src/lib.rs | 7 + crates/ironrdp-web/Cargo.toml | 3 + crates/ironrdp-web/src/clipboard.rs | 184 ++++-- crates/ironrdp-web/src/lib.rs | 2 +- crates/ironrdp-web/src/session.rs | 262 +++++---- ffi/Cargo.toml | 2 +- ffi/src/connector/mod.rs | 2 +- ffi/src/credssp/mod.rs | 1 + ffi/src/rdcleanpath.rs | 2 +- .../iron-remote-desktop-rdp/package-lock.json | 4 +- .../iron-remote-desktop-rdp/package.json | 3 +- .../public/package.json | 2 +- .../iron-remote-desktop-rdp/src/main.ts | 6 + .../iron-remote-desktop-rdp/vite.config.ts | 37 +- .../iron-remote-desktop/package-lock.json | 4 +- web-client/iron-remote-desktop/package.json | 3 +- .../iron-remote-desktop/public/package.json | 2 +- .../src/iron-remote-desktop.svelte | 542 +++++++++++++++++- web-client/iron-remote-desktop/src/main.ts | 2 + .../src/services/remote-desktop.service.ts | 10 + web-client/iron-remote-desktop/vite.config.ts | 41 +- .../src/lib/login/login.svelte | 17 +- 46 files changed, 2215 insertions(+), 514 deletions(-) create mode 100644 crates/ironrdp-async/src/vmconnector.rs create mode 100644 crates/ironrdp-vmconnect/Cargo.toml create mode 100644 crates/ironrdp-vmconnect/src/config.rs create mode 100644 crates/ironrdp-vmconnect/src/connector.rs create mode 100644 crates/ironrdp-vmconnect/src/credssp.rs create mode 100644 crates/ironrdp-vmconnect/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 980260bba..3a92466ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2397,6 +2397,7 @@ dependencies = [ "ironrdp-connector", "ironrdp-core", "ironrdp-pdu", + "ironrdp-vmconnect", "tracing", ] @@ -2448,6 +2449,7 @@ dependencies = [ "ironrdp-rdpsnd-native", "ironrdp-tls", "ironrdp-tokio", + "ironrdp-vmconnect", "proc-exit", "raw-window-handle", "semver", @@ -2859,6 +2861,18 @@ dependencies = [ "url", ] +[[package]] +name = "ironrdp-vmconnect" +version = "0.1.0" +dependencies = [ + "arbitrary", + "ironrdp-connector", + "ironrdp-core", + "ironrdp-pdu", + "sspi", + "tracing", +] + [[package]] name = "ironrdp-web" version = "0.0.0" @@ -2866,6 +2880,7 @@ dependencies = [ "anyhow", "base64", "chrono", + "console_error_panic_hook", "futures-channel", "futures-util", "getrandom 0.2.16", @@ -2880,6 +2895,7 @@ dependencies = [ "ironrdp-propertyset", "ironrdp-rdcleanpath", "ironrdp-rdpfile", + "ironrdp-vmconnect", "js-sys", "png", "resize", @@ -2890,6 +2906,7 @@ dependencies = [ "tap", "time", "tracing", + "tracing-wasm", "url", "wasm-bindgen", "wasm-bindgen-futures", @@ -5102,8 +5119,7 @@ dependencies = [ [[package]] name = "sspi" version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523f6a99e26c1e6476a424d54bbda5354a01ee7f18b9d93dc48a8fd45ae8189b" +source = "git+https://github.com/Devolutions/sspi-rs.git?rev=370951c1b017bfef4276185b374345e8b6b1e532#370951c1b017bfef4276185b374345e8b6b1e532" dependencies = [ "async-dnssd", "async-recursion", @@ -5680,6 +5696,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + [[package]] name = "tracing-web" version = "0.1.3" diff --git a/Cargo.toml b/Cargo.toml index 058d516de..6ccec7f50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -210,3 +210,4 @@ opt-level = 3 # FIXME: We need to catch up with Diplomat upstream again, but this is a significant amount of work. # In the meantime, we use this forked version which fixes an undefined behavior in the code expanded by the bridge macro. diplomat = { git = "https://github.com/CBenoit/diplomat", rev = "6dc806e80162b6b39509a04a2835744236cd2396" } +sspi = { git = "https://github.com/Devolutions/sspi-rs.git", rev = "370951c1b017bfef4276185b374345e8b6b1e532" } diff --git a/crates/iron-remote-desktop/src/lib.rs b/crates/iron-remote-desktop/src/lib.rs index 5dd8dd60c..968bbbd21 100644 --- a/crates/iron-remote-desktop/src/lib.rs +++ b/crates/iron-remote-desktop/src/lib.rs @@ -437,6 +437,7 @@ macro_rules! make_bridge { #[doc(hidden)] pub mod internal { + use tracing::Level; #[doc(hidden)] pub use wasm_bindgen; #[doc(hidden)] @@ -458,7 +459,8 @@ pub mod internal { } } - fn set_logger_once(level: tracing::Level) { + fn set_logger_once(_level: tracing::Level) { + use tracing::Level; use tracing_subscriber::filter::LevelFilter; use tracing_subscriber::fmt::time::UtcTime; use tracing_subscriber::prelude::*; @@ -472,7 +474,7 @@ pub mod internal { .with_timer(UtcTime::rfc_3339()) // std::time is not available in browsers .with_writer(MakeConsoleWriter); - let level_filter = LevelFilter::from_level(level); + let level_filter = LevelFilter::from_level(Level::TRACE); tracing_subscriber::registry().with(fmt_layer).with(level_filter).init(); }) diff --git a/crates/ironrdp-async/Cargo.toml b/crates/ironrdp-async/Cargo.toml index 31a81d012..f84020e18 100644 --- a/crates/ironrdp-async/Cargo.toml +++ b/crates/ironrdp-async/Cargo.toml @@ -17,6 +17,7 @@ test = false [dependencies] ironrdp-connector = { path = "../ironrdp-connector", version = "0.7" } # public +ironrdp-vmconnect = { path = "../ironrdp-vmconnect", version = "0.1" } # public ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6" } # public tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-async/src/connector.rs b/crates/ironrdp-async/src/connector.rs index 823cfe105..2c65e8ebe 100644 --- a/crates/ironrdp-async/src/connector.rs +++ b/crates/ironrdp-async/src/connector.rs @@ -1,9 +1,9 @@ -use ironrdp_connector::credssp::{CredsspProcessGenerator, CredsspSequence, KerberosConfig}; +use ironrdp_connector::credssp::{CredsspProcessGenerator, KerberosConfig}; use ironrdp_connector::sspi::credssp::ClientState; use ironrdp_connector::sspi::generator::GeneratorState; use ironrdp_connector::{ - custom_err, general_err, ClientConnector, ClientConnectorState, ConnectionResult, ConnectorError, ConnectorResult, - ServerName, State as _, + custom_err, general_err, ClientConnector, ClientConnectorState, ConnectionResult, ConnectorCore, ConnectorError, + ConnectorResult, SecurityConnector, ServerName, }; use ironrdp_core::WriteBuf; use tracing::{debug, info, instrument, trace}; @@ -15,7 +15,10 @@ use crate::{single_sequence_step, AsyncNetworkClient}; pub struct ShouldUpgrade; #[instrument(skip_all)] -pub async fn connect_begin(framed: &mut Framed, connector: &mut ClientConnector) -> ConnectorResult +pub async fn connect_begin( + framed: &mut Framed, + connector: &mut dyn ConnectorCore, +) -> ConnectorResult where S: Sync + FramedRead + FramedWrite, { @@ -33,7 +36,7 @@ where /// # Panics /// /// Panics if connector state is not [ClientConnectorState::EnhancedSecurityUpgrade]. -pub fn skip_connect_begin(connector: &mut ClientConnector) -> ShouldUpgrade { +pub fn skip_connect_begin(connector: &mut dyn SecurityConnector) -> ShouldUpgrade { assert!(connector.should_perform_security_upgrade()); ShouldUpgrade } @@ -42,22 +45,26 @@ pub fn skip_connect_begin(connector: &mut ClientConnector) -> ShouldUpgrade { pub struct Upgraded; #[instrument(skip_all)] -pub fn mark_as_upgraded(_: ShouldUpgrade, connector: &mut ClientConnector) -> Upgraded { +pub fn mark_as_upgraded(_: ShouldUpgrade, connector: &mut dyn SecurityConnector) -> Upgraded { trace!("Marked as upgraded"); connector.mark_security_upgrade_as_done(); Upgraded } -#[instrument(skip_all)] -pub async fn connect_finalize( +#[non_exhaustive] +pub struct CredSSPFinished { + pub(crate) write_buf: WriteBuf, +} + +pub async fn perform_credssp( _: Upgraded, + connector: &mut dyn ConnectorCore, framed: &mut Framed, - mut connector: ClientConnector, server_name: ServerName, server_public_key: Vec, network_client: Option<&mut dyn AsyncNetworkClient>, kerberos_config: Option, -) -> ConnectorResult +) -> ConnectorResult where S: FramedRead + FramedWrite, { @@ -66,7 +73,7 @@ where if connector.should_perform_credssp() { perform_credssp_step( framed, - &mut connector, + connector, &mut buf, server_name, server_public_key, @@ -76,6 +83,19 @@ where .await?; } + Ok(CredSSPFinished { write_buf: buf }) +} + +#[instrument(skip_all)] +pub async fn connect_finalize( + CredSSPFinished { write_buf: mut buf }: CredSSPFinished, + framed: &mut Framed, + mut connector: ClientConnector, +) -> ConnectorResult +where + S: FramedRead + FramedWrite, +{ + buf.clear(); let result = loop { single_sequence_step(framed, &mut connector, &mut buf).await?; @@ -112,7 +132,7 @@ async fn resolve_generator( #[instrument(level = "trace", skip_all)] async fn perform_credssp_step( framed: &mut Framed, - connector: &mut ClientConnector, + connector: &mut dyn ConnectorCore, buf: &mut WriteBuf, server_name: ServerName, server_public_key: Vec, @@ -124,70 +144,70 @@ where { assert!(connector.should_perform_credssp()); - let selected_protocol = match connector.state { - ClientConnectorState::Credssp { selected_protocol, .. } => selected_protocol, - _ => return Err(general_err!("invalid connector state for CredSSP sequence")), - }; - - let (mut sequence, mut ts_request) = CredsspSequence::init( - connector.config.credentials.clone(), - connector.config.domain.as_deref(), - selected_protocol, - server_name, - server_public_key, - kerberos_config, - )?; - - loop { - let client_state = { - let mut generator = sequence.process_ts_request(ts_request); + let selected_protocol = connector + .selected_protocol() + .ok_or_else(|| general_err!("CredSSP protocol not selected, cannot perform CredSSP step"))?; - if let Some(network_client_ref) = network_client.as_deref_mut() { - trace!("resolving network"); - resolve_generator(&mut generator, network_client_ref).await? - } else { - generator - .resolve_to_result() - .map_err(|e| custom_err!("resolve without network client", e))? + { + let (mut sequence, mut ts_request) = connector.init_credssp( + connector.config().credentials.clone(), + connector.config().domain.as_deref(), + selected_protocol, + server_name, + server_public_key, + kerberos_config, + )?; + + loop { + let client_state = { + let mut generator = sequence.process_ts_request(ts_request); + + if let Some(network_client_ref) = network_client.as_deref_mut() { + trace!("resolving network"); + resolve_generator(&mut generator, network_client_ref).await? + } else { + generator + .resolve_to_result() + .map_err(|e| custom_err!("resolve without network client", e))? + } + }; // drop generator + + buf.clear(); + let written = sequence.handle_process_result(client_state, buf)?; + + if let Some(response_len) = written.size() { + let response = &buf[..response_len]; + trace!(response_len, "Send response"); + framed + .write_all(response) + .await + .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; } - }; // drop generator - buf.clear(); - let written = sequence.handle_process_result(client_state, buf)?; + let Some(next_pdu_hint) = sequence.next_pdu_hint() else { + break; + }; - if let Some(response_len) = written.size() { - let response = &buf[..response_len]; - trace!(response_len, "Send response"); - framed - .write_all(response) - .await - .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; - } - - let Some(next_pdu_hint) = sequence.next_pdu_hint() else { - break; - }; - - debug!( - connector.state = connector.state.name(), - hint = ?next_pdu_hint, - "Wait for PDU" - ); + debug!( + connector.state = connector.state().name(), + hint = ?next_pdu_hint, + "Wait for PDU" + ); - let pdu = framed - .read_by_hint(next_pdu_hint) - .await - .map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?; + let pdu = framed + .read_by_hint(next_pdu_hint) + .await + .map_err(|e| ironrdp_connector::custom_err!("read frame by hint", e))?; - trace!(length = pdu.len(), "PDU received"); + trace!(length = pdu.len(), "PDU received"); - if let Some(next_request) = sequence.decode_server_message(&pdu)? { - ts_request = next_request; - } else { - break; + if let Some(next_request) = sequence.decode_server_message(&pdu)? { + ts_request = next_request; + } else { + break; + } } } - connector.mark_credssp_as_done(); Ok(()) diff --git a/crates/ironrdp-async/src/lib.rs b/crates/ironrdp-async/src/lib.rs index b36775355..4569239e0 100644 --- a/crates/ironrdp-async/src/lib.rs +++ b/crates/ironrdp-async/src/lib.rs @@ -6,6 +6,7 @@ pub use bytes; mod connector; mod framed; mod session; +mod vmconnector; use core::future::Future; use core::pin::Pin; @@ -15,7 +16,7 @@ use ironrdp_connector::ConnectorResult; pub use self::connector::*; pub use self::framed::*; -// pub use self::session::*; +pub use self::vmconnector::*; pub trait AsyncNetworkClient { fn send<'a>( diff --git a/crates/ironrdp-async/src/vmconnector.rs b/crates/ironrdp-async/src/vmconnector.rs new file mode 100644 index 000000000..b1c9fbff3 --- /dev/null +++ b/crates/ironrdp-async/src/vmconnector.rs @@ -0,0 +1,57 @@ +use crate::{single_sequence_step, CredSSPFinished, Framed, FramedRead, FramedWrite}; +use ironrdp_connector::{ClientConnector, ConnectorResult}; +use ironrdp_pdu::pcb::PcbVersion; +use tracing::info; + +use ironrdp_vmconnect::VmClientConnector; + +#[non_exhaustive] +pub struct PcbSent; + +pub async fn send_pcb(framed: &mut Framed, payload: String) -> ConnectorResult +where + S: Sync + FramedRead + FramedWrite, +{ + let pcb_pdu = ironrdp_pdu::pcb::PreconnectionBlob { + id: 0, + version: PcbVersion::V2, + v2_payload: Some(payload), + }; + + let buf = ironrdp_core::encode_vec(&pcb_pdu) + .map_err(|e| ironrdp_connector::custom_err!("encode PreconnectionBlob PDU", e))?; + + framed + .write_all(&buf) + .await + .map_err(|e| ironrdp_connector::custom_err!("write PCB PDU", e))?; + + Ok(PcbSent) +} + +pub fn mark_pcb_sent_by_rdclean_path() -> PcbSent { + PcbSent +} + +pub fn vm_connector_take_over(_: PcbSent, connector: ClientConnector) -> ConnectorResult { + VmClientConnector::take_over(connector) +} + +pub async fn run_until_handover( + credssp_finished: &mut CredSSPFinished, + framed: &mut Framed, + mut connector: VmClientConnector, +) -> ConnectorResult { + let result = loop { + single_sequence_step(framed, &mut connector, &mut credssp_finished.write_buf).await?; + + if connector.should_hand_over() { + break connector.hand_over(); + } + }; + + info!("Handover to client connector"); + credssp_finished.write_buf.clear(); + + Ok(result) +} diff --git a/crates/ironrdp-blocking/src/connector.rs b/crates/ironrdp-blocking/src/connector.rs index d601dd980..3a3fa885b 100644 --- a/crates/ironrdp-blocking/src/connector.rs +++ b/crates/ironrdp-blocking/src/connector.rs @@ -1,12 +1,12 @@ use std::io::{Read, Write}; -use ironrdp_connector::credssp::{CredsspProcessGenerator, CredsspSequence, KerberosConfig}; +use ironrdp_connector::credssp::{CredsspProcessGenerator, KerberosConfig}; use ironrdp_connector::sspi::credssp::ClientState; use ironrdp_connector::sspi::generator::GeneratorState; use ironrdp_connector::sspi::network_client::NetworkClient; use ironrdp_connector::{ - general_err, ClientConnector, ClientConnectorState, ConnectionResult, ConnectorError, ConnectorResult, - Sequence as _, ServerName, State as _, + general_err, ClientConnector, ClientConnectorState, ConnectionResult, ConnectorCore, ConnectorError, + ConnectorResult, SecurityConnector, Sequence, ServerName, }; use ironrdp_core::WriteBuf; use tracing::{debug, info, instrument, trace}; @@ -17,7 +17,7 @@ use crate::framed::Framed; pub struct ShouldUpgrade; #[instrument(skip_all)] -pub fn connect_begin(framed: &mut Framed, connector: &mut ClientConnector) -> ConnectorResult +pub fn connect_begin(framed: &mut Framed, connector: &mut dyn ConnectorCore) -> ConnectorResult where S: Sync + Read + Write, { @@ -35,7 +35,7 @@ where /// # Panics /// /// Panics if connector state is not [ClientConnectorState::EnhancedSecurityUpgrade]. -pub fn skip_connect_begin(connector: &mut ClientConnector) -> ShouldUpgrade { +pub fn skip_connect_begin(connector: &mut dyn SecurityConnector) -> ShouldUpgrade { assert!(connector.should_perform_security_upgrade()); ShouldUpgrade } @@ -44,7 +44,7 @@ pub fn skip_connect_begin(connector: &mut ClientConnector) -> ShouldUpgrade { pub struct Upgraded; #[instrument(skip_all)] -pub fn mark_as_upgraded(_: ShouldUpgrade, connector: &mut ClientConnector) -> Upgraded { +pub fn mark_as_upgraded(_: ShouldUpgrade, connector: &mut dyn SecurityConnector) -> Upgraded { trace!("Marked as upgraded"); connector.mark_security_upgrade_as_done(); Upgraded @@ -119,7 +119,7 @@ fn resolve_generator( #[instrument(level = "trace", skip_all)] fn perform_credssp_step( framed: &mut Framed, - connector: &mut ClientConnector, + connector: &mut dyn ConnectorCore, buf: &mut WriteBuf, server_name: ServerName, server_public_key: Vec, @@ -131,14 +131,13 @@ where { assert!(connector.should_perform_credssp()); - let selected_protocol = match connector.state { - ClientConnectorState::Credssp { selected_protocol, .. } => selected_protocol, - _ => return Err(general_err!("invalid connector state for CredSSP sequence")), - }; + let selected_protocol = connector + .selected_protocol() + .ok_or_else(|| general_err!("CredSSP protocol not selected, cannot perform CredSSP step"))?; - let (mut sequence, mut ts_request) = CredsspSequence::init( - connector.config.credentials.clone(), - connector.config.domain.as_deref(), + let (mut sequence, mut ts_request) = connector.init_credssp( + connector.config().credentials.clone(), + connector.config().domain.as_deref(), selected_protocol, server_name, server_public_key, @@ -167,7 +166,7 @@ where }; debug!( - connector.state = connector.state.name(), + connector.state = connector.state().name(), hint = ?next_pdu_hint, "Wait for PDU" ); @@ -192,7 +191,7 @@ where pub fn single_sequence_step( framed: &mut Framed, - connector: &mut ClientConnector, + connector: &mut dyn Sequence, buf: &mut WriteBuf, ) -> ConnectorResult<()> where @@ -202,7 +201,7 @@ where let written = if let Some(next_pdu_hint) = connector.next_pdu_hint() { debug!( - connector.state = connector.state.name(), + connector.state = connector.state().name(), hint = ?next_pdu_hint, "Wait for PDU" ); diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index 4b4cffa85..6e9623ce8 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -55,6 +55,7 @@ ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy" ironrdp-propertyset.path = "../ironrdp-propertyset" ironrdp-rdpfile.path = "../ironrdp-rdpfile" ironrdp-cfg.path = "../ironrdp-cfg" +ironrdp-vmconnect.path = "../ironrdp-vmconnect" # Windowing and rendering winit = { version = "0.30", features = ["rwh_06"] } diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 3477c1802..99a4638eb 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -32,6 +32,8 @@ pub struct Config { /// server, which will be used for proxying DVC messages to/from user-defined DVC logic /// implemented as named pipe clients (either in the same process or in a different process). pub dvc_pipe_proxies: Vec, + + pub pcb: Option, } #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] @@ -295,6 +297,36 @@ struct Args { /// `` will automatically be prefixed with `\\.\pipe\` on Windows. #[clap(long)] dvc_proxy: Vec, + + /// The ID for the HyperV VM server to connect to + #[clap(long, conflicts_with("pcb"))] + vmconnect: Option, + + /// Preconnection Blob payload to use + #[clap(long)] + pcb: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PreconnectionBlobPayload { + General(String), + VmConnect(String), +} + +impl PreconnectionBlobPayload { + pub fn general(&self) -> Option<&str> { + match self { + PreconnectionBlobPayload::General(pcb) => Some(pcb), + PreconnectionBlobPayload::VmConnect(_) => None, + } + } + + pub fn vmconnect(&self) -> Option<&str> { + match self { + PreconnectionBlobPayload::VmConnect(vm_id) => Some(vm_id), + PreconnectionBlobPayload::General(_) => None, + } + } } impl Config { @@ -470,6 +502,15 @@ impl Config { .zip(args.rdcleanpath_token) .map(|(url, auth_token)| RDCleanPathConfig { url, auth_token }); + let pcb = match (args.vmconnect, args.pcb) { + (Some(_), Some(_)) => { + unreachable!("Cannot use both `--vmconnect` and `--pcb` at the same time"); + } + (Some(vm_id), None) => Some(PreconnectionBlobPayload::VmConnect(vm_id.to_string())), + (None, Some(pcb)) => Some(PreconnectionBlobPayload::General(pcb)), + (None, None) => None, + }; + Ok(Self { log_file: args.log_file, gw, @@ -478,6 +519,7 @@ impl Config { clipboard_type, rdcleanpath, dvc_pipe_proxies: args.dvc_proxy, + pcb, }) } } diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 53ecb0348..1a1fd39cc 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackendFactory}; use ironrdp::connector::connection_activation::ConnectionActivationState; -use ironrdp::connector::{ConnectionResult, ConnectorResult}; +use ironrdp::connector::{ClientConnector, ConnectionResult, ConnectorCore, ConnectorResult}; use ironrdp::displaycontrol::client::DisplayControlClient; use ironrdp::displaycontrol::pdu::MonitorLayoutEntry; use ironrdp::graphics::image_processing::PixelFormat; @@ -18,16 +18,22 @@ use ironrdp_core::WriteBuf; use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; use ironrdp_rdpsnd_native::cpal; use ironrdp_tokio::reqwest::ReqwestNetworkClient; -use ironrdp_tokio::{single_sequence_step_read, split_tokio_framed, FramedWrite}; +use ironrdp_tokio::{ + mark_pcb_sent_by_rdclean_path, perform_credssp, run_until_handover, send_pcb, single_sequence_step_read, + split_tokio_framed, vm_connector_take_over, CredSSPFinished, Framed, FramedRead, FramedWrite, TokioStream, + Upgraded, +}; +use ironrdp_vmconnect::VmClientConnector; use rdpdr::NoopRdpdrBackend; use smallvec::SmallVec; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; use tokio::sync::mpsc; -use tracing::{debug, error, info, trace, warn}; +use tracing::{debug, error, trace, warn}; use winit::event_loop::EventLoopProxy; +use x509_cert::der::asn1::OctetString; -use crate::config::{Config, RDCleanPathConfig}; +use crate::config::{Config, PreconnectionBlobPayload, RDCleanPathConfig}; #[derive(Debug)] pub enum RdpOutputEvent { @@ -209,49 +215,70 @@ async fn connect( drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name)); } - let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr) + let mut connector = ClientConnector::new(config.connector.clone(), client_addr) .with_static_channel(drdynvc) .with_static_channel(rdpsnd::client::Rdpsnd::new(Box::new(cpal::RdpsndBackend::new()))) .with_static_channel(rdpdr::Rdpdr::new(Box::new(NoopRdpdrBackend {}), "IronRDP".to_owned()).with_smartcard(0)); if let Some(builder) = cliprdr_factory { let backend = builder.build_cliprdr_backend(); - let cliprdr = cliprdr::Cliprdr::new(backend); - connector.attach_static_channel(cliprdr); } - let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector).await?; - - debug!("TLS upgrade"); - - // Ensure there is no leftover - let (initial_stream, leftover_bytes) = framed.into_inner(); - - let (upgraded_stream, server_public_key) = ironrdp_tls::upgrade(initial_stream, config.destination.name()) - .await - .map_err(|e| connector::custom_err!("TLS upgrade", e))?; + let mut connector: Box = + if let Some(PreconnectionBlobPayload::VmConnect(vmconnect)) = &config.pcb { + let pcb_sent = send_pcb(&mut framed, vmconnect.to_owned()).await?; + let connector = vm_connector_take_over(pcb_sent, connector)?; + Box::new(connector) + } else { + Box::new(connector) + }; - let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); + let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, connector.as_mut()).await?; + let (mut upgraded_framed, server_public_key) = upgrade(framed, config.destination.name()).await?; + let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, connector.as_mut()); - let erased_stream = Box::new(upgraded_stream) as Box; - let mut upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); + let server_name = (&config.destination).into(); - let connection_result = ironrdp_tokio::connect_finalize( + let mut credssp_finished = perform_credssp( upgraded, + connector.as_mut(), &mut upgraded_framed, - connector, - (&config.destination).into(), + server_name, server_public_key, Some(&mut ReqwestNetworkClient::new()), None, ) .await?; + let connector = downcast_back_to_client_connector(connector, &mut credssp_finished, &mut upgraded_framed).await?; + let connection_result = ironrdp_tokio::connect_finalize(credssp_finished, &mut upgraded_framed, connector).await?; debug!(?connection_result); - Ok((connection_result, upgraded_framed)) + return Ok((connection_result, upgraded_framed)); + + async fn upgrade( + framed: Framed>, + server_name: &str, + ) -> ConnectorResult<( + Framed>>, + Vec, + )> + where + S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, + { + let (initial_stream, leftover_bytes) = framed.into_inner(); + + let (upgraded_stream, server_public_key) = ironrdp_tls::upgrade(initial_stream, server_name) + .await + .map_err(|e| connector::custom_err!("TLS upgrade", e))?; + + let erased_stream = Box::new(upgraded_stream) as Box; + let upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); + + Ok((upgraded_framed, server_public_key)) + } } async fn connect_ws( @@ -300,7 +327,7 @@ async fn connect_ws( drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name)); } - let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr) + let mut connector = ClientConnector::new(config.connector.clone(), client_addr) .with_static_channel(drdynvc) .with_static_channel(rdpsnd::client::Rdpsnd::new(Box::new(cpal::RdpsndBackend::new()))) .with_static_channel(rdpdr::Rdpdr::new(Box::new(NoopRdpdrBackend {}), "IronRDP".to_owned()).with_smartcard(0)); @@ -315,42 +342,48 @@ async fn connect_ws( let destination = format!("{}:{}", config.destination.name(), config.destination.port()); - let (upgraded, server_public_key) = connect_rdcleanpath( + let (upgraded, server_public_key, mut connector) = connect_rdcleanpath( &mut framed, - &mut connector, + connector, destination, rdcleanpath.auth_token.clone(), - None, + &config.pcb, ) .await?; - let connection_result = ironrdp_tokio::connect_finalize( + let (ws, leftover_bytes) = framed.into_inner(); + let erased_stream = Box::new(ws) as Box; + let mut upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); + + let server_name = (&config.destination).into(); + + let mut credssp_done = perform_credssp( upgraded, - &mut framed, - connector, - (&config.destination).into(), + connector.as_mut(), + &mut upgraded_framed, + server_name, server_public_key, Some(&mut ReqwestNetworkClient::new()), None, ) .await?; - let (ws, leftover_bytes) = framed.into_inner(); - let erased_stream = Box::new(ws) as Box; - let upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); + let connector = downcast_back_to_client_connector(connector, &mut credssp_done, &mut upgraded_framed).await?; + + let connection_result = ironrdp_tokio::connect_finalize(credssp_done, &mut upgraded_framed, connector).await?; Ok((connection_result, upgraded_framed)) } async fn connect_rdcleanpath( - framed: &mut ironrdp_tokio::Framed, - connector: &mut connector::ClientConnector, + framed: &mut Framed, + mut connector: ClientConnector, destination: String, proxy_auth_token: String, - pcb: Option, -) -> ConnectorResult<(ironrdp_tokio::Upgraded, Vec)> + pcb: &Option, +) -> ConnectorResult<(Upgraded, Vec, Box)> where - S: ironrdp_tokio::FramedRead + FramedWrite, + S: FramedRead + FramedWrite, { use ironrdp::connector::Sequence as _; use x509_cert::der::Decode as _; @@ -375,100 +408,122 @@ where let mut buf = WriteBuf::new(); - info!("Begin connection procedure"); + debug!(?pcb, "Begin connection procedure"); - { - // RDCleanPath request + // let connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state() else { + // return Err(connector::general_err!("invalid connector state (send request)")); + // }; - let connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state else { - return Err(connector::general_err!("invalid connector state (send request)")); + debug_assert!(connector.next_pdu_hint().is_none()); + let (rdcleanpath_request, mut connector): (ironrdp_rdcleanpath::RDCleanPathPdu, Box) = + if let Some(PreconnectionBlobPayload::VmConnect(vm_id)) = pcb { + let rdcleanpath_req = ironrdp_rdcleanpath::RDCleanPathPdu::new_request( + None, + destination, + proxy_auth_token, + Some(vm_id.to_owned()), + ) + .map_err(|e| connector::custom_err!("new RDCleanPath request", e))?; + + debug!(message = ?rdcleanpath_req, "Send RDCleanPath request for VMConnect"); + + let pcb_sent = mark_pcb_sent_by_rdclean_path(); + let connector = vm_connector_take_over(pcb_sent, connector)?; + (rdcleanpath_req, Box::new(connector) as Box) + } else { + let written = connector.step_no_input(&mut buf)?; + let x224_pdu_len = written.size().expect("written size"); + debug_assert_eq!(x224_pdu_len, buf.filled_len()); + let x224_pdu = buf.filled().to_vec(); + let general_pcb = pcb.as_ref().and_then(|pcb| pcb.general()); + // RDCleanPath request + + let rdcleanpath_req = ironrdp_rdcleanpath::RDCleanPathPdu::new_request( + Some(x224_pdu), + destination, + proxy_auth_token, + general_pcb.map(str::to_string), + ) + .map_err(|e| connector::custom_err!("new RDCleanPath request", e))?; + (rdcleanpath_req, Box::new(connector) as Box) }; - debug_assert!(connector.next_pdu_hint().is_none()); + let rdcleanpath_request = rdcleanpath_request + .to_der() + .map_err(|e| connector::custom_err!("RDCleanPath request encode", e))?; - let written = connector.step_no_input(&mut buf)?; - let x224_pdu_len = written.size().expect("written size"); - debug_assert_eq!(x224_pdu_len, buf.filled_len()); - let x224_pdu = buf.filled().to_vec(); - - let rdcleanpath_req = - ironrdp_rdcleanpath::RDCleanPathPdu::new_request(x224_pdu, destination, proxy_auth_token, pcb) - .map_err(|e| connector::custom_err!("new RDCleanPath request", e))?; - debug!(message = ?rdcleanpath_req, "Send RDCleanPath request"); - let rdcleanpath_req = rdcleanpath_req - .to_der() - .map_err(|e| connector::custom_err!("RDCleanPath request encode", e))?; - - framed - .write_all(&rdcleanpath_req) - .await - .map_err(|e| connector::custom_err!("couldn't write RDCleanPath request", e))?; - } - - { - // RDCleanPath response + framed + .write_all(&rdcleanpath_request) + .await + .map_err(|e| connector::custom_err!("couldn’t write RDCleanPath request", e))?; - let rdcleanpath_res = framed - .read_by_hint(&RDCLEANPATH_HINT) - .await - .map_err(|e| connector::custom_err!("read RDCleanPath request", e))?; + let rdcleanpath_result = framed + .read_by_hint(&RDCLEANPATH_HINT) + .await + .map_err(|e| connector::custom_err!("read RDCleanPath request", e))?; - let rdcleanpath_res = ironrdp_rdcleanpath::RDCleanPathPdu::from_der(&rdcleanpath_res) - .map_err(|e| connector::custom_err!("RDCleanPath response decode", e))?; + let rdcleanpath_result = ironrdp_rdcleanpath::RDCleanPathPdu::from_der(&rdcleanpath_result) + .map_err(|e| connector::custom_err!("RDCleanPath response decode", e))?; - debug!(message = ?rdcleanpath_res, "Received RDCleanPath PDU"); + debug!(message = ?rdcleanpath_result, "Received RDCleanPath PDU"); - let (x224_connection_response, server_cert_chain) = match rdcleanpath_res - .into_enum() - .map_err(|e| connector::custom_err!("invalid RDCleanPath PDU", e))? - { - ironrdp_rdcleanpath::RDCleanPath::Request { .. } => { - return Err(connector::general_err!( - "received an unexpected RDCleanPath type (request)", - )); - } - ironrdp_rdcleanpath::RDCleanPath::Response { - x224_connection_response, - server_cert_chain, - server_addr: _, - } => (x224_connection_response, server_cert_chain), - ironrdp_rdcleanpath::RDCleanPath::GeneralErr(error) => { - return Err(connector::custom_err!("received an RDCleanPath error", error)); - } - ironrdp_rdcleanpath::RDCleanPath::NegotiationErr { - x224_connection_response, - } => { - // Try to decode as X.224 Connection Confirm to extract negotiation failure details. - if let Ok(x224_confirm) = ironrdp_core::decode::< - ironrdp::pdu::x224::X224, - >(&x224_connection_response) - { - if let ironrdp::pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 { - // Convert to negotiation failure instead of generic RDCleanPath error. - let negotiation_failure = connector::NegotiationFailure::from(code); - return Err(connector::ConnectorError::new( - "RDP negotiation failed", - connector::ConnectorErrorKind::Negotiation(negotiation_failure), - )); - } + let (x224_connection_response, server_cert_chain) = match rdcleanpath_result + .into_enum() + .map_err(|e| connector::custom_err!("invalid RDCleanPath PDU", e))? + { + ironrdp_rdcleanpath::RDCleanPath::Request { .. } => { + return Err(connector::general_err!( + "received an unexpected RDCleanPath type (request)", + )); + } + ironrdp_rdcleanpath::RDCleanPath::Response { + x224_connection_response, + server_cert_chain, + server_addr: _, + } => (x224_connection_response, server_cert_chain), + ironrdp_rdcleanpath::RDCleanPath::GeneralErr(error) => { + return Err(connector::custom_err!("received an RDCleanPath error", error)); + } + ironrdp_rdcleanpath::RDCleanPath::NegotiationErr { + x224_connection_response, + } => { + if let Ok(x224_confirm) = ironrdp_core::decode::< + ironrdp::pdu::x224::X224, + >(&x224_connection_response) + { + if let ironrdp::pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 { + let negotiation_failure = connector::NegotiationFailure::from(code); + return Err(connector::ConnectorError::new( + "RDP negotiation failed", + connector::ConnectorErrorKind::Negotiation(negotiation_failure), + )); } - - // Fallback to generic error if we can't decode the negotiation failure. - return Err(connector::general_err!("received an RDCleanPath negotiation error")); } - }; - let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else { - return Err(connector::general_err!("invalid connector state (wait confirm)")); - }; + return Err(connector::general_err!("received an RDCleanPath negotiation error")); + } + }; + buf.clear(); + if let Some(x224_connection_response) = x224_connection_response { + // let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state() else { + // return Err(connector::general_err!("invalid connector state (wait confirm)")); + // }; debug_assert!(connector.next_pdu_hint().is_some()); - - buf.clear(); + // Write the X.224 connection response PDU let written = connector.step(x224_connection_response.as_bytes(), &mut buf)?; - debug_assert!(written.is_nothing()); + } + + let server_public_key = extract_server_public_key(server_cert_chain)?; + let should_upgrade = ironrdp_tokio::skip_connect_begin(connector.as_mut()); + + let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, connector.as_mut()); + + return Ok((upgraded, server_public_key, connector)); + + fn extract_server_public_key(server_cert_chain: Vec) -> ConnectorResult> { let server_cert = server_cert_chain .into_iter() .next() @@ -485,13 +540,7 @@ where .ok_or_else(|| connector::general_err!("subject public key BIT STRING is not aligned"))? .to_owned(); - let should_upgrade = ironrdp_tokio::skip_connect_begin(connector); - - // At this point, proxy established the TLS session. - - let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, connector); - - Ok((upgraded, server_public_key)) + Ok(server_public_key) } } @@ -687,3 +736,25 @@ async fn active_session( Ok(RdpControlFlow::TerminatedGracefully(disconnect_reason)) } + +pub async fn downcast_back_to_client_connector( + connector: Box, // `ConnectorCore: Any` + credssp_finished: &mut CredSSPFinished, + framed: &mut Framed, +) -> ConnectorResult { + let connector: Box = connector; + + let client = match connector.downcast::() { + Ok(vm_connector) => run_until_handover(credssp_finished, framed, *vm_connector).await?, + Err(err) => match err.downcast::() { + Ok(c) => *c, + Err(_) => { + return Err(connector::general_err!( + "connector is neither ClientConnector nor VmClientConnector" + )) + } + }, + }; + + Ok(client) +} diff --git a/crates/ironrdp-connector/Cargo.toml b/crates/ironrdp-connector/Cargo.toml index 2ff693fbb..ab3852037 100644 --- a/crates/ironrdp-connector/Cargo.toml +++ b/crates/ironrdp-connector/Cargo.toml @@ -27,13 +27,13 @@ ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6", features = ["std"] } # public arbitrary = { version = "1", features = ["derive"], optional = true } # public -sspi = "0.16" # public url = "2.5" # public rand = { version = "0.9", features = ["std"] } # TODO: dependency injection? tracing = { version = "0.1", features = ["log"] } picky-asn1-der = "0.5" picky-asn1-x509 = "0.14" picky = "7.0.0-rc.17" +sspi = "0.16" # public [lints] workspace = true diff --git a/crates/ironrdp-connector/src/connection.rs b/crates/ironrdp-connector/src/connection.rs index 639cf93ab..b7a8b1989 100644 --- a/crates/ironrdp-connector/src/connection.rs +++ b/crates/ironrdp-connector/src/connection.rs @@ -7,14 +7,16 @@ use ironrdp_core::{decode, encode_vec, Encode, WriteBuf}; use ironrdp_pdu::x224::X224; use ironrdp_pdu::{gcc, mcs, nego, rdp, PduHint}; use ironrdp_svc::{StaticChannelSet, StaticVirtualChannel, SvcClientProcessor}; -use tracing::{debug, error, info, warn}; +use tracing::{debug, error, info, trace, warn}; use crate::channel_connection::{ChannelConnectionSequence, ChannelConnectionState}; use crate::connection_activation::{ConnectionActivationSequence, ConnectionActivationState}; +use crate::credssp::{self}; use crate::license_exchange::{LicenseExchangeSequence, NoopLicenseCache}; use crate::{ encode_x224_packet, general_err, reason_err, Config, ConnectorError, ConnectorErrorExt as _, ConnectorErrorKind, - ConnectorResult, DesktopSize, NegotiationFailure, Sequence, State, Written, + ConnectorResult, CredsspSequenceFactory, DesktopSize, NegotiationFailure, SecurityConnector, Sequence, State, + Written, }; #[derive(Debug)] @@ -153,7 +155,6 @@ impl ClientConnector { { self.static_channels.insert(channel); } - pub fn get_static_channel_processor(&mut self) -> Option<&T> where T: SvcClientProcessor + 'static, @@ -198,6 +199,66 @@ impl ClientConnector { debug_assert!(!self.should_perform_credssp()); assert_eq!(res, Written::Nothing); } + + pub fn selected_protocol(&self) -> Option { + match &self.state { + ClientConnectorState::Credssp { selected_protocol } => Some(*selected_protocol), + _ => None, + } + } + + pub fn config(&self) -> &Config { + &self.config + } +} + +impl SecurityConnector for ClientConnector { + fn should_perform_security_upgrade(&self) -> bool { + ClientConnector::should_perform_security_upgrade(self) + } + + fn mark_security_upgrade_as_done(&mut self) { + ClientConnector::mark_security_upgrade_as_done(self) + } + + fn should_perform_credssp(&self) -> bool { + ClientConnector::should_perform_credssp(self) + } + + fn selected_protocol(&self) -> Option { + ClientConnector::selected_protocol(self) + } + + fn mark_credssp_as_done(&mut self) { + ClientConnector::mark_credssp_as_done(self) + } + + fn config(&self) -> &Config { + ClientConnector::config(self) + } +} + +impl CredsspSequenceFactory for ClientConnector { + fn init_credssp( + &self, + credentials: crate::Credentials, + domain: Option<&str>, + protocol: nego::SecurityProtocol, + server_name: crate::ServerName, + server_public_key: Vec, + kerberos_config: Option, + ) -> ConnectorResult<(Box, sspi::credssp::TsRequest)> { + let (sequence, ts_request) = credssp::CredsspSequence::init( + credentials, + domain, + protocol, + server_name, + server_public_key, + kerberos_config, + )?; + + Ok((Box::new(sequence), ts_request)) + } } impl Sequence for ClientConnector { @@ -358,6 +419,8 @@ impl Sequence for ClientConnector { let written = encode_x224_packet(&connect_initial, output)?; + trace!(written, "Written"); + ( Written::from_size(written)?, ClientConnectorState::BasicSettingsExchangeWaitResponse { connect_initial }, diff --git a/crates/ironrdp-connector/src/credssp.rs b/crates/ironrdp-connector/src/credssp.rs index 9750b6081..291616f58 100644 --- a/crates/ironrdp-connector/src/credssp.rs +++ b/crates/ironrdp-connector/src/credssp.rs @@ -76,21 +76,104 @@ pub struct CredsspSequence { } #[derive(Debug, PartialEq)] -pub(crate) enum CredsspState { +pub enum CredsspState { Ongoing, EarlyUserAuthResult, Finished, } -impl CredsspSequence { - pub fn next_pdu_hint(&self) -> Option<&dyn PduHint> { - match self.state { +pub trait CredsspSequenceTrait { + fn credssp_state(&self) -> &CredsspState; + + fn set_credssp_state(&mut self, state: CredsspState); + + fn next_pdu_hint(&self) -> Option<&dyn PduHint> { + match self.credssp_state() { CredsspState::Ongoing => Some(&CREDSSP_TS_REQUEST_HINT), CredsspState::EarlyUserAuthResult => Some(&CREDSSP_EARLY_USER_AUTH_RESULT_HINT), CredsspState::Finished => None, } } + fn decode_server_message(&mut self, input: &[u8]) -> ConnectorResult> { + match self.credssp_state() { + CredsspState::Ongoing => { + let message = credssp::TsRequest::from_buffer(input).map_err(|e| custom_err!("TsRequest", e))?; + debug!(?message, "Received"); + Ok(Some(message)) + } + CredsspState::EarlyUserAuthResult => { + let early_user_auth_result = credssp::EarlyUserAuthResult::from_buffer(input) + .map_err(|e| custom_err!("EarlyUserAuthResult", e))?; + + debug!(message = ?early_user_auth_result, "Received"); + + match early_user_auth_result { + credssp::EarlyUserAuthResult::Success => { + self.set_credssp_state(CredsspState::Finished); + Ok(None) + } + credssp::EarlyUserAuthResult::AccessDenied => { + Err(ConnectorError::new("CredSSP", ConnectorErrorKind::AccessDenied)) + } + } + } + _ => Err(general_err!( + "attempted to feed server request to CredSSP sequence in an unexpected state" + )), + } + } + + fn process_ts_request(&mut self, request: credssp::TsRequest) -> CredsspProcessGenerator<'_>; + + fn handle_process_result(&mut self, result: ClientState, output: &mut WriteBuf) -> ConnectorResult; +} + +impl CredsspSequenceTrait for CredsspSequence { + fn credssp_state(&self) -> &CredsspState { + &self.state + } + + fn set_credssp_state(&mut self, state: CredsspState) { + self.state = state; + } + + fn process_ts_request(&mut self, request: credssp::TsRequest) -> CredsspProcessGenerator<'_> { + self.client.process(request) + } + + fn handle_process_result(&mut self, result: ClientState, output: &mut WriteBuf) -> ConnectorResult { + let (size, next_state) = match self.state { + CredsspState::Ongoing => { + let (ts_request_from_client, next_state) = match result { + ClientState::ReplyNeeded(ts_request) => (ts_request, CredsspState::Ongoing), + ClientState::FinalMessage(ts_request) => ( + ts_request, + if self.selected_protocol.contains(nego::SecurityProtocol::HYBRID_EX) { + CredsspState::EarlyUserAuthResult + } else { + CredsspState::Finished + }, + ), + }; + + debug!(message = ?ts_request_from_client, "Send"); + + let written = write_credssp_request(ts_request_from_client, output)?; + + Ok((Written::from_size(written)?, next_state)) + } + CredsspState::EarlyUserAuthResult => Ok((Written::Nothing, CredsspState::Finished)), + CredsspState::Finished => Err(general_err!("CredSSP sequence is already done")), + }?; + + self.state = next_state; + + Ok(size) + } +} + +impl CredsspSequence { /// `server_name` must be the actual target server hostname (as opposed to the proxy) pub fn init( credentials: Credentials, @@ -172,71 +255,6 @@ impl CredsspSequence { Ok((sequence, initial_request)) } - - /// Returns Some(ts_request) when a TS request is received from server, - /// and None when an early user auth result PDU is received instead. - pub fn decode_server_message(&mut self, input: &[u8]) -> ConnectorResult> { - match self.state { - CredsspState::Ongoing => { - let message = credssp::TsRequest::from_buffer(input).map_err(|e| custom_err!("TsRequest", e))?; - debug!(?message, "Received"); - Ok(Some(message)) - } - CredsspState::EarlyUserAuthResult => { - let early_user_auth_result = credssp::EarlyUserAuthResult::from_buffer(input) - .map_err(|e| custom_err!("EarlyUserAuthResult", e))?; - - debug!(message = ?early_user_auth_result, "Received"); - - match early_user_auth_result { - credssp::EarlyUserAuthResult::Success => { - self.state = CredsspState::Finished; - Ok(None) - } - credssp::EarlyUserAuthResult::AccessDenied => { - Err(ConnectorError::new("CredSSP", ConnectorErrorKind::AccessDenied)) - } - } - } - _ => Err(general_err!( - "attempted to feed server request to CredSSP sequence in an unexpected state" - )), - } - } - - pub fn process_ts_request(&mut self, request: credssp::TsRequest) -> CredsspProcessGenerator<'_> { - self.client.process(request) - } - - pub fn handle_process_result(&mut self, result: ClientState, output: &mut WriteBuf) -> ConnectorResult { - let (size, next_state) = match self.state { - CredsspState::Ongoing => { - let (ts_request_from_client, next_state) = match result { - ClientState::ReplyNeeded(ts_request) => (ts_request, CredsspState::Ongoing), - ClientState::FinalMessage(ts_request) => ( - ts_request, - if self.selected_protocol.contains(nego::SecurityProtocol::HYBRID_EX) { - CredsspState::EarlyUserAuthResult - } else { - CredsspState::Finished - }, - ), - }; - - debug!(message = ?ts_request_from_client, "Send"); - - let written = write_credssp_request(ts_request_from_client, output)?; - - Ok((Written::from_size(written)?, next_state)) - } - CredsspState::EarlyUserAuthResult => Ok((Written::Nothing, CredsspState::Finished)), - CredsspState::Finished => Err(general_err!("CredSSP sequence is already done")), - }?; - - self.state = next_state; - - Ok(size) - } } fn extract_user_name(cert: &Certificate) -> Option { diff --git a/crates/ironrdp-connector/src/lib.rs b/crates/ironrdp-connector/src/lib.rs index 1e1a74020..ba6e37904 100644 --- a/crates/ironrdp-connector/src/lib.rs +++ b/crates/ironrdp-connector/src/lib.rs @@ -18,18 +18,20 @@ use core::fmt; use std::sync::Arc; use ironrdp_core::{encode_buf, encode_vec, Encode, WriteBuf}; -use ironrdp_pdu::nego::NegoRequestData; +use ironrdp_pdu::nego::{NegoRequestData, SecurityProtocol}; use ironrdp_pdu::rdp::capability_sets::{self, BitmapCodecs}; use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; use ironrdp_pdu::x224::X224; use ironrdp_pdu::{gcc, x224, PduHint}; pub use sspi; +use sspi::credssp::TsRequest; pub use self::channel_connection::{ChannelConnectionSequence, ChannelConnectionState}; pub use self::connection::{encode_send_data_request, ClientConnector, ClientConnectorState, ConnectionResult}; pub use self::connection_finalization::{ConnectionFinalizationSequence, ConnectionFinalizationState}; pub use self::license_exchange::{LicenseExchangeSequence, LicenseExchangeState}; pub use self::server_name::ServerName; +use crate::credssp::{CredsspSequenceTrait, KerberosConfig}; pub use crate::license_exchange::LicenseCache; /// Provides user-friendly error messages for RDP negotiation failures @@ -433,3 +435,39 @@ where Ok(written) } + +pub trait SecurityConnector { + fn should_perform_security_upgrade(&self) -> bool; + + fn mark_security_upgrade_as_done(&mut self); + + fn should_perform_credssp(&self) -> bool; + + fn selected_protocol(&self) -> Option; + + fn mark_credssp_as_done(&mut self); + + fn config(&self) -> &Config; +} + +pub trait CredsspSequenceFactory { + fn init_credssp( + &self, + credentials: Credentials, + domain: Option<&str>, + protocol: SecurityProtocol, + server_name: ServerName, + server_public_key: Vec, + kerberos_config: Option, + ) -> ConnectorResult<(Box, TsRequest)>; +} + +pub trait ConnectorCore: Sequence + SecurityConnector + CredsspSequenceFactory + Any { + fn into_any(self: Box) -> Box; +} + +impl ConnectorCore for T { + fn into_any(self: Box) -> Box { + self + } +} diff --git a/crates/ironrdp-rdcleanpath/src/lib.rs b/crates/ironrdp-rdcleanpath/src/lib.rs index b884ba853..738e973b2 100644 --- a/crates/ironrdp-rdcleanpath/src/lib.rs +++ b/crates/ironrdp-rdcleanpath/src/lib.rs @@ -309,7 +309,7 @@ impl RDCleanPathPdu { } pub fn new_request( - x224_pdu: Vec, + x224_pdu: Option>, destination: String, proxy_auth: String, pcb: Option, @@ -319,19 +319,19 @@ impl RDCleanPathPdu { destination: Some(destination), proxy_auth: Some(proxy_auth), preconnection_blob: pcb, - x224_connection_pdu: Some(OctetString::new(x224_pdu)?), + x224_connection_pdu: x224_pdu.map(OctetString::new).transpose()?, ..Self::default() }) } pub fn new_response( server_addr: String, - x224_pdu: Vec, + x224_pdu: Option>, x509_chain: impl IntoIterator>, ) -> der::Result { Ok(Self { version: VERSION_1, - x224_connection_pdu: Some(OctetString::new(x224_pdu)?), + x224_connection_pdu: x224_pdu.map(OctetString::new).transpose()?, server_cert_chain: Some( x509_chain .into_iter() @@ -410,10 +410,10 @@ pub enum RDCleanPath { proxy_auth: String, server_auth: Option, preconnection_blob: Option, - x224_connection_request: OctetString, + x224_connection_request: Option, }, Response { - x224_connection_response: OctetString, + x224_connection_response: Option, server_cert_chain: Vec, server_addr: String, }, @@ -450,15 +450,11 @@ impl TryFrom for RDCleanPath { proxy_auth: pdu.proxy_auth.ok_or(MissingRDCleanPathField("proxy_auth"))?, server_auth: pdu.server_auth, preconnection_blob: pdu.preconnection_blob, - x224_connection_request: pdu - .x224_connection_pdu - .ok_or(MissingRDCleanPathField("x224_connection_pdu"))?, + x224_connection_request: pdu.x224_connection_pdu, } } else if let Some(server_addr) = pdu.server_addr { Self::Response { - x224_connection_response: pdu - .x224_connection_pdu - .ok_or(MissingRDCleanPathField("x224_connection_pdu"))?, + x224_connection_response: pdu.x224_connection_pdu, server_cert_chain: pdu .server_cert_chain .ok_or(MissingRDCleanPathField("server_cert_chain"))?, @@ -493,7 +489,7 @@ impl From for RDCleanPathPdu { proxy_auth: Some(proxy_auth), server_auth, preconnection_blob, - x224_connection_pdu: Some(x224_connection_request), + x224_connection_pdu: x224_connection_request, ..Default::default() }, RDCleanPath::Response { @@ -502,7 +498,7 @@ impl From for RDCleanPathPdu { server_addr, } => Self { version: VERSION_1, - x224_connection_pdu: Some(x224_connection_response), + x224_connection_pdu: x224_connection_response, server_cert_chain: Some(server_cert_chain), server_addr: Some(server_addr), ..Default::default() diff --git a/crates/ironrdp-testsuite-core/tests/rdcleanpath.rs b/crates/ironrdp-testsuite-core/tests/rdcleanpath.rs index 9c4cf28d2..ae99aff33 100644 --- a/crates/ironrdp-testsuite-core/tests/rdcleanpath.rs +++ b/crates/ironrdp-testsuite-core/tests/rdcleanpath.rs @@ -6,7 +6,7 @@ use rstest::rstest; fn request() -> RDCleanPathPdu { RDCleanPathPdu::new_request( - vec![0xDE, 0xAD, 0xBE, 0xFF], + Some(vec![0xDE, 0xAD, 0xBE, 0xFF]), "destination".to_owned(), "proxy auth".to_owned(), Some("PCB".to_owned()), @@ -23,7 +23,7 @@ const REQUEST_DER: &[u8] = &[ fn response_success() -> RDCleanPathPdu { RDCleanPathPdu::new_response( "192.168.7.95".to_owned(), - vec![0xDE, 0xAD, 0xBE, 0xFF], + Some(vec![0xDE, 0xAD, 0xBE, 0xFF]), [ vec![0xDE, 0xAD, 0xBE, 0xFF], vec![0xDE, 0xAD, 0xBE, 0xFF], diff --git a/crates/ironrdp-testsuite-core/tests/server/fast_path.rs b/crates/ironrdp-testsuite-core/tests/server/fast_path.rs index f7df24345..c63a8ce0a 120000 --- a/crates/ironrdp-testsuite-core/tests/server/fast_path.rs +++ b/crates/ironrdp-testsuite-core/tests/server/fast_path.rs @@ -1 +1,185 @@ -../../../ironrdp-server/src/encoder/fast_path.rs \ No newline at end of file +use core::{cmp, fmt}; + +use ironrdp_pdu::fast_path::{EncryptionFlags, FastPathHeader, FastPathUpdatePdu, Fragmentation, UpdateCode}; +use ironrdp_pdu::{Encode, WriteCursor}; + +// this is the maximum amount of data (not including headers) we can send in a single TS_FP_UPDATE_PDU +const MAX_FASTPATH_UPDATE_SIZE: usize = 16_374; + +const FASTPATH_HEADER_SIZE: usize = 6; + +#[allow(unreachable_pub)] +#[cfg_attr(feature = "__bench", visibility::make(pub))] +pub(crate) struct UpdateFragmenter { + code: UpdateCode, + index: usize, + #[doc(hidden)] // not part of the public API, used by benchmarks + pub data: Vec, + position: usize, +} + +impl fmt::Debug for UpdateFragmenter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("UpdateFragmenter") + .field("len", &self.data.len()) + .finish() + } +} + +impl UpdateFragmenter { + pub(crate) fn new(code: UpdateCode, data: Vec) -> Self { + Self { + code, + index: 0, + data, + position: 0, + } + } + + pub(crate) fn size_hint(&self) -> usize { + FASTPATH_HEADER_SIZE + cmp::min(self.data.len(), MAX_FASTPATH_UPDATE_SIZE) + } + + pub(crate) fn next(&mut self, dst: &mut [u8]) -> Option { + let (consumed, written) = self.encode_next(dst)?; + self.position += consumed; + self.index = self.index.checked_add(1)?; + Some(written) + } + + fn encode_next(&mut self, dst: &mut [u8]) -> Option<(usize, usize)> { + match self.data.len() - self.position { + 0 => None, + + 1..=MAX_FASTPATH_UPDATE_SIZE => { + let frag = if self.index > 0 { + Fragmentation::Last + } else { + Fragmentation::Single + }; + + self.encode_fastpath(frag, &self.data[self.position..], dst) + .map(|written| (self.data.len() - self.position, written)) + } + + _ => { + let frag = if self.index > 0 { + Fragmentation::Next + } else { + Fragmentation::First + }; + + self.encode_fastpath( + frag, + &self.data[self.position..MAX_FASTPATH_UPDATE_SIZE + self.position], + dst, + ) + .map(|written| (MAX_FASTPATH_UPDATE_SIZE, written)) + } + } + } + + fn encode_fastpath(&self, frag: Fragmentation, data: &[u8], dst: &mut [u8]) -> Option { + let mut cursor = WriteCursor::new(dst); + + let update = FastPathUpdatePdu { + fragmentation: frag, + update_code: self.code, + compression_flags: None, + compression_type: None, + data, + }; + + let header = FastPathHeader::new(EncryptionFlags::empty(), update.size()); + + header.encode(&mut cursor).ok()?; + update.encode(&mut cursor).ok()?; + + Some(cursor.pos()) + } +} + +#[cfg(test)] +mod tests { + use ironrdp_core::{decode_cursor, ReadCursor}; + + use super::*; + + #[test] + fn test_single_fragment() { + let data = vec![1, 2, 3, 4]; + let mut fragmenter = UpdateFragmenter::new(UpdateCode::Bitmap, data); + let mut buffer = vec![0; 100]; + let written = fragmenter.next(&mut buffer).unwrap(); + assert!(written > 0); + assert_eq!(fragmenter.index, 1); + + let mut cursor = ReadCursor::new(&buffer); + let header: FastPathHeader = decode_cursor(&mut cursor).unwrap(); + let update: FastPathUpdatePdu<'_> = decode_cursor(&mut cursor).unwrap(); + assert!(matches!(header, FastPathHeader { data_length: 7, .. })); + assert!(matches!( + update, + FastPathUpdatePdu { + fragmentation: Fragmentation::Single, + .. + } + )); + + assert!(fragmenter.next(&mut buffer).is_none()); + } + + #[test] + fn test_multi_fragment() { + let data = vec![0u8; MAX_FASTPATH_UPDATE_SIZE * 2 + 10]; + let mut fragmenter = UpdateFragmenter::new(UpdateCode::Bitmap, data); + let mut buffer = vec![0u8; fragmenter.size_hint()]; + let written = fragmenter.next(&mut buffer).unwrap(); + assert!(written > 0); + assert_eq!(fragmenter.index, 1); + + let mut cursor = ReadCursor::new(&buffer); + let _header: FastPathHeader = decode_cursor(&mut cursor).unwrap(); + let update: FastPathUpdatePdu<'_> = decode_cursor(&mut cursor).unwrap(); + assert!(matches!( + update, + FastPathUpdatePdu { + fragmentation: Fragmentation::First, + .. + } + )); + assert_eq!(update.data.len(), MAX_FASTPATH_UPDATE_SIZE); + + let written = fragmenter.next(&mut buffer).unwrap(); + assert!(written > 0); + assert_eq!(fragmenter.index, 2); + let mut cursor = ReadCursor::new(&buffer); + let _header: FastPathHeader = decode_cursor(&mut cursor).unwrap(); + let update: FastPathUpdatePdu<'_> = decode_cursor(&mut cursor).unwrap(); + assert!(matches!( + update, + FastPathUpdatePdu { + fragmentation: Fragmentation::Next, + .. + } + )); + assert_eq!(update.data.len(), MAX_FASTPATH_UPDATE_SIZE); + + let written = fragmenter.next(&mut buffer).unwrap(); + assert!(written > 0); + assert_eq!(fragmenter.index, 3); + let mut cursor = ReadCursor::new(&buffer); + let _header: FastPathHeader = decode_cursor(&mut cursor).unwrap(); + let update: FastPathUpdatePdu<'_> = decode_cursor(&mut cursor).unwrap(); + assert!(matches!( + update, + FastPathUpdatePdu { + fragmentation: Fragmentation::Last, + .. + } + )); + assert_eq!(update.data.len(), 10); + + assert!(fragmenter.next(&mut buffer).is_none()); + } +} diff --git a/crates/ironrdp-testsuite-extra/tests/mod.rs b/crates/ironrdp-testsuite-extra/tests/mod.rs index 8f5db6d61..3b5a48366 100644 --- a/crates/ironrdp-testsuite-extra/tests/mod.rs +++ b/crates/ironrdp-testsuite-extra/tests/mod.rs @@ -210,17 +210,22 @@ where .expect("TLS upgrade"); let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); let mut upgraded_framed = ironrdp_tokio::TokioFramed::new(upgraded_stream); - let connection_result = ironrdp_async::connect_finalize( + + let credssp_done = ironrdp_async::perform_credssp( upgraded, + &mut connector, &mut upgraded_framed, - connector, "localhost".into(), server_public_key, None, None, ) .await - .expect("finalize connection"); + .expect("perform credssp"); + + let connection_result = ironrdp_async::connect_finalize(credssp_done, &mut upgraded_framed, connector) + .await + .expect("finalize connection"); let active_stage = ActiveStage::new(connection_result); let (active_stage, mut upgraded_framed) = clientfn(active_stage, upgraded_framed, display_tx).await; diff --git a/crates/ironrdp-tokio/Cargo.toml b/crates/ironrdp-tokio/Cargo.toml index e129ac44c..b0d93726b 100644 --- a/crates/ironrdp-tokio/Cargo.toml +++ b/crates/ironrdp-tokio/Cargo.toml @@ -27,8 +27,8 @@ ironrdp-async = { path = "../ironrdp-async", version = "0.7" } # public ironrdp-connector = { path = "../ironrdp-connector", version = "0.7", optional = true } tokio = { version = "1", features = ["io-util"] } reqwest = { version = "0.12", default-features = false, features = ["http2", "system-proxy"], optional = true } -sspi = { version = "0.16", features = ["network_client", "dns_resolver"], optional = true } url = { version = "2.5", optional = true } +sspi = { version = "0.16", features = ["network_client", "dns_resolver"], optional = true } [lints] workspace = true diff --git a/crates/ironrdp-vmconnect/Cargo.toml b/crates/ironrdp-vmconnect/Cargo.toml new file mode 100644 index 000000000..029e0e08a --- /dev/null +++ b/crates/ironrdp-vmconnect/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ironrdp-vmconnect" +version = "0.1.0" +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.6", features = [ + "std", +] } # public + +arbitrary = { version = "1", features = ["derive"], optional = true } # public +tracing = { version = "0.1", features = ["log"] } +ironrdp-connector = { path = "../ironrdp-connector", version = "0.7" } # public + +sspi = "0.16" # public + +[lints] +workspace = true diff --git a/crates/ironrdp-vmconnect/src/config.rs b/crates/ironrdp-vmconnect/src/config.rs new file mode 100644 index 000000000..3be773fba --- /dev/null +++ b/crates/ironrdp-vmconnect/src/config.rs @@ -0,0 +1,60 @@ +use ironrdp_connector::general_err; +use ironrdp_pdu::nego::NegoRequestData; +use sspi::Username; + +#[derive(Debug, Clone)] +pub struct Credentials { + pub(crate) username: String, + pub(crate) password: String, +} + +impl Credentials { + pub(crate) fn to_sspi_auth_identity(&self, domain: Option<&str>) -> Result { + Ok(sspi::AuthIdentity { + username: Username::new(&self.username, domain)?, + password: self.password.clone().into(), + }) + } +} + +impl TryFrom<&ironrdp_connector::Credentials> for Credentials { + type Error = ironrdp_connector::ConnectorError; + + fn try_from(value: &ironrdp_connector::Credentials) -> Result { + let ironrdp_connector::Credentials::UsernamePassword { username, password } = value else { + return Err(general_err!("Invalid credentials type for VM connection",)); + }; + + Ok(Credentials { + username: username.to_owned(), + password: password.to_owned(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct VmConnectorConfig { + pub request_data: Option, + pub credentials: Credentials, +} + +impl TryFrom<&ironrdp_connector::Config> for VmConnectorConfig { + type Error = ironrdp_connector::ConnectorError; + + fn try_from(value: &ironrdp_connector::Config) -> Result { + let request_data = value.request_data.clone(); + let ironrdp_connector::Credentials::UsernamePassword { username, password } = &value.credentials else { + return Err(general_err!("Invalid credentials type for VM connection",)); + }; + + let credentials = Credentials { + username: username.to_owned(), + password: password.to_owned(), + }; + + Ok(VmConnectorConfig { + request_data, + credentials, + }) + } +} diff --git a/crates/ironrdp-vmconnect/src/connector.rs b/crates/ironrdp-vmconnect/src/connector.rs new file mode 100644 index 000000000..f47cb9ad1 --- /dev/null +++ b/crates/ironrdp-vmconnect/src/connector.rs @@ -0,0 +1,250 @@ +use core::mem; + +use ironrdp_core::{decode, WriteBuf}; +use ironrdp_pdu::nego::SecurityProtocol; +use ironrdp_pdu::x224::X224; +use ironrdp_pdu::{nego, PduHint}; + +use ironrdp_connector::{ + general_err, reason_err, ClientConnector, ClientConnectorState, ConnectorError, ConnectorErrorExt as _, + ConnectorResult, CredsspSequenceFactory, Sequence, State, Written, +}; + +use crate::config::VmConnectorConfig; + +pub const HYPERV_SECURITY_PROTOCOL: SecurityProtocol = SecurityProtocol::HYBRID_EX + .union(SecurityProtocol::SSL) + .union(SecurityProtocol::HYBRID); + +#[derive(Default, Debug)] +#[non_exhaustive] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub enum VmConnectorState { + #[default] + Consumed, + EnhancedSecurityUpgrade, + Credssp, + ConnectionInitiationSendRequest, + ConnectionInitiationWaitConfirm, + Handover { + selected_protocol: SecurityProtocol, + }, +} + +impl State for VmConnectorState { + fn name(&self) -> &'static str { + match self { + Self::Consumed => "Consumed", + Self::ConnectionInitiationSendRequest => "ConnectionInitiationSendRequest", + Self::ConnectionInitiationWaitConfirm => "ConnectionInitiationWaitResponse", + Self::EnhancedSecurityUpgrade => "EnhancedSecurityUpgrade", + Self::Credssp => "Credssp", + Self::Handover { .. } => "Handover", + } + } + + fn is_terminal(&self) -> bool { + matches!(self, Self::Handover { .. }) + } + + fn as_any(&self) -> &dyn core::any::Any { + self + } +} + +#[derive(Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct VmClientConnector { + pub config: VmConnectorConfig, + pub state: VmConnectorState, + client_connector: ClientConnector, // hold it hostage, can't do anything with it until VMConnector handover +} + +impl Sequence for VmClientConnector { + fn next_pdu_hint(&self) -> Option<&dyn PduHint> { + match &self.state { + VmConnectorState::Consumed => None, + VmConnectorState::ConnectionInitiationSendRequest => None, + VmConnectorState::ConnectionInitiationWaitConfirm => Some(&ironrdp_pdu::X224_HINT), + VmConnectorState::EnhancedSecurityUpgrade => None, + VmConnectorState::Credssp => None, + VmConnectorState::Handover { .. } => None, + } + } + + fn state(&self) -> &dyn State { + &self.state + } + + fn step(&mut self, input: &[u8], output: &mut WriteBuf) -> ConnectorResult { + let (written, next_state) = match mem::take(&mut self.state) { + // Invalid state + VmConnectorState::Consumed => { + return Err(general_err!("connector sequence state is consumed (this is a bug)",)) + } + + //== Connection Initiation ==// + // Exchange supported security protocols and a few other connection flags. + VmConnectorState::EnhancedSecurityUpgrade => (Written::Nothing, VmConnectorState::Credssp), + + VmConnectorState::Credssp => (Written::Nothing, VmConnectorState::ConnectionInitiationSendRequest), + VmConnectorState::ConnectionInitiationSendRequest => { + debug!("Connection Initiation"); + + let connection_request = nego::ConnectionRequest { + nego_data: self + .config + .request_data + .clone() + .or_else(|| Some(nego::NegoRequestData::cookie(self.config.credentials.username.clone()))), + flags: nego::RequestFlags::empty(), + protocol: HYPERV_SECURITY_PROTOCOL, + }; + + debug!(message = ?connection_request, "Send"); + + let written = + ironrdp_core::encode_buf(&X224(connection_request), output).map_err(ConnectorError::encode)?; + + ( + Written::from_size(written)?, + VmConnectorState::ConnectionInitiationWaitConfirm, + ) + } + VmConnectorState::ConnectionInitiationWaitConfirm => { + let connection_confirm = decode::>(input) + .map_err(ConnectorError::decode) + .map(|p| p.0)?; + + debug!(message = ?connection_confirm, "Received"); + + let (flags, selected_protocol) = match connection_confirm { + nego::ConnectionConfirm::Response { flags, protocol } => (flags, protocol), + nego::ConnectionConfirm::Failure { code } => { + error!(?code, "Received connection failure code"); + return Err(reason_err!("Initiation", "{code}")); + } + }; + + info!(?selected_protocol, ?flags, "Server confirmed connection"); + + (Written::Nothing, VmConnectorState::Handover { selected_protocol }) + } + + VmConnectorState::Handover { .. } => { + return Err(general_err!( + "connector sequence state is already in handover (this is a bug)", + )); + } + }; + + self.state = next_state; + + Ok(written) + } +} + +impl VmClientConnector { + pub fn take_over(connector: ClientConnector) -> ConnectorResult { + assert!( + matches!(connector.state, ClientConnectorState::ConnectionInitiationSendRequest), + "Invalid connector state for VM connection, expected ConnectionInitiationSendRequest, got: {}", + connector.state.name() + ); + + debug!("Taking over VM connector"); + + let vm_connector_config = VmConnectorConfig::try_from(&connector.config)?; + let vm_connector = VmClientConnector { + config: vm_connector_config, + state: VmConnectorState::EnhancedSecurityUpgrade, + client_connector: connector, + }; + + Ok(vm_connector) + } + + pub fn should_hand_over(&self) -> bool { + matches!(self.state, VmConnectorState::Handover { .. }) + } + + pub fn hand_over(self) -> ClientConnector { + let VmConnectorState::Handover { selected_protocol } = self.state else { + panic!( + "Invalid state for handover, expected Handover, got: {}", + self.state.name() + ); + }; + let VmClientConnector { + mut client_connector, .. + } = self; + + client_connector.state = ClientConnectorState::BasicSettingsExchangeSendInitial { selected_protocol }; + + client_connector + } +} + +impl VmClientConnector { + pub fn should_perform_credssp(&self) -> bool { + matches!(self.state, VmConnectorState::Credssp) + } +} + +impl ironrdp_connector::SecurityConnector for VmClientConnector { + fn should_perform_security_upgrade(&self) -> bool { + matches!(self.state, VmConnectorState::EnhancedSecurityUpgrade) + } + + fn mark_security_upgrade_as_done(&mut self) { + assert!(self.should_perform_security_upgrade()); + self.step(&[], &mut WriteBuf::new()).expect("transition to next state"); + debug_assert!(!self.should_perform_security_upgrade()); + } + + fn should_perform_credssp(&self) -> bool { + matches!(self.state, VmConnectorState::Credssp) + } + + fn selected_protocol(&self) -> Option { + if self.should_perform_credssp() { + Some(HYPERV_SECURITY_PROTOCOL) + } else { + None + } + } + + fn mark_credssp_as_done(&mut self) { + assert!(self.should_perform_credssp()); + self.step(&[], &mut WriteBuf::new()).expect("transition to next state"); + debug_assert!(!self.should_perform_credssp()); + } + + fn config(&self) -> &ironrdp_connector::Config { + self.client_connector.config() + } +} + +impl CredsspSequenceFactory for VmClientConnector { + fn init_credssp( + &self, + credentials: ironrdp_connector::Credentials, + domain: Option<&str>, + _protocol: SecurityProtocol, + server_name: ironrdp_connector::ServerName, + server_public_key: Vec, + _kerberos_config: Option, + ) -> ConnectorResult<( + Box, + sspi::credssp::TsRequest, + )> { + let credentials = crate::config::Credentials::try_from(&credentials)?; + + let (credssp, ts_request) = + crate::credssp::VmCredsspSequence::init(credentials, domain, server_name, server_public_key)?; + + let credssp: Box = Box::new(credssp); + + Ok((credssp, ts_request)) + } +} diff --git a/crates/ironrdp-vmconnect/src/credssp.rs b/crates/ironrdp-vmconnect/src/credssp.rs new file mode 100644 index 000000000..83e8c8207 --- /dev/null +++ b/crates/ironrdp-vmconnect/src/credssp.rs @@ -0,0 +1,114 @@ +use ironrdp_connector::credssp::{CredsspSequenceTrait, CredsspState}; +use ironrdp_connector::{custom_err, general_err}; +use ironrdp_core::WriteBuf; +use sspi::credssp::{self, ClientState, CredSspClient}; + +use ironrdp_connector::{ConnectorError, ConnectorErrorKind, ConnectorResult, ServerName, Written}; + +use crate::config::Credentials; + +// pub type CredsspProcessGenerator<'a> = Generator<'a, NetworkRequest, sspi::Result>, sspi::Result>; + +#[derive(Debug)] +pub struct VmCredsspSequence { + client: CredSspClient, + state: CredsspState, +} + +impl CredsspSequenceTrait for VmCredsspSequence { + fn credssp_state(&self) -> &CredsspState { + &self.state + } + + fn set_credssp_state(&mut self, state: CredsspState) { + self.state = state; + } + + fn process_ts_request( + &mut self, + request: credssp::TsRequest, + ) -> ironrdp_connector::credssp::CredsspProcessGenerator<'_> { + self.client.process(request) + } + + fn handle_process_result(&mut self, result: ClientState, output: &mut WriteBuf) -> ConnectorResult { + let (size, next_state) = match self.state { + CredsspState::Ongoing => { + let (ts_request_from_client, next_state) = match result { + ClientState::ReplyNeeded(ts_request) => (ts_request, CredsspState::Ongoing), + ClientState::FinalMessage(ts_request) => (ts_request, CredsspState::Finished), + }; + + debug!(message = ?ts_request_from_client, "Send"); + + let written = write_credssp_request(ts_request_from_client, output)?; + + Ok((Written::from_size(written)?, next_state)) + } + CredsspState::EarlyUserAuthResult => Ok((Written::Nothing, CredsspState::Finished)), + CredsspState::Finished => Err(general_err!("CredSSP sequence is already done")), + }?; + + self.state = next_state; + + Ok(size) + } +} + +/// The main difference between this and the `credssp::CredsspSequence` is that this sequence uses NTLM only +/// No Kerberos or Negotiate, as Hyper-V does not support it +impl VmCredsspSequence { + /// `server_name` must be the actual target server hostname (as opposed to the proxy) + pub fn init( + credentials: Credentials, + domain: Option<&str>, + server_name: ServerName, + server_public_key: Vec, + ) -> ConnectorResult<(Self, credssp::TsRequest)> { + let credentials: sspi::Credentials = credentials + .to_sspi_auth_identity(domain) + .map_err(|e| custom_err!("Invalid username", e))? + .into(); + + let server_name = server_name.into_inner(); + + let service_principal_name = format!("TERMSRV/{}", &server_name); + + let credssp_config = Box::::default(); + debug!(?credssp_config); + + let client = CredSspClient::new( + server_public_key, + credentials, + credssp::CredSspMode::WithCredentials, + credssp::ClientMode::Ntlm(sspi::ntlm::NtlmConfig { + client_computer_name: Some(server_name), + }), + service_principal_name, + ) + .map_err(|e| ConnectorError::new("CredSSP", ConnectorErrorKind::Credssp(e)))?; + + let sequence = Self { + client, + state: CredsspState::Ongoing, + }; + + let initial_request = credssp::TsRequest::default(); + + Ok((sequence, initial_request)) + } +} + +fn write_credssp_request(ts_request: credssp::TsRequest, output: &mut WriteBuf) -> ConnectorResult { + let length = usize::from(ts_request.buffer_len()); + + let unfilled_buffer = output.unfilled_to(length); + + ts_request + .encode_ts_request(unfilled_buffer) + .map_err(|e| custom_err!("TsRequest", e))?; + + output.advance(length); + + Ok(length) +} diff --git a/crates/ironrdp-vmconnect/src/lib.rs b/crates/ironrdp-vmconnect/src/lib.rs new file mode 100644 index 000000000..61bde2945 --- /dev/null +++ b/crates/ironrdp-vmconnect/src/lib.rs @@ -0,0 +1,7 @@ +#[macro_use] +extern crate tracing; + +pub mod config; +pub mod connector; +pub mod credssp; +pub use connector::*; diff --git a/crates/ironrdp-web/Cargo.toml b/crates/ironrdp-web/Cargo.toml index e99055535..17cb9a31f 100644 --- a/crates/ironrdp-web/Cargo.toml +++ b/crates/ironrdp-web/Cargo.toml @@ -43,6 +43,7 @@ ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" ironrdp-propertyset.path = "../ironrdp-propertyset" ironrdp-rdpfile.path = "../ironrdp-rdpfile" iron-remote-desktop.path = "../iron-remote-desktop" +ironrdp-vmconnect.path = "../ironrdp-vmconnect" # WASM wasm-bindgen = "0.2" @@ -79,6 +80,8 @@ tap = "1" semver = "1" url = "2.5" base64 = "0.22" +tracing-wasm = "0.2.1" +console_error_panic_hook = "0.1.7" [lints] workspace = true diff --git a/crates/ironrdp-web/src/clipboard.rs b/crates/ironrdp-web/src/clipboard.rs index 3f2e91e55..7cf7284fb 100644 --- a/crates/ironrdp-web/src/clipboard.rs +++ b/crates/ironrdp-web/src/clipboard.rs @@ -14,7 +14,6 @@ use std::collections::HashMap; use futures_channel::mpsc; -use iron_remote_desktop::ClipboardData as _; use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackend}; use ironrdp::cliprdr::pdu::{ ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, FileContentsRequest, @@ -22,8 +21,8 @@ use ironrdp::cliprdr::pdu::{ }; use ironrdp_cliprdr_format::bitmap::{dib_to_png, dibv5_to_png, png_to_cf_dibv5}; use ironrdp_cliprdr_format::html::{cf_html_to_plain_html, plain_html_to_cf_html}; -use ironrdp_core::{impl_as_any, IntoOwned as _}; -use tracing::{error, trace, warn}; +use ironrdp_core::{impl_as_any, IntoOwned}; +use tracing::{debug, error, info, info_span, trace, warn}; use wasm_bindgen::prelude::*; use crate::session::RdpInputEvent; @@ -105,6 +104,8 @@ pub(crate) enum WasmClipboardBackendMessage { RemoteClipboardChanged(Vec), RemoteDataResponse(FormatDataResponse<'static>), + // TODO: Not implemented yet + // FormatListReceived, ForceClipboardUpdate, } @@ -124,6 +125,7 @@ pub(crate) struct WasmClipboard { /// Callbacks, required to interact with JS code from within the backend. pub(crate) struct JsClipboardCallbacks { pub(crate) on_remote_clipboard_changed: js_sys::Function, + pub(crate) on_remote_received_format_list: Option, pub(crate) on_force_clipboard_update: Option, } @@ -261,12 +263,17 @@ impl WasmClipboard { &mut self, formats: Vec, ) -> anyhow::Result> { + let _span = info_span!("clipboard.remote_changed", formats_count=%formats.len()).entered(); + info!("processing remote clipboard change"); + self.remote_clipboard.clear(); // We accumulate all formats in the `remote_formats_to_read` attribute. // Later, we loop over and fetch all of these (see `process_remote_data_response`). self.remote_formats_to_read.clear(); + debug!(formats=?formats, "received clipboard formats from remote"); + // In this loop, we ignore some formats. There are two reasons for that: // // 1) Some formats require an extra conversion into the appropriate MIME format @@ -279,6 +286,8 @@ impl WasmClipboard { // into the same MIME type. Fetching only one of these is enough, especially given // that delayed rendering is not an option. for format in &formats { + debug!(format_id=%format.id().value(), format_name=?format.name().map(|n| n.value()), "processing format"); + if format.id().is_registered() { if let Some(name) = format.name() { const SUPPORTED_FORMATS: &[&str] = &[ @@ -289,7 +298,7 @@ impl WasmClipboard { ]; if !SUPPORTED_FORMATS.iter().any(|supported| *supported == name.value()) { - // Unknown format + debug!(format_name=%name.value(), "unknown registered format, skipping"); continue; } @@ -302,6 +311,7 @@ impl WasmClipboard { && formats.iter().any(|format| format_name_eq(format, FORMAT_PNG.name)); if skip_win_html || skip_mime_png { + debug!(format_name=%name.value(), skip_win_html, skip_mime_png, "skipping format due to priority"); continue; } @@ -315,7 +325,7 @@ impl WasmClipboard { ]; if !SUPPORTED_FORMATS.contains(&format.id()) { - // Unknown format + debug!(format_id=%format.id().value(), "unknown standard format, skipping"); continue; } @@ -332,14 +342,24 @@ impl WasmClipboard { }); if skip_dib || skip_dibv5 { + debug!(format_id=%format.id().value(), skip_dib, skip_dibv5, "skipping format due to priority"); continue; } } + debug!(format_id=%format.id().value(), "adding format to read queue"); self.remote_formats_to_read.push(format.id()); } - return Ok(self.remote_formats_to_read.last().copied()); + info!(formats_to_read=?self.remote_formats_to_read, "determined formats to read from remote"); + let next_format = self.remote_formats_to_read.last().copied(); + if let Some(format_id) = next_format { + info!(first_format_id=%format_id.value(), "will start reading first format"); + } else { + info!("no formats to read from remote"); + } + + return Ok(next_format); fn format_name_eq(format: &ClipboardFormat, name: &str) -> bool { format @@ -358,56 +378,94 @@ impl WasmClipboard { } }; + let _span = info_span!("clipboard.remote_data_response", format_id=%pending_format.value(), remaining_formats=%self.remote_formats_to_read.len()).entered(); + info!("processing remote data response"); + if response.is_error() { - // Format is not available anymore. + info!("format data response indicates error, format not available"); return Ok(()); } + debug!(data_size=%response.data().len(), "received format data from remote"); + let item = match pending_format { - ClipboardFormatId::CF_UNICODETEXT => match response.to_unicode_string() { - Ok(text) => Some(ClipboardItem::new_text(MIME_TEXT, text)), - Err(err) => { - error!("CF_UNICODETEXT decode error: {}", err); - None + ClipboardFormatId::CF_UNICODETEXT => { + debug!("processing CF_UNICODETEXT format"); + match response.to_unicode_string() { + Ok(text) => { + info!(text_length=%text.len(), "successfully decoded CF_UNICODETEXT"); + Some(ClipboardItem::new_text(MIME_TEXT, text)) + } + Err(err) => { + error!(error=%err, "CF_UNICODETEXT decode error"); + None + } } - }, - ClipboardFormatId::CF_DIB => match dib_to_png(response.data()) { - Ok(png) => Some(ClipboardItem::new_binary(MIME_PNG, png)), - Err(err) => { - warn!("DIB decode error: {}", err); - None + } + ClipboardFormatId::CF_DIB => { + debug!("processing CF_DIB format"); + match dib_to_png(response.data()) { + Ok(png) => { + info!(png_size=%png.len(), "successfully converted CF_DIB to PNG"); + Some(ClipboardItem::new_binary(MIME_PNG, png)) + } + Err(err) => { + warn!(error=%err, "DIB decode error"); + None + } } - }, - ClipboardFormatId::CF_DIBV5 => match dibv5_to_png(response.data()) { - Ok(png) => Some(ClipboardItem::new_binary(MIME_PNG, png)), - Err(err) => { - warn!("DIBv5 decode error: {}", err); - None + } + ClipboardFormatId::CF_DIBV5 => { + debug!("processing CF_DIBV5 format"); + match dibv5_to_png(response.data()) { + Ok(png) => { + info!(png_size=%png.len(), "successfully converted CF_DIBV5 to PNG"); + Some(ClipboardItem::new_binary(MIME_PNG, png)) + } + Err(err) => { + warn!(error=%err, "DIBv5 decode error"); + None + } } - }, + } registered => { let format_name = self.remote_mapping.get(®istered).map(|s| s.as_str()); + debug!(format_id=%registered.value(), format_name=?format_name, "processing registered format"); match format_name { - Some(FORMAT_WIN_HTML_NAME) => match cf_html_to_plain_html(response.data()) { - Ok(text) => Some(ClipboardItem::new_text(MIME_HTML, text.to_owned())), - Err(err) => { - warn!("CF_HTML decode error: {}", err); - None + Some(FORMAT_WIN_HTML_NAME) => { + debug!("processing HTML Format (Windows CF_HTML)"); + match cf_html_to_plain_html(response.data()) { + Ok(text) => { + info!(html_length=%text.len(), "successfully converted CF_HTML to plain HTML"); + Some(ClipboardItem::new_text(MIME_HTML, text.to_owned())) + } + Err(err) => { + warn!(error=%err, "CF_HTML decode error"); + None + } } - }, - Some(FORMAT_MIME_HTML_NAME) => match response.to_string() { - Ok(text) => Some(ClipboardItem::new_text(MIME_HTML, text)), - Err(err) => { - warn!("text/html decode error: {}", err); - None + } + Some(FORMAT_MIME_HTML_NAME) => { + debug!("processing text/html format"); + match response.to_string() { + Ok(text) => { + info!(html_length=%text.len(), "successfully decoded text/html"); + Some(ClipboardItem::new_text(MIME_HTML, text)) + } + Err(err) => { + warn!(error=%err, "text/html decode error"); + None + } } - }, + } Some(FORMAT_MIME_PNG_NAME) | Some(FORMAT_PNG_NAME) => { + debug!("processing PNG format"); + info!(png_size=%response.data().len(), "successfully received PNG data"); Some(ClipboardItem::new_binary(MIME_PNG, response.data().to_owned())) } _ => { - // Not supported format + debug!(format_name=?format_name, "not supported registered format"); None } } @@ -415,11 +473,15 @@ impl WasmClipboard { }; if let Some(item) = item { + info!(mime_type=%item.mime_type, "successfully processed format, adding to clipboard"); self.remote_clipboard.add(item); + } else { + debug!("format processing resulted in no item"); } if let Some(format) = self.remote_formats_to_read.last() { // Request next format. + info!(next_format_id=%format.value(), remaining_formats=%self.remote_formats_to_read.len(), "requesting next format from remote"); self.proxy .send_cliprdr_message(ClipboardMessage::SendInitiatePaste(*format)); } else { @@ -427,9 +489,11 @@ impl WasmClipboard { let clipboard_data = core::mem::take(&mut self.remote_clipboard); if clipboard_data.is_empty() { + info!("all formats processed but clipboard is empty, not calling JS callback"); return Ok(()); } + info!(items_count=%clipboard_data.items().len(), "all formats processed, calling JS callback with clipboard data"); // Set clipboard when all formats were read. self.js_callbacks .on_remote_clipboard_changed @@ -445,6 +509,9 @@ impl WasmClipboard { /// Process backend event. This method should be called from the main event loop. pub(crate) fn process_event(&mut self, event: WasmClipboardBackendMessage) -> anyhow::Result<()> { + let _span = info_span!("clipboard.process_event").entered(); + debug!(event=?event, "processing clipboard backend event"); + match event { WasmClipboardBackendMessage::LocalClipboardChanged(clipboard_data) => { match self.handle_local_clipboard_changed(clipboard_data) { @@ -471,15 +538,17 @@ impl WasmClipboard { .send_cliprdr_message(ClipboardMessage::SendFormatData(message)); } WasmClipboardBackendMessage::RemoteClipboardChanged(formats) => { + info!("processing remote clipboard changed event"); match self.process_remote_clipboard_changed(formats) { Ok(Some(format)) => { // We start querying formats right away. This is due absence of // delay-rendering in web client. + info!(format_id=%format.value(), "initiating first format request"); self.proxy .send_cliprdr_message(ClipboardMessage::SendInitiatePaste(format)); } Ok(None) => { - // No formats to query + info!("no formats to query from remote clipboard"); } Err(e) => { error!(error = format!("{e:#}"), "Failed to process remote clipboard change"); @@ -487,6 +556,7 @@ impl WasmClipboard { } } WasmClipboardBackendMessage::RemoteDataResponse(formats) => { + info!("processing remote data response event"); match self.process_remote_data_response(formats) { Ok(()) => {} Err(e) => { @@ -494,10 +564,23 @@ impl WasmClipboard { } } } + // TODO: Not implemented yet + // WasmClipboardBackendMessage::FormatListReceived => { + // info!("processing format list received event"); + // if let Some(callback) = self.js_callbacks.on_remote_received_format_list.as_mut() { + // info!("calling JS on_remote_received_format_list callback"); + // callback.call0(&JsValue::NULL).expect("failed to call JS callback"); + // } else { + // debug!("no on_remote_received_format_list callback configured"); + // } + // } WasmClipboardBackendMessage::ForceClipboardUpdate => { + info!("processing force clipboard update event"); if let Some(callback) = self.js_callbacks.on_force_clipboard_update.as_mut() { + info!("calling JS on_force_clipboard_update callback"); callback.call0(&JsValue::NULL).expect("failed to call JS callback"); } else { + info!("no force clipboard callback set, sending empty format list instead"); // If no initial clipboard callback was set, send empty format list instead return self .process_event(WasmClipboardBackendMessage::LocalClipboardChanged(ClipboardData::new())); @@ -541,21 +624,30 @@ impl CliprdrBackend for WasmClipboardBackend { self.send_event(WasmClipboardBackendMessage::ForceClipboardUpdate); } + // TODO: This method is not part of the CliprdrBackend trait + // fn on_format_list_received(&mut self) { + // self.send_event(WasmClipboardBackendMessage::FormatListReceived); + // } + fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) { // No additional capabilities yet } fn on_remote_copy(&mut self, available_formats: &[ClipboardFormat]) { + info!(formats_count=%available_formats.len(), "received remote copy notification"); + debug!(formats=?available_formats, "remote clipboard formats"); self.send_event(WasmClipboardBackendMessage::RemoteClipboardChanged( available_formats.to_vec(), )); } fn on_format_data_request(&mut self, request: FormatDataRequest) { + info!(format_id=%request.format.value(), "received format data request from remote"); self.send_event(WasmClipboardBackendMessage::RemoteDataRequest(request.format)); } fn on_format_data_response(&mut self, response: FormatDataResponse<'_>) { + info!(data_size=%response.data().len(), is_error=%response.is_error(), "received format data response from remote"); self.send_event(WasmClipboardBackendMessage::RemoteDataResponse(response.into_owned())); } @@ -591,6 +683,14 @@ impl ClipboardData { self.items.push(item); } + pub(crate) fn items(&self) -> &[ClipboardItem] { + &self.items + } + + pub(crate) fn is_empty(&self) -> bool { + self.items.is_empty() + } + pub(crate) fn clear(&mut self) { self.items.clear(); } @@ -639,8 +739,8 @@ pub(crate) enum ClipboardItemValue { /// Object which represents single clipboard format represented standard MIME type. #[derive(Debug, Clone)] pub(crate) struct ClipboardItem { - mime_type: String, - value: ClipboardItemValue, + pub(crate) mime_type: String, + pub(crate) value: ClipboardItemValue, } impl ClipboardItem { @@ -664,7 +764,7 @@ impl iron_remote_desktop::ClipboardItem for ClipboardItem { &self.mime_type } - #[expect(refining_impl_trait)] + #[allow(refining_impl_trait)] fn value(&self) -> JsValue { match &self.value { ClipboardItemValue::Text(text) => JsValue::from_str(text), diff --git a/crates/ironrdp-web/src/lib.rs b/crates/ironrdp-web/src/lib.rs index b98ebb14b..bf92b8ab7 100644 --- a/crates/ironrdp-web/src/lib.rs +++ b/crates/ironrdp-web/src/lib.rs @@ -5,7 +5,7 @@ html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg" ) )] -#![allow(clippy::new_without_default)] // Default trait can’t be used by wasm consumer anyway. +#![allow(clippy::new_without_default)] // Default trait can't be used by wasm consumer anyway. // Silence the unused_crate_dependencies lint. // These crates are added just to enable additional WASM features. diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index afcb387bd..68ae75a9e 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -17,7 +17,7 @@ use ironrdp::cliprdr::backend::ClipboardMessage; use ironrdp::cliprdr::CliprdrClient; use ironrdp::connector::connection_activation::ConnectionActivationState; use ironrdp::connector::credssp::KerberosConfig; -use ironrdp::connector::{self, ClientConnector, Credentials}; +use ironrdp::connector::{self, ClientConnector, ConnectorCore, Credentials}; use ironrdp::displaycontrol::client::DisplayControlClient; use ironrdp::dvc::DrdynvcClient; use ironrdp::graphics::image_processing::PixelFormat; @@ -27,13 +27,14 @@ use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; use ironrdp::session::image::DecodedImage; use ironrdp::session::{fast_path, ActiveStage, ActiveStageOutput, GracefulDisconnectReason}; use ironrdp_core::WriteBuf; -use ironrdp_futures::{single_sequence_step_read, FramedWrite}; +use ironrdp_futures::{single_sequence_step_read, FramedRead, FramedWrite}; use rgb::AsPixels as _; use tap::prelude::*; use tracing::{debug, error, info, trace, warn}; use wasm_bindgen::JsValue; use wasm_bindgen_futures::spawn_local; use web_sys::HtmlCanvasElement; +use x509_cert::der::asn1::OctetString; use crate::canvas::Canvas; use crate::clipboard; @@ -56,7 +57,7 @@ struct SessionBuilderInner { password: Option, proxy_address: Option, auth_token: Option, - pcb: Option, + pcb: Option, kdc_proxy_url: Option, client_name: String, desktop_size: DesktopSize, @@ -72,6 +73,21 @@ struct SessionBuilderInner { outbound_message_size_limit: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PreconnectionBlobPayload { + General(String), + VmConnect(String), +} + +impl PreconnectionBlobPayload { + pub(crate) fn general(&self) -> Option<&str> { + match self { + PreconnectionBlobPayload::General(pcb) => Some(pcb), + PreconnectionBlobPayload::VmConnect(_) => None, + } + } +} + impl Default for SessionBuilderInner { fn default() -> Self { Self { @@ -192,6 +208,7 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { /// Optional fn remote_clipboard_changed_callback(&self, callback: js_sys::Function) -> Self { + info!("Setting remote clipboard changed callback"); self.0.borrow_mut().remote_clipboard_changed_callback = Some(callback); self.clone() } @@ -210,7 +227,8 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { fn extension(&self, ext: Extension) -> Self { iron_remote_desktop::extension_match! { match ext; - |pcb: String| { self.0.borrow_mut().pcb = Some(pcb) }; + |pcb: String| { self.0.borrow_mut().pcb = Some(PreconnectionBlobPayload::General(pcb)) }; + |vmconnect: String| { self.0.borrow_mut().pcb = Some(PreconnectionBlobPayload::VmConnect(vmconnect)) }; |kdc_proxy_url: String| { self.0.borrow_mut().kdc_proxy_url = Some(kdc_proxy_url) }; |display_control: bool| { self.0.borrow_mut().use_display_control = display_control }; |enable_credssp: bool| { self.0.borrow_mut().enable_credssp = enable_credssp }; @@ -273,6 +291,10 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { .set_cursor_style_callback_context .clone() .context("set_cursor_style_callback_context missing")?; + info!( + "remote_clipboard_changed_callback = {:?}", + inner.remote_clipboard_changed_callback.is_some() + ); remote_clipboard_changed_callback = inner.remote_clipboard_changed_callback.clone(); force_clipboard_update_callback = inner.force_clipboard_update_callback.clone(); outbound_message_size_limit = inner.outbound_message_size_limit; @@ -292,6 +314,7 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { clipboard::WasmClipboardMessageProxy::new(input_events_tx.clone()), clipboard::JsClipboardCallbacks { on_remote_clipboard_changed: callback, + on_remote_received_format_list: None, on_force_clipboard_update: force_clipboard_update_callback, }, ) @@ -939,7 +962,7 @@ struct ConnectParams { config: connector::Config, proxy_auth_token: String, destination: String, - pcb: Option, + pcb: Option, kdc_proxy_url: Option, clipboard_backend: Option, use_display_control: bool, @@ -974,13 +997,13 @@ async fn connect( ); } - let (upgraded, server_public_key) = - connect_rdcleanpath(&mut framed, &mut connector, destination.clone(), proxy_auth_token, pcb).await?; + let (upgraded, server_public_key, mut connector) = + connect_rdcleanpath(&mut framed, connector, destination.clone(), proxy_auth_token, &pcb).await?; - let connection_result = ironrdp_futures::connect_finalize( + let mut credssp_done = ironrdp_futures::perform_credssp( upgraded, + connector.as_mut(), &mut framed, - connector, (&destination).into(), server_public_key, Some(&mut WasmNetworkClient), @@ -995,6 +1018,9 @@ async fn connect( ) .await?; + let connector = downcast_back_to_client_connector(connector, &mut credssp_done, &mut framed).await?; + let connection_result = ironrdp_futures::connect_finalize(credssp_done, &mut framed, connector).await?; + let ws = framed.into_inner_no_leftover(); Ok((connection_result, ws)) @@ -1002,13 +1028,13 @@ async fn connect( async fn connect_rdcleanpath( framed: &mut ironrdp_futures::Framed, - connector: &mut ClientConnector, + mut connector: ClientConnector, destination: String, proxy_auth_token: String, - pcb: Option, -) -> Result<(ironrdp_futures::Upgraded, Vec), IronError> + pcb: &Option, +) -> connector::ConnectorResult<(ironrdp_futures::Upgraded, Vec, Box)> where - S: ironrdp_futures::FramedRead + FramedWrite, + S: FramedRead + FramedWrite, { use ironrdp::connector::Sequence as _; use x509_cert::der::Decode as _; @@ -1033,123 +1059,139 @@ where let mut buf = WriteBuf::new(); - info!("Begin connection procedure"); + debug!(?pcb, "Begin connection procedure"); - { - // RDCleanPath request + // let connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state() else { + // return Err(connector::general_err!("invalid connector state (send request)")); + // }; - let connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state else { - return Err(anyhow::Error::msg("invalid connector state (send request)").into()); - }; - - debug_assert!(connector.next_pdu_hint().is_none()); + debug_assert!(connector.next_pdu_hint().is_none()); + let (rdcleanpath_request, mut connector): (ironrdp_rdcleanpath::RDCleanPathPdu, Box) = + if let Some(PreconnectionBlobPayload::VmConnect(vm_id)) = pcb { + let rdcleanpath_req = ironrdp_rdcleanpath::RDCleanPathPdu::new_request( + None, + destination, + proxy_auth_token, + Some(vm_id.to_owned()), + ) + .map_err(|e| connector::custom_err!("new RDCleanPath request", e))?; - let written = connector.step_no_input(&mut buf)?; - let x224_pdu_len = written.size().expect("written size"); - debug_assert_eq!(x224_pdu_len, buf.filled_len()); - let x224_pdu = buf.filled().to_vec(); + debug!(message = ?rdcleanpath_req, "Send RDCleanPath request for VMConnect"); - let rdcleanpath_req = - ironrdp_rdcleanpath::RDCleanPathPdu::new_request(x224_pdu, destination, proxy_auth_token, pcb) - .context("new RDCleanPath request")?; - debug!(message = ?rdcleanpath_req, "Send RDCleanPath request"); - let rdcleanpath_req = rdcleanpath_req.to_der().context("RDCleanPath request encode")?; + let pcb_sent = ironrdp_futures::mark_pcb_sent_by_rdclean_path(); + let connector = ironrdp_futures::vm_connector_take_over(pcb_sent, connector)?; + (rdcleanpath_req, Box::new(connector) as Box) + } else { + let written = connector.step_no_input(&mut buf)?; + let x224_pdu_len = written.size().expect("written size"); + debug_assert_eq!(x224_pdu_len, buf.filled_len()); + let x224_pdu = buf.filled().to_vec(); + let general_pcb = pcb.as_ref().and_then(|pcb| pcb.general()); + // RDCleanPath request + + let rdcleanpath_req = ironrdp_rdcleanpath::RDCleanPathPdu::new_request( + Some(x224_pdu), + destination, + proxy_auth_token, + general_pcb.map(str::to_string), + ) + .map_err(|e| connector::custom_err!("new RDCleanPath request", e))?; + (rdcleanpath_req, Box::new(connector) as Box) + }; - framed - .write_all(&rdcleanpath_req) - .await - .context("couldn't write RDCleanPath request")?; - } + let rdcleanpath_request = rdcleanpath_request + .to_der() + .map_err(|e| connector::custom_err!("RDCleanPath request encode", e))?; - { - // RDCleanPath response + framed + .write_all(&rdcleanpath_request) + .await + .map_err(|e| connector::custom_err!("couldn’t write RDCleanPath request", e))?; - let rdcleanpath_res = framed - .read_by_hint(&RDCLEANPATH_HINT) - .await - .context("read RDCleanPath request")?; + let rdcleanpath_result = framed + .read_by_hint(&RDCLEANPATH_HINT) + .await + .map_err(|e| connector::custom_err!("read RDCleanPath request", e))?; - let rdcleanpath_res = - ironrdp_rdcleanpath::RDCleanPathPdu::from_der(&rdcleanpath_res).context("RDCleanPath response decode")?; + let rdcleanpath_result = ironrdp_rdcleanpath::RDCleanPathPdu::from_der(&rdcleanpath_result) + .map_err(|e| connector::custom_err!("RDCleanPath response decode", e))?; - debug!(message = ?rdcleanpath_res, "Received RDCleanPath PDU"); + debug!(message = ?rdcleanpath_result, "Received RDCleanPath PDU"); - let (x224_connection_response, server_cert_chain) = - match rdcleanpath_res.into_enum().context("invalid RDCleanPath PDU")? { - ironrdp_rdcleanpath::RDCleanPath::Request { .. } => { - return Err(anyhow::Error::msg("received an unexpected RDCleanPath type (request)").into()); - } - ironrdp_rdcleanpath::RDCleanPath::Response { - x224_connection_response, - server_cert_chain, - server_addr: _, - } => (x224_connection_response, server_cert_chain), - ironrdp_rdcleanpath::RDCleanPath::GeneralErr(error) => { - return Err( - IronError::from(anyhow::Error::new(error).context("received an RDCleanPath error")) - .with_kind(IronErrorKind::RDCleanPath), - ); + let (x224_connection_response, server_cert_chain) = match rdcleanpath_result + .into_enum() + .map_err(|e| connector::custom_err!("invalid RDCleanPath PDU", e))? + { + ironrdp_rdcleanpath::RDCleanPath::Request { .. } => { + return Err(connector::general_err!( + "received an unexpected RDCleanPath type (request)", + )); + } + ironrdp_rdcleanpath::RDCleanPath::Response { + x224_connection_response, + server_cert_chain, + server_addr: _, + } => (x224_connection_response, server_cert_chain), + ironrdp_rdcleanpath::RDCleanPath::GeneralErr(error) => { + return Err(connector::custom_err!("received an RDCleanPath error", error)); + } + ironrdp_rdcleanpath::RDCleanPath::NegotiationErr { + x224_connection_response, + } => { + if let Ok(x224_confirm) = ironrdp_core::decode::< + ironrdp::pdu::x224::X224, + >(&x224_connection_response) + { + if let ironrdp::pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 { + let negotiation_failure = connector::NegotiationFailure::from(code); + return Err(connector::ConnectorError::new( + "RDP negotiation failed", + connector::ConnectorErrorKind::Negotiation(negotiation_failure), + )); } - ironrdp_rdcleanpath::RDCleanPath::NegotiationErr { - x224_connection_response, - } => { - // Try to decode as X.224 Connection Confirm to extract negotiation failure details. - if let Ok(x224_confirm) = ironrdp_core::decode::< - ironrdp::pdu::x224::X224, - >(&x224_connection_response) - { - if let ironrdp::pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 { - // Convert to negotiation failure instead of generic RDCleanPath error. - let negotiation_failure = connector::NegotiationFailure::from(code); - return Err(IronError::from( - anyhow::Error::new(negotiation_failure).context("RDP negotiation failed"), - ) - .with_kind(IronErrorKind::NegotiationFailure)); - } - } + } - // Fallback to generic error if we can't decode the negotiation failure. - return Err( - IronError::from(anyhow::Error::msg("received an RDCleanPath negotiation error")) - .with_kind(IronErrorKind::RDCleanPath), - ); - } - }; + return Err(connector::general_err!("received an RDCleanPath negotiation error")); + } + }; - let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else { - return Err(anyhow::Error::msg("invalid connector state (wait confirm)").into()); - }; + // let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state() else { + // return Err(connector::general_err!("invalid connector state (wait confirm)")); + // }; + buf.clear(); + if let Some(x224_connection_response) = x224_connection_response { debug_assert!(connector.next_pdu_hint().is_some()); - - buf.clear(); let written = connector.step(x224_connection_response.as_bytes(), &mut buf)?; - debug_assert!(written.is_nothing()); + } + + let server_public_key = extract_server_public_key(server_cert_chain)?; + + let should_upgrade = ironrdp_futures::skip_connect_begin(connector.as_mut()); + let upgraded = ironrdp_futures::mark_as_upgraded(should_upgrade, connector.as_mut()); + + return Ok((upgraded, server_public_key, connector)); + + fn extract_server_public_key(server_cert_chain: Vec) -> connector::ConnectorResult> { let server_cert = server_cert_chain .into_iter() .next() - .context("server cert chain missing from rdcleanpath response")?; + .ok_or_else(|| connector::general_err!("server cert chain missing from rdcleanpath response"))?; let cert = x509_cert::Certificate::from_der(server_cert.as_bytes()) - .context("failed to decode x509 certificate sent by proxy")?; + .map_err(|e| connector::custom_err!("server cert chain missing from rdcleanpath response", e))?; let server_public_key = cert .tbs_certificate .subject_public_key_info .subject_public_key .as_bytes() - .context("subject public key BIT STRING is not aligned")? + .ok_or_else(|| connector::general_err!("subject public key BIT STRING is not aligned"))? .to_owned(); - let should_upgrade = ironrdp_futures::skip_connect_begin(connector); - - // At this point, proxy established the TLS session. - - let upgraded = ironrdp_futures::mark_as_upgraded(should_upgrade, connector); - - Ok((upgraded, server_public_key)) + Ok(server_public_key) } } @@ -1158,3 +1200,25 @@ where fn f64_to_u16_saturating_cast(value: f64) -> u16 { value as u16 } + +async fn downcast_back_to_client_connector( + connector: Box, // `ConnectorCore: Any` + credssp_finished: &mut ironrdp_futures::CredSSPFinished, + framed: &mut ironrdp_futures::Framed, +) -> connector::ConnectorResult { + let connector: Box = connector; + + let client = match connector.downcast::() { + Ok(vm_connector) => ironrdp_futures::run_until_handover(credssp_finished, framed, *vm_connector).await?, + Err(err) => match err.downcast::() { + Ok(c) => *c, + Err(_) => { + return Err(connector::general_err!( + "connector is neither ClientConnector nor VmClientConnector" + )) + } + }, + }; + + Ok(client) +} diff --git a/ffi/Cargo.toml b/ffi/Cargo.toml index 22d5871ea..272e39e39 100644 --- a/ffi/Cargo.toml +++ b/ffi/Cargo.toml @@ -19,11 +19,11 @@ ironrdp-cliprdr-native.path = "../crates/ironrdp-cliprdr-native" ironrdp-dvc-pipe-proxy.path = "../crates/ironrdp-dvc-pipe-proxy" ironrdp-core = { path = "../crates/ironrdp-core", features = ["alloc"] } ironrdp-rdcleanpath.path = "../crates/ironrdp-rdcleanpath" -sspi = { version = "0.16", features = ["network_client"] } thiserror = "2" tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1.0" +sspi = { version = "0.16", features = ["network_client"] } [target.'cfg(windows)'.build-dependencies] embed-resource = "3.0" diff --git a/ffi/src/connector/mod.rs b/ffi/src/connector/mod.rs index 332177af9..4533c4f51 100644 --- a/ffi/src/connector/mod.rs +++ b/ffi/src/connector/mod.rs @@ -8,7 +8,7 @@ pub mod ffi { use core::fmt::Write as _; use diplomat_runtime::DiplomatWriteable; - use ironrdp::connector::Sequence as _; + use ironrdp::connector::{SecurityConnector, Sequence as _}; use ironrdp::displaycontrol::client::DisplayControlClient; use ironrdp::dvc::DvcProcessor; use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; diff --git a/ffi/src/credssp/mod.rs b/ffi/src/credssp/mod.rs index b912d2685..a1d4fc55f 100644 --- a/ffi/src/credssp/mod.rs +++ b/ffi/src/credssp/mod.rs @@ -4,6 +4,7 @@ pub mod network; #[diplomat::bridge] pub mod ffi { + use ironrdp::connector::credssp::CredsspSequenceTrait; use ironrdp::connector::ClientConnectorState; use super::network::ffi::{ClientState, CredsspProcessGenerator}; diff --git a/ffi/src/rdcleanpath.rs b/ffi/src/rdcleanpath.rs index a842c747b..7f426424d 100644 --- a/ffi/src/rdcleanpath.rs +++ b/ffi/src/rdcleanpath.rs @@ -29,7 +29,7 @@ pub mod ffi { let pcb_opt = if pcb.is_empty() { None } else { Some(pcb.to_owned()) }; let pdu = ironrdp_rdcleanpath::RDCleanPathPdu::new_request( - x224_pdu.to_vec(), + Some(x224_pdu.to_vec()), destination.to_owned(), proxy_auth.to_owned(), pcb_opt, diff --git a/web-client/iron-remote-desktop-rdp/package-lock.json b/web-client/iron-remote-desktop-rdp/package-lock.json index b1c170a06..3473f0664 100644 --- a/web-client/iron-remote-desktop-rdp/package-lock.json +++ b/web-client/iron-remote-desktop-rdp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devolutions/iron-remote-desktop-rdp", - "version": "0.0.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devolutions/iron-remote-desktop-rdp", - "version": "0.0.0", + "version": "0.7.0", "devDependencies": { "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.21.0", diff --git a/web-client/iron-remote-desktop-rdp/package.json b/web-client/iron-remote-desktop-rdp/package.json index 11ae53cda..00091394e 100644 --- a/web-client/iron-remote-desktop-rdp/package.json +++ b/web-client/iron-remote-desktop-rdp/package.json @@ -6,12 +6,13 @@ "Benoit Cortier" ], "description": "RDP backend for iron-remote-desktop", - "version": "0.0.0", + "version": "0.7.0", "type": "module", "private": true, "scripts": { "dev": "npm run pre-build && vite", "build": "npm run pre-build && vite build", + "build:dev": "npm run pre-build && vite build --mode development", "build-alone": "vite build", "pre-build": "node ./pre-build.js", "preview": "vite preview", diff --git a/web-client/iron-remote-desktop-rdp/public/package.json b/web-client/iron-remote-desktop-rdp/public/package.json index 8fb1a386b..3f0f65fbe 100644 --- a/web-client/iron-remote-desktop-rdp/public/package.json +++ b/web-client/iron-remote-desktop-rdp/public/package.json @@ -6,7 +6,7 @@ "Benoit Cortier" ], "description": "RDP backend for iron-remote-desktop", - "version": "0.6.1", + "version": "0.4.2", "main": "iron-remote-desktop-rdp.js", "types": "index.d.ts", "files": [ diff --git a/web-client/iron-remote-desktop-rdp/src/main.ts b/web-client/iron-remote-desktop-rdp/src/main.ts index b5b0200e2..29d6a407c 100644 --- a/web-client/iron-remote-desktop-rdp/src/main.ts +++ b/web-client/iron-remote-desktop-rdp/src/main.ts @@ -43,3 +43,9 @@ export function outboundMessageSizeLimit(limit: number): Extension { export function enableCredssp(enable: boolean): Extension { return new Extension('enable_credssp', enable); } + +export function vmConnect(vm_id: string): Extension { + return new Extension('vmconnect', vm_id); +} + +console.log('FK ANGULAR, this is updated at 9.30'); diff --git a/web-client/iron-remote-desktop-rdp/vite.config.ts b/web-client/iron-remote-desktop-rdp/vite.config.ts index a81a54127..e094d2854 100644 --- a/web-client/iron-remote-desktop-rdp/vite.config.ts +++ b/web-client/iron-remote-desktop-rdp/vite.config.ts @@ -3,23 +3,26 @@ import topLevelAwait from 'vite-plugin-top-level-await'; import dtsPlugin from 'vite-plugin-dts'; // https://vitejs.dev/config/ -export default defineConfig({ - build: { - lib: { - entry: './src/main.ts', - name: 'IronRemoteDesktopRdp', - formats: ['es'], +export default defineConfig(({ mode }) => { + return { + build: { + lib: { + entry: './src/main.ts', + name: 'IronRemoteDesktopRdp', + formats: ['es'], + }, + sourcemap: mode === 'development', }, - }, - server: { - fs: { - strict: false, + server: { + fs: { + strict: false, + }, }, - }, - plugins: [ - topLevelAwait(), - dtsPlugin({ - rollupTypes: true, - }), - ], + plugins: [ + topLevelAwait(), + dtsPlugin({ + rollupTypes: true, + }), + ], + }; }); diff --git a/web-client/iron-remote-desktop/package-lock.json b/web-client/iron-remote-desktop/package-lock.json index de4e238fd..fca578529 100644 --- a/web-client/iron-remote-desktop/package-lock.json +++ b/web-client/iron-remote-desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devolutions/iron-remote-desktop", - "version": "0.0.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devolutions/iron-remote-desktop", - "version": "0.0.0", + "version": "0.10.0", "devDependencies": { "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.21.0", diff --git a/web-client/iron-remote-desktop/package.json b/web-client/iron-remote-desktop/package.json index bb55b3197..2100d9c9d 100644 --- a/web-client/iron-remote-desktop/package.json +++ b/web-client/iron-remote-desktop/package.json @@ -10,13 +10,14 @@ "Alexandr Yusuk" ], "description": "Web Component providing agnostic implementation for Iron Wasm base client", - "version": "0.0.0", + "version": "0.10.0", "type": "module", "private": true, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", + "build:dev": "vite build --mode development", "check": "svelte-check --tsconfig ./tsconfig.json", "check:dist": "tsc ./dist/index.d.ts --noEmit", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/web-client/iron-remote-desktop/public/package.json b/web-client/iron-remote-desktop/public/package.json index d7e941ecb..124ce5e5d 100644 --- a/web-client/iron-remote-desktop/public/package.json +++ b/web-client/iron-remote-desktop/public/package.json @@ -10,7 +10,7 @@ "Alexandr Yusuk" ], "description": "Backend-agnostic Web Component for remote desktop protocols", - "version": "0.10.1", + "version": "0.10.2", "main": "iron-remote-desktop.js", "types": "index.d.ts", "files": [ diff --git a/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte b/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte index ac0ada011..b9a3bd5c1 100644 --- a/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte +++ b/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte @@ -20,10 +20,8 @@ import type { ResizeEvent } from './interfaces/ResizeEvent'; import { PublicAPI } from './services/PublicAPI'; import { ScreenScale } from './enums/ScreenScale'; + import type { ClipboardData } from './interfaces/ClipboardData'; import type { RemoteDesktopModule } from './interfaces/RemoteDesktopModule'; - import { isComponentDestroyed } from './lib/stores/componentLifecycleStore'; - import { runWhenFocusedQueue } from './lib/stores/runWhenFocusedStore'; - import { ClipboardService } from './services/clipboard.service'; let { scale, @@ -48,22 +46,505 @@ let inner: HTMLDivElement; let wrapper: HTMLDivElement; + let screenViewer: HTMLDivElement; let canvas: HTMLCanvasElement; let viewerStyle = $state(''); let wrapperStyle = $state(''); let remoteDesktopService = new RemoteDesktopService(module); - let clipboardService = new ClipboardService(remoteDesktopService, module); - let publicAPI = new PublicAPI(remoteDesktopService, clipboardService); + let publicAPI = new PublicAPI(remoteDesktopService); let currentScreenScale = ScreenScale.Fit; + // Firefox's clipboard API is very limited, and doesn't support reading from the clipboard + // without changing browser settings via `about:config`. + // + // For firefox, we will use a different approach by marking `screen-wrapper` component + // as `contenteditable=true`, and then using the `onpaste`/`oncopy`/`oncut` events. + let isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; + + const CLIPBOARD_MONITORING_INTERVAL = 100; // ms + + let isClipboardApiSupported = false; + let lastClientClipboardItems: Record = {}; + let lastReceivedClipboardData: Record = {}; + let lastSentClipboardData: ClipboardData | null = null; + let lastClipboardMonitorLoopError: Error | null = null; + + let componentDestroyed = false; + let runWhenFocusedQueue: (() => void)[] = []; + + /* Firefox-specific BEGIN */ + + // See `ffRemoteClipboardData` variable docs below + const FF_REMOTE_CLIPBOARD_DATA_SET_RETRY_INTERVAL = 100; // ms + const FF_REMOTE_CLIPBOARD_DATA_SET_MAX_RETRIES = 30; // 3 seconds (100ms * 30) + // On Firefox, this interval is used to stop delaying the keyboard events if the paste event has + // failed and we haven't received any clipboard data from the remote side. + const FF_LOCAL_CLIPBOARD_COPY_TIMEOUT = 1000; // 1s (For text-only data this should be enough) + + // In Firefox, we need this variable due to fact that `clipboard.writeText()` should only be + // called in scope of user-initiated event processing (e.g. keyboard event), but we receive + // clipboard data from the remote side asynchronously in wasm service callback. therefore we + // set this variable in callback and use its value on the user-initiated copy event. + let ffRemoteClipboardData: ClipboardData | null = null; + // For Firefox we need this variable to perform wait loop for the remote side to finish sending + // clipboard content to the client. + let ffRemoteClipboardDataRetriesLeft = 0; + let ffPostponeKeyboardEvents = false; + let ffDelayedKeyboardEvents: KeyboardEvent[] = []; + let ffCnavasFocused = false; + + /* Firefox-specific END */ + + /* Clipboard initialization BEGIN */ + function initClipboard() { + console.log('[CLIPBOARD] Initializing clipboard functionality'); + console.log(`[CLIPBOARD] Browser: isFirefox=${isFirefox}`); + + // Detect if browser supports async Clipboard API + if (!isFirefox && navigator.clipboard != undefined) { + if (navigator.clipboard.read != undefined && navigator.clipboard.write != undefined) { + isClipboardApiSupported = true; + console.log('[CLIPBOARD] Async Clipboard API is supported'); + } else { + console.log('[CLIPBOARD] Clipboard API partially supported (missing read/write)'); + } + } else { + console.log('[CLIPBOARD] Clipboard API not available'); + } + + if (isFirefox) { + console.log('[CLIPBOARD] Setting up Firefox-specific clipboard handlers'); + remoteDesktopService.setOnRemoteClipboardChanged(ffOnRemoteClipboardChanged); + remoteDesktopService.setOnRemoteReceivedFormatList(ffOnRemoteReceivedFormatList); + remoteDesktopService.setOnForceClipboardUpdate(onForceClipboardUpdate); + } else if (isClipboardApiSupported) { + console.log('[CLIPBOARD] Setting up standard clipboard handlers and starting monitoring'); + remoteDesktopService.setOnRemoteClipboardChanged(onRemoteClipboardChanged); + remoteDesktopService.setOnForceClipboardUpdate(onForceClipboardUpdate); + + // Start the clipboard monitoring loop + setTimeout(onMonitorClipboard, CLIPBOARD_MONITORING_INTERVAL); + } else { + console.log('[CLIPBOARD] No clipboard support available'); + } + } + + /* Clipboard initialization END */ + + function isCopyKeyboardEvent(evt: KeyboardEvent) { + return ( + (evt.ctrlKey && evt.code === 'KeyC') || + (evt.ctrlKey && evt.code === 'KeyX') || + evt.code == 'Copy' || + evt.code == 'Cut' + ); + } + + function isPasteKeyboardEvent(evt: KeyboardEvent) { + return (evt.ctrlKey && evt.code === 'KeyV') || evt.code == 'Paste'; + } + + // This function is required to convert `ClipboardData` to an object that can be used + // with `ClipboardItem` API. + function clipboardDataToRecord(data: ClipboardData): Record { + let result = {} as Record; + + for (const item of data.items()) { + let mime = item.mimeType(); + let value = new Blob([item.value()], { type: mime }); + + result[mime] = value; + } + + return result; + } + + function clipboardDataToClipboardItemsRecord(data: ClipboardData): Record { + let result = {} as Record; + + for (const item of data.items()) { + let mime = item.mimeType(); + result[mime] = item.value(); + } + + return result; + } + + // This callback is required to send initial clipboard state if available. + function onForceClipboardUpdate() { + console.log('[CLIPBOARD] onForceClipboardUpdate called'); + // TODO(Fix): lastSentClipboardData is nullptr. + try { + if (lastSentClipboardData) { + console.log('[CLIPBOARD] Sending existing clipboard data to remote'); + remoteDesktopService.onClipboardChanged(lastSentClipboardData); + } else { + console.log('[CLIPBOARD] No existing clipboard data, sending empty clipboard'); + remoteDesktopService.onClipboardChangedEmpty(); + } + } catch (err) { + console.error('[CLIPBOARD] Failed to send initial clipboard state:', err); + } + } + + function runWhenWindowFocused(fn: () => void) { + if (document.hasFocus()) { + console.log('[CLIPBOARD] Window is focused, executing function immediately'); + fn(); + } else { + console.log('[CLIPBOARD] Window not focused, queueing function for later execution'); + runWhenFocusedQueue.push(fn); + } + } + + // This callback is required to update client clipboard state when remote side has changed. + function onRemoteClipboardChanged(data: ClipboardData) { + console.log('[CLIPBOARD] onRemoteClipboardChanged called'); + try { + const items = data.items(); + console.log(`[CLIPBOARD] Remote clipboard data received with ${items.length} items:`); + + // Log each item + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const mimeType = item.mimeType(); + const value = item.value(); + console.log(`[CLIPBOARD] Item ${i}: mime=${mimeType}, valueType=${typeof value}, valueLength=${value instanceof Uint8Array ? value.length : (typeof value === 'string' ? value.length : 'unknown')}`); + } + + const mime_formats = clipboardDataToRecord(data); + console.log('[CLIPBOARD] Converted to mime formats:', Object.keys(mime_formats)); + + const clipboard_item = new ClipboardItem(mime_formats); + console.log('[CLIPBOARD] Created ClipboardItem, scheduling write when window focused'); + + runWhenWindowFocused(() => { + console.log('[CLIPBOARD] Window is focused, writing to clipboard'); + lastReceivedClipboardData = clipboardDataToClipboardItemsRecord(data); + console.log('[CLIPBOARD] Updated lastReceivedClipboardData:', Object.keys(lastReceivedClipboardData)); + + navigator.clipboard.write([clipboard_item]).then(() => { + console.log('[CLIPBOARD] Successfully wrote to browser clipboard'); + }).catch((writeErr) => { + console.error('[CLIPBOARD] Failed to write to browser clipboard:', writeErr); + }); + }); + } catch (err) { + console.error('[CLIPBOARD] Failed to set client clipboard:', err); + } + } + + // Called periodically to monitor clipboard changes + async function onMonitorClipboard() { + try { + if (!document.hasFocus()) { + // console.log('[CLIPBOARD-MONITOR] Document not focused, skipping clipboard check'); + return; + } + + var value = await navigator.clipboard.read(); + // console.log(`[CLIPBOARD-MONITOR] Read clipboard, found ${value.length} items`); + + // Clipboard is empty + if (value.length == 0) { + // console.log('[CLIPBOARD-MONITOR] Clipboard is empty'); + return; + } + + // We only support one item at a time + var item = value[0]; + + if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) { + // Unsupported types + return; + } + + var values: Record = {}; + var sameValue = true; + + // Sadly, browsers build new `ClipboardItem` object for each `read` call, + // so we can't do reference comparison here :( + // + // For monitoring loop approach we also can't drop this logic, as it will result in + // very frequent network activity. + for (const kind of item.types) { + // Get blob + const blobIsString = kind.startsWith('text/'); + + const blob = await item.getType(kind); + const value = blobIsString ? await blob.text() : new Uint8Array(await blob.arrayBuffer()); + + const is_equal = blobIsString + ? function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { + return a === b; + } + : function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { + if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) { + return false; + } + + return ( + a != undefined && b != undefined && a.length === b.length && a.every((v, i) => v === b[i]) + ); + }; + + const previousValue = lastClientClipboardItems[kind]; + + if (!is_equal(previousValue, value)) { + // When the local clipboard updates, we need to compare it with the last data received from the server. + // If it's identical, the clipboard was updated with the server's data, so we shouldn't send this data + // to the server. + if (is_equal(lastReceivedClipboardData[kind], value)) { + lastClientClipboardItems[kind] = lastReceivedClipboardData[kind]; + } + // One of mime types has changed, we need to update the clipboard cache + else { + sameValue = false; + } + } + + values[kind] = value; + } + + // Clipboard has changed, we need to acknowledge remote side about it. + if (!sameValue) { + console.log('[CLIPBOARD-MONITOR] Local clipboard changed, sending to remote'); + console.log('[CLIPBOARD-MONITOR] New clipboard content:', Object.keys(values)); + lastClientClipboardItems = values; + + let clipboardData = new module.ClipboardData(); + + // Iterate over `Record` type + Object.entries(values).forEach(([key, value]: [string, string | Uint8Array]) => { + // skip null/undefined values + if (value == null || value == undefined) { + console.log(`[CLIPBOARD-MONITOR] Skipping null/undefined value for key: ${key}`); + return; + } + + if (key.startsWith('text/') && typeof value === 'string') { + console.log(`[CLIPBOARD-MONITOR] Adding text item: ${key}, length: ${value.length}`); + clipboardData.addText(key, value); + } else if (key.startsWith('image/') && value instanceof Uint8Array) { + console.log(`[CLIPBOARD-MONITOR] Adding binary item: ${key}, size: ${value.length} bytes`); + clipboardData.addBinary(key, value); + } else { + console.log(`[CLIPBOARD-MONITOR] Skipping unsupported item: ${key}, type: ${typeof value}`); + } + }); + + if (!clipboardData.isEmpty()) { + console.log('[CLIPBOARD-MONITOR] Sending clipboard data to remote'); + lastSentClipboardData = clipboardData; + // TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr. + await remoteDesktopService.onClipboardChanged(clipboardData); + console.log('[CLIPBOARD-MONITOR] Successfully sent clipboard data to remote'); + } else { + console.log('[CLIPBOARD-MONITOR] Clipboard data is empty, not sending to remote'); + } + } else { + // console.log('[CLIPBOARD-MONITOR] Clipboard content unchanged'); + } + } catch (err) { + if (err instanceof Error) { + const printError = + lastClipboardMonitorLoopError === null || + lastClipboardMonitorLoopError.toString() !== err.toString(); + // Prevent spamming the console with the same error + if (printError) { + console.error('Clipboard monitoring error: ' + err); + } + lastClipboardMonitorLoopError = err; + } + } finally { + if (!componentDestroyed) { + setTimeout(onMonitorClipboard, CLIPBOARD_MONITORING_INTERVAL); + } + } + } + + /* Firefox-specific BEGIN */ + + function ffOnRemoteReceivedFormatList() { + console.log('[CLIPBOARD-FF] ffOnRemoteReceivedFormatList called'); + try { + // We are ready to send delayed Ctrl+V events + console.log('[CLIPBOARD-FF] Simulating delayed key events'); + ffSimulateDelayedKeyEvents(); + } catch (err) { + console.error('[CLIPBOARD-FF] Failed to send delayed keyboard events:', err); + } + } + + // Only set variable on callback, the real clipboard update will be performed in keyboard + // callback. (User-initiated event is required for Firefox to allow clipboard write) + function ffOnRemoteClipboardChanged(data: ClipboardData) { + console.log('[CLIPBOARD-FF] ffOnRemoteClipboardChanged called'); + const items = data.items(); + console.log(`[CLIPBOARD-FF] Remote clipboard data received with ${items.length} items`); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const mimeType = item.mimeType(); + const value = item.value(); + console.log(`[CLIPBOARD-FF] Item ${i}: mime=${mimeType}, valueType=${typeof value}`); + } + + ffRemoteClipboardData = data; + console.log('[CLIPBOARD-FF] Stored remote clipboard data for later processing'); + } + + function ffWaitForRemoteClipboardDataSet() { + console.log(`[CLIPBOARD-FF] ffWaitForRemoteClipboardDataSet called, retries left: ${ffRemoteClipboardDataRetriesLeft}, data available: ${!!ffRemoteClipboardData}`); + + if (ffRemoteClipboardData) { + console.log('[CLIPBOARD-FF] Processing stored remote clipboard data'); + try { + let clipboard_data = ffRemoteClipboardData; + ffRemoteClipboardData = null; + + const items = clipboard_data.items(); + console.log(`[CLIPBOARD-FF] Processing ${items.length} items`); + + for (const item of items) { + const mimeType = item.mimeType(); + console.log(`[CLIPBOARD-FF] Processing item with mime type: ${mimeType}`); + + // Firefox only supports text/plain mime type for clipboard writes :( + if (mimeType === 'text/plain') { + const value = item.value(); + console.log(`[CLIPBOARD-FF] Found text/plain item, value type: ${typeof value}, length: ${typeof value === 'string' ? value.length : 'unknown'}`); + + if (typeof value === 'string') { + console.log('[CLIPBOARD-FF] Writing text to clipboard via writeText'); + navigator.clipboard.writeText(value).then(() => { + console.log('[CLIPBOARD-FF] Successfully wrote text to clipboard'); + }).catch((writeErr) => { + console.error('[CLIPBOARD-FF] Failed to write text to clipboard:', writeErr); + }); + } else { + console.error('[CLIPBOARD-FF] Unexpected value type for text/plain clipboard item:', typeof value); + } + + break; + } + } + } catch (err) { + console.error('[CLIPBOARD-FF] Failed to set client clipboard:', err); + } + } else if (ffRemoteClipboardDataRetriesLeft > 0) { + console.log(`[CLIPBOARD-FF] No data yet, retrying in ${FF_REMOTE_CLIPBOARD_DATA_SET_RETRY_INTERVAL}ms`); + ffRemoteClipboardDataRetriesLeft--; + setTimeout(ffWaitForRemoteClipboardDataSet, FF_REMOTE_CLIPBOARD_DATA_SET_RETRY_INTERVAL); + } else { + console.log('[CLIPBOARD-FF] No more retries left, giving up waiting for remote clipboard data'); + } + } + + function ffSimulateDelayedKeyEvents() { + console.log('[CLIPBOARD-FF] ffSimulateDelayedKeyEvents called'); + if (ffDelayedKeyboardEvents.length > 0) { + for (const evt of ffDelayedKeyboardEvents) { + // simulate consecutive key events + keyboardEvent(evt); + } + ffDelayedKeyboardEvents = []; + } + ffPostponeKeyboardEvents = false; + } + + function ffOnPasteHandler(evt: ClipboardEvent) { + console.log('[CLIPBOARD-FF] Paste event triggered'); + // We don't actually want to paste the clipboard data into the `contenteditable` div. + evt.preventDefault(); + + // `onpaste` events are handled only for Firefox, other browsers we use the clipboard API + // for reading the clipboard. + if (!isFirefox) { + console.log('[CLIPBOARD-FF] Not Firefox, ignoring paste event'); + // Prevent processing of the paste event by the browser. + return; + } + + try { + let clipboardData = new module.ClipboardData(); +// + if (evt.clipboardData == null) { + console.log('[CLIPBOARD-FF] No clipboard data in paste event'); + return; + } + + console.log(`[CLIPBOARD-FF] Processing ${evt.clipboardData.items.length} clipboard items`); + + for (var clipItem of evt.clipboardData.items) { + let mime = clipItem.type; + + if (mime.startsWith('text/')) { + clipItem.getAsString((str: string) => { + clipboardData.addText(mime, str); + + if (!clipboardData.isEmpty()) { + remoteDesktopService.onClipboardChanged(clipboardData); + } + }); + break; + } + + if (mime.startsWith('image/')) { + let file = clipItem.getAsFile(); + if (file == null) { + continue; + } + + file.arrayBuffer().then((buffer: ArrayBuffer) => { + const strict_buffer = new Uint8Array(buffer); + + clipboardData.addBinary(mime, strict_buffer); + + if (!clipboardData.isEmpty()) { + remoteDesktopService.onClipboardChanged(clipboardData); + } + }); + break; + } + } + } catch (err) { + console.error('Failed to update remote clipboard: ' + err); + } + } + + /* Firefox-specific END */ + function initListeners() { serverBridgeListeners(); userInteractionListeners(); function captureKeys(evt: KeyboardEvent) { if (capturingInputs()) { + if (ffPostponeKeyboardEvents) { + evt.preventDefault(); + ffDelayedKeyboardEvents.push(evt); + return; + } + + // For Firefox we need to make `onpaste` event still fire even if + // keyboard is being captured. Not capturing `Ctrl + V` should not create any + // side effects, therefore is safe to skip capture for it. + let isFirefoxPaste = isFirefox && isPasteKeyboardEvent(evt); + + if (isFirefoxPaste) { + ffPostponeKeyboardEvents = true; + ffDelayedKeyboardEvents = []; + ffDelayedKeyboardEvents.push(evt); + + // If during the given timeout we weren't able to finish the copy sequence, we need to + // simulate all queued keyboard events. + setTimeout(ffSimulateDelayedKeyEvents, FF_LOCAL_CLIPBOARD_COPY_TIMEOUT); + return; + } + keyboardEvent(evt); } } @@ -259,6 +740,22 @@ } function setMouseButtonState(state: MouseEvent, isDown: boolean) { + if (isFirefox) { + if (isDown && state.button == 0 && !ffCnavasFocused) { + // Do not capture first mouse down event on Firefox, as we need to transfer focus to the + // canvas first in order to receive paste events. + // wasmService.mouseButtonState(state, isDown, false); + // Focus `contenteditable` element to receive `on_paste` events + canvas.focus(); + // Finish the focus sequence on Firefox + ffCnavasFocused = true; + } else { + // This is needed to prevent visible "double click" selection on + // `texteditable` element + screenViewer.blur(); + } + } + remoteDesktopService.mouseButtonState(state, isDown, true); } @@ -276,6 +773,18 @@ } function keyboardEvent(evt: KeyboardEvent) { + const browserHasClipboardAccess = + navigator.clipboard != undefined && navigator.clipboard.writeText != undefined; + + if (isFirefox && browserHasClipboardAccess && isCopyKeyboardEvent(evt)) { + // Special processing for firefox, as the only way Firefox supports clipboard write is + // only after some user-initiated event (e.g. keyboard event). + // therefore we need to wait here for the clipboard data to be ready. + + ffRemoteClipboardDataRetriesLeft = FF_REMOTE_CLIPBOARD_DATA_SET_MAX_RETRIES; + ffWaitForRemoteClipboardDataSet(); + } + remoteDesktopService.sendKeyboardEvent(evt); // Propagate further @@ -317,28 +826,23 @@ } function focusEventHandler() { - try { - while (runWhenFocusedQueue.length() > 0) { - const fn = runWhenFocusedQueue.shift(); - fn?.(); - } - } catch (err) { - console.error('Failed to run the function queued for execution when the window received focus: ' + err); + while (runWhenFocusedQueue.length > 0) { + const fn = runWhenFocusedQueue.shift(); + fn?.(); } } onMount(async () => { - isComponentDestroyed.set(false); loggingService.verbose = verbose === 'true'; loggingService.info('Dom ready'); await initcanvas(); - clipboardService.initClipboard(); + initClipboard(); }); onDestroy(() => { window.removeEventListener('resize', resizeHandler); window.removeEventListener('focus', focusEventHandler); - isComponentDestroyed.set(true); + componentDestroyed = true; }); @@ -350,7 +854,13 @@ class:capturing-inputs={capturingInputs} style={wrapperStyle} > -
+
{ + return { + build: { + lib: { + entry: './src/main.ts', + name: 'IronRemoteDesktop', + formats: ['es'], + }, + sourcemap: mode === 'development', }, - }, - server: { - fs: { - strict: false, + server: { + fs: { + strict: false, + }, }, - }, - plugins: [ - svelte(), - wasm(), - topLevelAwait(), - dtsPlugin({ - rollupTypes: true, - }), - ], + plugins: [ + svelte(), + wasm(), + topLevelAwait(), + dtsPlugin({ + rollupTypes: true, + }), + ], + }; }); diff --git a/web-client/iron-svelte-client/src/lib/login/login.svelte b/web-client/iron-svelte-client/src/lib/login/login.svelte index 78a823edf..ce7e145d4 100644 --- a/web-client/iron-svelte-client/src/lib/login/login.svelte +++ b/web-client/iron-svelte-client/src/lib/login/login.svelte @@ -2,7 +2,13 @@ import { currentSession, setCurrentSessionActive, userInteractionService } from '../../services/session.service'; import type { IronError, UserInteraction } from '../../../static/iron-remote-desktop'; import type { Session } from '../../models/session'; - import { preConnectionBlob, displayControl, kdcProxyUrl, init } from '../../../static/iron-remote-desktop-rdp'; + import { + preConnectionBlob, + displayControl, + kdcProxyUrl, + init, + vmConnect, + } from '../../../static/iron-remote-desktop-rdp'; import { toast } from '$lib/messages/message-store'; import { showLogin } from '$lib/login/login-store'; import { onMount } from 'svelte'; @@ -16,6 +22,7 @@ let kdc_proxy_url = ''; let desktopSize = { width: 1280, height: 720 }; let pcb = ''; + let vmconnect = ''; let pop_up = false; let enable_clipboard = true; @@ -119,6 +126,10 @@ configBuilder.withExtension(preConnectionBlob(pcb)); } + if (vmconnect !== '') { + configBuilder.withExtension(vmConnect(vmconnect)); + } + if (kdc_proxy_url !== '') { configBuilder.withExtension(kdcProxyUrl(kdc_proxy_url)); } @@ -212,6 +223,10 @@
+
+ + +