diff --git a/Cargo.lock b/Cargo.lock index 662b8941a2..9cccb13816 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3313,6 +3313,7 @@ dependencies = [ "ironrdp-nscodec", "ironrdp-pdu", "ironrdp-rdpdr", + "ironrdp-rdpeai", "ironrdp-rdpei", "ironrdp-rdpeusb", "ironrdp-rdpsnd", diff --git a/crates/ironrdp-rdpeai/src/server.rs b/crates/ironrdp-rdpeai/src/server.rs index 11f250cfbc..c63c6cc499 100644 --- a/crates/ironrdp-rdpeai/src/server.rs +++ b/crates/ironrdp-rdpeai/src/server.rs @@ -9,6 +9,29 @@ use tracing::{debug, trace, warn}; use crate::CHANNEL_NAME; use crate::pdu::{DataPdu, FormatChangePdu, FormatsPdu, OpenPdu, OpenReplyPdu, RdpeaiPdu, Version, VersionPdu}; +pub trait RdpeaiError: core::error::Error + Send + Sync + 'static {} + +impl RdpeaiError for T where T: core::error::Error + Send + Sync + 'static {} + +/// Message sent by the embedding application's event loop to drive [`RdpeaiServer`] from +/// outside the DVC message-processing path — e.g. once a consumer decides it wants to start +/// recording, or wants to switch formats mid-session. +#[derive(Debug)] +pub enum RdpeaiServerMessage { + /// Request the client start recording. See [`RdpeaiServer::open`]. + Open { + frames_per_packet: u32, + initial_format: u32, + capture_format: AudioFormat, + }, + /// Request the client switch to a different negotiated format. See + /// [`RdpeaiServer::change_format`]. + ChangeFormat { new_format: u32 }, + /// Failure received from the embedding application's own capture/consumer pipeline. + /// Implementations should log/display this error. + Error(Box), +} + /// Handler for the server side of the Audio Input Redirection Virtual Channel (`AUDIO_INPUT`). /// /// Implementations supply the list of audio formats the server offers and receive the @@ -31,7 +54,9 @@ pub trait RdpeaiServerBackend: Send { } /// Called when the client answers an Open PDU (MS-RDPEAI 3.3.5.1.8). `result` is the - /// client's raw HRESULT; `result == 0` ([`OpenReplyPdu::S_OK`]) means capture started. + /// client's raw HRESULT; per MS-RDPEAI 3.3.5.1.8 an HRESULT is an error only when its + /// sign bit is set, so `result >= 0` (not just [`OpenReplyPdu::S_OK`]) means capture + /// started. fn on_open_reply(&mut self, result: i32) { let _ = result; } @@ -323,24 +348,45 @@ impl RdpeaiServer { } fn handle_open_reply(&mut self, pdu: OpenReplyPdu) -> PduResult> { - if self.state != State::AwaitingOpenReply { - warn!(?self.state, "Ignoring out-of-sequence AUDIO_INPUT OpenReply PDU"); - return Ok(Vec::new()); - } - - self.backend.on_open_reply(pdu.result); - if pdu.result == OpenReplyPdu::S_OK { - self.state = State::Opened; - debug!("AUDIO_INPUT capture opened"); - } else { - // MS-RDPEAI 3.3.5.1.8: on failure the server MAY send another Open PDU; leave that - // to the caller by returning to Ready rather than retrying automatically. - self.current_format = None; - self.state = State::Ready; - warn!( - result = pdu.result, - "AUDIO_INPUT client failed to open its capture device" - ); + match self.state { + State::AwaitingOpenReply => { + self.backend.on_open_reply(pdu.result); + // MS-RDPEAI 3.3.5.1.8: an HRESULT is an error only when its sign bit is set, + // so any non-negative result (not just S_OK) is success. + if pdu.result >= 0 { + self.state = State::Opened; + debug!("AUDIO_INPUT capture opened"); + } else { + // MS-RDPEAI 3.3.5.1.8: on failure the server MAY send another Open PDU; + // leave that to the caller by returning to Ready rather than retrying + // automatically. + self.current_format = None; + self.state = State::Ready; + warn!( + result = pdu.result, + "AUDIO_INPUT client failed to open its capture device" + ); + } + } + State::AwaitingFormatConfirm => { + // A client rejecting Open before confirming the initial format (e.g. + // initialFormat out of range, or FramesPerPacket rejected) skips the + // FormatChange confirm and replies with OpenReply failure directly. + // 3.3.5.1.8 only conditions the server's reaction on the Result field, not on + // a preceding FormatChange, so accept it here too rather than leaving the + // channel wedged in AwaitingFormatConfirm with no path back to Ready. + self.backend.on_open_reply(pdu.result); + self.pending_open_format = None; + self.current_format = None; + self.state = State::Ready; + warn!( + result = pdu.result, + "AUDIO_INPUT client rejected Open before confirming the initial format" + ); + } + _ => { + warn!(?self.state, "Ignoring out-of-sequence AUDIO_INPUT OpenReply PDU"); + } } Ok(Vec::new()) } diff --git a/crates/ironrdp-server/Cargo.toml b/crates/ironrdp-server/Cargo.toml index ea9c5b787d..b35b97e14b 100644 --- a/crates/ironrdp-server/Cargo.toml +++ b/crates/ironrdp-server/Cargo.toml @@ -57,6 +57,7 @@ ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.7" } # public ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.9" } # public ironrdp-rdpei = { path = "../ironrdp-rdpei", version = "0.1" } # public +ironrdp-rdpeai = { path = "../ironrdp-rdpeai", version = "0.1" } # public ironrdp-rdpeusb = { path = "../ironrdp-rdpeusb", version = "0.1", optional = true } ironrdp-usb = { path = "../ironrdp-usb", version = "0.1", optional = true } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-server/src/builder.rs b/crates/ironrdp-server/src/builder.rs index 963f94d47c..e54e53a1fc 100644 --- a/crates/ironrdp-server/src/builder.rs +++ b/crates/ironrdp-server/src/builder.rs @@ -19,7 +19,10 @@ use super::server::{ use crate::error::ServerResult; #[cfg(feature = "usb")] use crate::urbdrc::DeviceFactory; -use crate::{DisplayUpdate, RdpServerDisplayUpdates, RdpdrServerFactory, RdpeiServerFactory, SoundServerFactory}; +use crate::{ + DisplayUpdate, RdpServerDisplayUpdates, RdpdrServerFactory, RdpeaiServerFactory, RdpeiServerFactory, + SoundServerFactory, +}; pub struct WantsAddr {} pub struct WantsSecurity { @@ -46,6 +49,7 @@ pub struct BuilderDone { sound_factory: Option>, rdpei_factory: Option>, rdpdr_factory: Option>, + rdpeai_factory: Option>, connection_handler: Option>, credential_validator: Option>, #[cfg(feature = "egfx")] @@ -156,6 +160,7 @@ impl RdpServerBuilder { cliprdr_factory: None, rdpei_factory: None, rdpdr_factory: None, + rdpeai_factory: None, connection_handler: None, credential_validator: None, codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), @@ -189,6 +194,7 @@ impl RdpServerBuilder { cliprdr_factory: None, rdpei_factory: None, rdpdr_factory: None, + rdpeai_factory: None, connection_handler: None, credential_validator: None, codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), @@ -239,6 +245,12 @@ impl RdpServerBuilder { self } + /// Configure MS-RDPEAI (audio input / microphone redirection over a dynamic channel). + pub fn with_rdpeai_factory(mut self, rdpeai_factory: Option>) -> Self { + self.state.rdpeai_factory = rdpeai_factory; + self + } + /// Configure EGFX (Graphics Pipeline Extension) for H.264 video streaming. #[cfg(feature = "egfx")] pub fn with_gfx_factory(mut self, gfx_factory: Option>) -> Self { @@ -471,6 +483,7 @@ impl RdpServerBuilder { self.state.cliprdr_factory, self.state.rdpei_factory, self.state.rdpdr_factory, + self.state.rdpeai_factory, self.state.connection_handler, #[cfg(feature = "egfx")] self.state.gfx_factory, diff --git a/crates/ironrdp-server/src/lib.rs b/crates/ironrdp-server/src/lib.rs index 289462497b..94544265cf 100644 --- a/crates/ironrdp-server/src/lib.rs +++ b/crates/ironrdp-server/src/lib.rs @@ -19,6 +19,7 @@ pub mod heartbeat; #[cfg(feature = "helper")] mod helper; mod rdpdr; +mod rdpeai; mod rdpei; mod server; mod sound; @@ -43,6 +44,7 @@ pub use ironrdp_pdu::rdp::session_info::ServerAutoReconnect; #[cfg(feature = "usb")] pub use ironrdp_rdpeusb::io::{CompletionData, DeviceAnnounce, DeviceText, InternalIoControlPacket}; pub use rdpdr::{NoopRdpdrServerBackend, RdpdrServerBackend, RdpdrServerFactory, RdpdrServerMessage}; +pub use rdpeai::{NoopRdpeaiServerBackend, RdpeaiServerBackend, RdpeaiServerFactory, RdpeaiServerMessage}; pub use rdpei::{ CsReadyFlags, CsReadyPdu, DismissHoveringTouchContactPdu, PenContact, PenContactDataFlags, PenContactFields, PenContactFlags, PenEventPdu, PenFlags, PenFrame, RdpInputProtocolVersion, RdpeiHandler, RdpeiServer, diff --git a/crates/ironrdp-server/src/rdpeai.rs b/crates/ironrdp-server/src/rdpeai.rs new file mode 100644 index 0000000000..9bd7ed413b --- /dev/null +++ b/crates/ironrdp-server/src/rdpeai.rs @@ -0,0 +1,7 @@ +pub use ironrdp_rdpeai::server::{NoopRdpeaiServerBackend, RdpeaiServerBackend, RdpeaiServerMessage}; + +use crate::ServerEventSender; + +pub trait RdpeaiServerFactory: ServerEventSender { + fn build_backend(&self) -> Box; +} diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 5c9151b443..f33f99bae3 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -35,11 +35,13 @@ use ironrdp_pdu::rdp::server_error_info::{ErrorInfo, ProtocolIndependentCode, Se use ironrdp_pdu::x224::X224; use ironrdp_pdu::{Action, PduResult, decode_err, mcs, nego, rdp}; use ironrdp_rdpdr as rdpdr; +use ironrdp_rdpeai as rdpeai; use ironrdp_rdpsnd as rdpsnd; use ironrdp_svc::{ChannelFlags, StaticChannelId, StaticChannelSet, SvcProcessor, server_encode_svc_messages}; use ironrdp_tokio::{FramedRead, FramedWrite, TokioFramed, split_tokio_framed, unsplit_tokio_framed}; use rand::RngCore as _; use rdpdr::server::{RdpdrServer, RdpdrServerMessage}; +use rdpeai::server::{RdpeaiServer, RdpeaiServerMessage}; use rdpsnd::server::{RdpsndServer, RdpsndServerMessage}; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; use tokio::net::{TcpSocket, TcpStream}; @@ -58,6 +60,7 @@ use crate::error::{ServerError, ServerErrorExt as _, ServerErrorKind, ServerResu use crate::gfx::{EgfxServerMessage, GfxServerFactory}; use crate::handler::RdpServerInputHandler; use crate::heartbeat::HeartbeatConfig; +use crate::rdpeai::RdpeaiServerFactory; use crate::rdpei::RdpeiServerFactory; #[cfg(feature = "usb")] use crate::urbdrc::{ @@ -670,6 +673,7 @@ pub struct RdpServer { cliprdr_factory: Option>, rdpei_factory: Option>, rdpdr_factory: Option>, + rdpeai_factory: Option>, echo_handle: EchoServerHandle, #[cfg(feature = "egfx")] gfx_factory: Option>, @@ -838,6 +842,7 @@ pub enum ServerEvent { Clipboard(ClipboardMessage), Rdpsnd(RdpsndServerMessage), Rdpdr(RdpdrServerMessage), + Rdpeai(RdpeaiServerMessage), Echo(EchoServerMessage), SetCredentials(Credentials), /// Replace or clear the Server Auto-Reconnect Cookie. @@ -869,6 +874,7 @@ impl fmt::Debug for ServerEvent { Self::Clipboard(..) => f.write_str("Clipboard(..)"), Self::Rdpsnd(..) => f.write_str("Rdpsnd(..)"), Self::Rdpdr(..) => f.write_str("Rdpdr(..)"), + Self::Rdpeai(..) => f.write_str("Rdpeai(..)"), Self::Echo(..) => f.write_str("Echo(..)"), Self::SetCredentials(..) => f.write_str("SetCredentials(..)"), Self::SetAutoReconnectCookie(Some(..)) => f.write_str("SetAutoReconnectCookie(Some(..))"), @@ -1347,6 +1353,11 @@ impl RdpServer { clippy::too_many_arguments, reason = "called via the builder; positional parameters are an internal detail" )] + #[expect( + clippy::similar_names, + reason = "rdpei (MS-RDPEI touch/pen) and rdpeai (MS-RDPEAI audio input) are distinct protocols \ + whose names happen to be textually close; renaming either would be less accurate" + )] pub(crate) fn new( opts: RdpServerOptions, handler: Box, @@ -1356,6 +1367,7 @@ impl RdpServer { mut cliprdr_factory: Option>, mut rdpei_factory: Option>, mut rdpdr_factory: Option>, + mut rdpeai_factory: Option>, connection_handler: Option>, #[cfg(feature = "egfx")] mut gfx_factory: Option>, display_suppressed: Option>, @@ -1377,6 +1389,9 @@ impl RdpServer { if let Some(rdpdr) = rdpdr_factory.as_mut() { rdpdr.set_sender(ev_sender.clone()); } + if let Some(rdpeai) = rdpeai_factory.as_mut() { + rdpeai.set_sender(ev_sender.clone()); + } #[cfg(feature = "egfx")] if let Some(gfx) = gfx_factory.as_mut() { gfx.set_sender(ev_sender.clone()); @@ -1392,6 +1407,7 @@ impl RdpServer { cliprdr_factory, rdpei_factory, rdpdr_factory, + rdpeai_factory, echo_handle: EchoServerHandle::new(ev_sender.clone()), #[cfg(feature = "egfx")] gfx_factory, @@ -1853,6 +1869,13 @@ impl RdpServer { dvc }; + let dvc = if let Some(factory) = self.rdpeai_factory.as_deref() { + let backend = factory.build_backend(); + dvc.with_dynamic_channel(RdpeaiServer::new(backend)) + } else { + dvc + }; + #[cfg(feature = "egfx")] let dvc = { let mut dvc = dvc; @@ -2876,6 +2899,62 @@ impl RdpServer { .await .map_err(|e| ServerError::io("write_all", e))?; } + ServerEvent::Rdpeai(msg) => { + let Some(drdynvc) = self.get_svc_processor::() else { + warn!("No drdynvc channel, dropping AUDIO_INPUT event"); + continue; + }; + let Some(channel_id) = drdynvc.get_channel_id_by_type::() else { + warn!("No AUDIO_INPUT dynamic channel, dropping event"); + continue; + }; + // dvc_by_id_mut already returns None for a channel that isn't yet + // Opened, and that case is handled just below; a separate + // is_channel_opened check here would only duplicate it. + let Some(mut rdpeai) = drdynvc.dvc_by_id_mut::(channel_id) else { + warn!("AUDIO_INPUT channel not opened or not found by id, dropping event"); + continue; + }; + let result = match msg { + RdpeaiServerMessage::Open { + frames_per_packet, + initial_format, + capture_format, + } => rdpeai + .processor_mut() + .open(frames_per_packet, initial_format, capture_format), + RdpeaiServerMessage::ChangeFormat { new_format } => { + rdpeai.processor_mut().change_format(new_format) + } + RdpeaiServerMessage::Error(error) => { + error!(?error, "Handling AUDIO_INPUT event"); + continue; + } + }; + // open()/change_format() reject calls that race the channel's current + // state (e.g. Open before negotiation finishes); that is an expected, + // recoverable condition per their own doc comments, not a connection + // fault, so drop-and-warn like every other unavailability in this arm + // rather than tearing down the session. + let msgs = match result { + Ok(msgs) => msgs, + Err(error) => { + warn!(%error, "AUDIO_INPUT event rejected by current channel state, dropping"); + continue; + } + }; + let dvc_messages = dvc::encode_dvc_messages(channel_id, msgs, ChannelFlags::SHOW_PROTOCOL) + .map_err(ServerError::encode)?; + let drdynvc_channel_id = self + .get_channel_id_by_type::() + .ok_or_else(|| ServerError::channel("DRDYNVC channel not found"))?; + let data = server_encode_svc_messages(dvc_messages, drdynvc_channel_id, user_channel_id) + .map_err(ServerError::encode)?; + writer + .write_all(&data) + .await + .map_err(|e| ServerError::io("write_all", e))?; + } ServerEvent::Clipboard(c) => { let Some(cliprdr) = self.get_svc_processor::() else { warn!("No clipboard channel, dropping event"); diff --git a/crates/ironrdp-testsuite-core/tests/rdpeai/server.rs b/crates/ironrdp-testsuite-core/tests/rdpeai/server.rs index 0b2f4bff36..08a50a247b 100644 --- a/crates/ironrdp-testsuite-core/tests/rdpeai/server.rs +++ b/crates/ironrdp-testsuite-core/tests/rdpeai/server.rs @@ -226,6 +226,60 @@ fn open_reply_failure_returns_to_ready_and_allows_retry() { assert!(server.open(320, 0, fmt).is_ok()); } +#[test] +fn open_reply_failure_before_format_change_confirm_returns_to_ready() { + // The client rejects Open (e.g. initialFormat out of range, or FramesPerPacket rejected) + // by replying with OpenReply failure directly, without first confirming the initial + // format via FormatChange (ironrdp-rdpeai's own client does exactly this in + // handle_open). The server must accept that reply from AwaitingFormatConfirm, not just + // AwaitingOpenReply, or the channel is stuck forever with no path back to Ready. + let fmt = pcm_format(1, 16000, 16); + let backend = MockBackend::new(vec![fmt.clone()]); + let mut server = RdpeaiServer::new(Box::new(backend.clone())); + + server.start(1).expect("start"); + process_encoded(&mut server, 1, RdpeaiPdu::Version(VersionPdu::new(Version::V1))); + process_encoded( + &mut server, + 1, + RdpeaiPdu::Formats(FormatsPdu::client(vec![fmt.clone()])), + ); + server.open(320, 0, fmt.clone()).expect("open"); + + // Skip the FormatChange confirm entirely; reply as if Open was rejected. + let reply_out = process_encoded( + &mut server, + 1, + RdpeaiPdu::OpenReply(OpenReplyPdu { + result: OpenReplyPdu::E_FAIL, + }), + ); + assert!(reply_out.is_empty()); + + assert_eq!(backend.state.lock().unwrap().open_replies, vec![OpenReplyPdu::E_FAIL]); + assert_eq!(server.current_format(), None); + assert!(server.open(320, 0, fmt).is_ok()); +} + +#[test] +fn open_reply_with_a_non_s_ok_non_negative_result_is_treated_as_success() { + // MS-RDPEAI 3.3.5.1.8: an HRESULT is an error only when its sign bit is set, so any + // non-negative result is success, not just S_OK (0). S_FALSE (1) is one such code. + const S_FALSE: i32 = 1; + let fmt = pcm_format(1, 16000, 16); + let backend = MockBackend::new(vec![fmt.clone()]); + let mut server = RdpeaiServer::new(Box::new(backend.clone())); + + negotiate_and_open(&mut server, vec![fmt.clone()], fmt.clone(), S_FALSE); + + assert_eq!(backend.state.lock().unwrap().open_replies, vec![S_FALSE]); + assert_eq!(server.current_format(), Some(&fmt)); + + let data_out = process_encoded(&mut server, 1, RdpeaiPdu::Data(DataPdu::new(vec![5, 6, 7]))); + assert!(data_out.is_empty()); + assert_eq!(backend.state.lock().unwrap().audio, vec![(fmt, vec![5, 6, 7])]); +} + #[test] fn change_format_round_trips_and_notifies_backend() { let fmt_a = pcm_format(1, 16000, 16);