-
Notifications
You must be signed in to change notification settings - Fork 301
feat(rdpdr): redirect a printer on Linux and macOS #2017
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,25 +1,38 @@ | ||
| use std::collections::HashSet; | ||
| use std::ffi::CString; | ||
| use std::io::{Read, Seek, SeekFrom, Write}; | ||
| use std::os::fd::{AsFd, AsRawFd}; | ||
| use std::os::unix::fs::MetadataExt; | ||
|
|
||
| use ironrdp_core::impl_as_any; | ||
| use ironrdp_pdu::{PduResult, encode_err}; | ||
| use ironrdp_rdpdr::RdpdrBackend; | ||
| use ironrdp_rdpdr::pdu::RdpdrPdu; | ||
| use ironrdp_rdpdr::pdu::efs::*; | ||
| use ironrdp_rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode}; | ||
| use ironrdp_rdpdr::{ | ||
| RdpdrBackend, RdpdrBackendFactory, RdpdrBackendFactoryResult, RdpdrBackendProduct, RdpdrDrive, RdpdrPrinter, | ||
| }; | ||
| use ironrdp_svc::SvcMessage; | ||
| use nix::dir::{Dir, OwningIter}; | ||
| use tracing::{debug, warn}; | ||
|
|
||
| use super::printer::{PrintTarget, PrinterSpooler, unsupported_response, write_response}; | ||
|
|
||
| #[derive(Debug, Default)] | ||
| pub struct NixRdpdrBackend { | ||
| file_id: u32, | ||
| file_base: String, | ||
| file_map: std::collections::HashMap<u32, std::fs::File>, | ||
| file_path_map: std::collections::HashMap<u32, String>, | ||
| file_dir_map: std::collections::HashMap<u32, OwningIter>, | ||
| /// Print-job handling for the virtual printer, when one is announced. | ||
| printer: Option<Printer>, | ||
| } | ||
|
|
||
| #[derive(Debug)] | ||
| struct Printer { | ||
| device_id: u32, | ||
| spooler: PrinterSpooler, | ||
| } | ||
|
|
||
| impl NixRdpdrBackend { | ||
|
|
@@ -29,14 +42,182 @@ impl NixRdpdrBackend { | |
| ..Default::default() | ||
| } | ||
| } | ||
|
|
||
| /// Accepts print jobs for the virtual printer announced as `device_id` and sends them to | ||
| /// `target`. | ||
| #[must_use] | ||
| pub fn with_printer(mut self, device_id: u32, target: PrintTarget) -> Self { | ||
| self.printer = Some(Printer { | ||
| device_id, | ||
| spooler: PrinterSpooler::new(target), | ||
| }); | ||
| self | ||
| } | ||
| } | ||
|
|
||
| impl_as_any!(NixRdpdrBackend); | ||
|
|
||
| /// Builds a [`NixRdpdrBackend`] for every RDPDR channel lifetime. | ||
| /// | ||
| /// `drives` are announced as redirected folders; `printer` announces a virtual | ||
| /// printer named after the client, whose jobs go to the given target. Drive | ||
| /// device IDs must be unique and nonzero, and the printer takes the highest ID | ||
| /// no drive uses; [`RdpdrBackendFactory::build_rdpdr_backend`] reports a | ||
| /// configuration that breaks this with a [`NixRdpdrBackendFactoryError`]. | ||
| #[derive(Debug, Clone)] | ||
| pub struct NixRdpdrBackendFactory { | ||
| file_base: String, | ||
| drives: Vec<(u32, String)>, | ||
| printer: Option<(String, PrintTarget)>, | ||
| } | ||
|
|
||
| impl NixRdpdrBackendFactory { | ||
| pub fn new(file_base: String) -> Self { | ||
| Self { | ||
| file_base, | ||
| drives: Vec::new(), | ||
| printer: None, | ||
| } | ||
| } | ||
|
|
||
| #[must_use] | ||
| pub fn with_drive(mut self, device_id: u32, name: String) -> Self { | ||
| self.drives.push((device_id, name)); | ||
| self | ||
| } | ||
|
|
||
| #[must_use] | ||
| pub fn with_printer(mut self, name: String, target: PrintTarget) -> Self { | ||
| self.printer = Some((name, target)); | ||
| self | ||
| } | ||
| } | ||
|
|
||
| /// The device ID the virtual printer takes unless a drive uses it. | ||
| const DEFAULT_PRINTER_DEVICE_ID: u32 = u32::MAX - 1; | ||
|
|
||
| impl RdpdrBackendFactory for NixRdpdrBackendFactory { | ||
| fn build_rdpdr_backend(&self) -> RdpdrBackendFactoryResult<RdpdrBackendProduct> { | ||
| let mut device_ids = HashSet::with_capacity(self.drives.len()); | ||
| for (device_id, name) in &self.drives { | ||
| if *device_id == 0 { | ||
| return Err(Box::new(NixRdpdrBackendFactoryError::ReservedDeviceId)); | ||
| } | ||
| if !is_valid_device_name(name) { | ||
| return Err(Box::new(NixRdpdrBackendFactoryError::InvalidDriveName(*device_id))); | ||
| } | ||
| if !device_ids.insert(*device_id) { | ||
| return Err(Box::new(NixRdpdrBackendFactoryError::DuplicateDeviceId(*device_id))); | ||
| } | ||
| } | ||
|
|
||
| let mut backend = NixRdpdrBackend::new(self.file_base.clone()); | ||
| let mut printer = None; | ||
| if let Some((name, target)) = &self.printer { | ||
| if !is_valid_device_name(name) { | ||
| return Err(Box::new(NixRdpdrBackendFactoryError::InvalidPrinterName)); | ||
| } | ||
| let mut device_id = DEFAULT_PRINTER_DEVICE_ID; | ||
| while device_ids.contains(&device_id) { | ||
| device_id = device_id | ||
| .checked_sub(1) | ||
| .filter(|device_id| *device_id != 0) | ||
| .ok_or(NixRdpdrBackendFactoryError::NoPrinterDeviceId)?; | ||
| } | ||
| backend = backend.with_printer(device_id, target.clone()); | ||
| printer = Some(RdpdrPrinter::new( | ||
| device_id, | ||
| name.clone(), | ||
| DEFAULT_PRINTER_DRIVER_NAME.to_owned(), | ||
| )); | ||
| } | ||
|
|
||
| let drives = self | ||
| .drives | ||
| .iter() | ||
| .map(|(id, name)| RdpdrDrive::new(*id, name.clone())) | ||
| .collect(); | ||
| let mut product = RdpdrBackendProduct::new(Box::new(backend), drives); | ||
| if let Some(printer) = printer { | ||
| product = product.with_printer(printer); | ||
| } | ||
| Ok(product) | ||
| } | ||
| } | ||
|
Comment on lines
+60
to
+146
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [skeptical] NixRdpdrBackendFactory performs no device-ID validation — medium 🟠 — with_drive accepts duplicate ids, 0, and ids colliding with the hardcoded PRINTER_DEVICE_ID. Duplicate announced ids make Rdpdr::announce_devices fail mid-connection ('device was announced more than once before server acknowledgement'), aborting the channel for what is a configuration error. The Windows sibling factory rejects duplicate ids up front and walks to a collision-free printer id; direct consumers of this exported factory bypass the client builder's collision checks.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 23b3805. |
||
|
|
||
| /// Device names are announced as NUL-terminated strings, so an empty name or one with an embedded | ||
| /// NUL would reach the server empty or cut short. | ||
| fn is_valid_device_name(name: &str) -> bool { | ||
| !name.is_empty() && !name.contains('\0') | ||
| } | ||
|
|
||
| /// Invalid [`NixRdpdrBackendFactory`] configuration. | ||
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
| pub enum NixRdpdrBackendFactoryError { | ||
| /// A drive used device ID 0. | ||
| ReservedDeviceId, | ||
| /// More than one drive used the same device ID. | ||
| DuplicateDeviceId(u32), | ||
| /// The drives use every device ID the printer could take. | ||
| NoPrinterDeviceId, | ||
| /// The drive with this device ID has an empty name or one with an embedded NUL. | ||
| InvalidDriveName(u32), | ||
| /// The printer has an empty name or one with an embedded NUL. | ||
| InvalidPrinterName, | ||
| } | ||
|
|
||
| impl core::fmt::Display for NixRdpdrBackendFactoryError { | ||
| fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { | ||
| match self { | ||
| Self::ReservedDeviceId => f.write_str("RDPDR device ID 0 is reserved"), | ||
| Self::DuplicateDeviceId(device_id) => write!(f, "duplicate RDPDR device ID {device_id}"), | ||
| Self::NoPrinterDeviceId => f.write_str("no RDPDR device ID is available for the printer"), | ||
| Self::InvalidDriveName(device_id) => { | ||
| write!( | ||
| f, | ||
| "RDPDR drive {device_id} has an empty name or one with an embedded NUL" | ||
| ) | ||
| } | ||
| Self::InvalidPrinterName => f.write_str("the RDPDR printer has an empty name or one with an embedded NUL"), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl core::error::Error for NixRdpdrBackendFactoryError {} | ||
|
|
||
| impl RdpdrBackend for NixRdpdrBackend { | ||
| fn handle_server_device_announce_response(&mut self, _pdu: ServerDeviceAnnounceResponse) -> PduResult<()> { | ||
| fn reset(&mut self) -> PduResult<()> { | ||
| if let Some(printer) = self.printer.as_mut() { | ||
| printer.spooler.reset(); | ||
| } | ||
| Ok(()) | ||
| } | ||
| fn handle_server_device_announce_response(&mut self, pdu: ServerDeviceAnnounceResponse) -> PduResult<()> { | ||
| if self | ||
| .printer | ||
| .as_ref() | ||
| .is_some_and(|printer| printer.device_id == pdu.device_id) | ||
| { | ||
| if pdu.result_code == NtStatus::SUCCESS { | ||
| tracing::info!("The server accepted the redirected printer"); | ||
| } else { | ||
| warn!(?pdu.result_code, "The server rejected the redirected printer"); | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
| fn handle_printer_io_request(&mut self, req: PrinterIoRequest) -> PduResult<Vec<SvcMessage>> { | ||
| match self.printer.as_mut() { | ||
| Some(printer) => printer.spooler.handle(req), | ||
| None => Ok(vec![SvcMessage::from(unsupported_response(req))]), | ||
| } | ||
| } | ||
|
Comment on lines
+209
to
+214
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [protocol] No-printer fallback completes printer IRPs with a close-response PDU — low 🟡 — When no spooler is configured, every printer IRP — including IRP_MJ_WRITE — is answered with a DeviceCloseResponse, which is not the DR_PRN_WRITE_RSP shape a server expects for a write completion, so the Length parse fails or truncates. Unreachable through NixRdpdrBackendFactory (which configures the product printer and the backend spooler together) but visible when integrations assemble the backend manually; it also mirrors the trait's default handler.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 23b3805. Without a printer, a create, write or close is answered with its own response type and |
||
| fn reject_printer_write(&mut self, req: DeviceIoRequest) -> PduResult<Vec<SvcMessage>> { | ||
| match self.printer.as_mut() { | ||
| Some(printer) => printer.spooler.reject_write(req), | ||
| None => Ok(vec![SvcMessage::from(write_response(req, 0, NtStatus::NOT_SUPPORTED))]), | ||
| } | ||
| } | ||
| fn handle_scard_call( | ||
| &mut self, | ||
| _req: DeviceControlRequest<ScardIoCtlCode>, | ||
|
|
@@ -801,3 +982,60 @@ pub(crate) fn process_dependent_file( | |
| Some(file) => fx(file, request), | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| fn build_error(factory: &NixRdpdrBackendFactory) -> NixRdpdrBackendFactoryError { | ||
| let Err(error) = factory.build_rdpdr_backend() else { | ||
| panic!("the factory accepted an invalid configuration"); | ||
| }; | ||
| *error | ||
| .downcast_ref::<NixRdpdrBackendFactoryError>() | ||
| .expect("a factory configuration error") | ||
| } | ||
|
|
||
| #[test] | ||
| fn the_factory_rejects_reserved_and_duplicate_drive_ids() { | ||
| let factory = NixRdpdrBackendFactory::new("/tmp".to_owned()); | ||
| assert_eq!( | ||
| build_error(&factory.clone().with_drive(0, "zero".to_owned())), | ||
| NixRdpdrBackendFactoryError::ReservedDeviceId | ||
| ); | ||
| assert_eq!( | ||
| build_error(&factory.with_drive(1, "a".to_owned()).with_drive(1, "b".to_owned())), | ||
| NixRdpdrBackendFactoryError::DuplicateDeviceId(1) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn the_factory_rejects_empty_and_nul_names() { | ||
| let factory = NixRdpdrBackendFactory::new("/tmp".to_owned()); | ||
| assert_eq!( | ||
| build_error(&factory.clone().with_drive(1, String::new())), | ||
| NixRdpdrBackendFactoryError::InvalidDriveName(1) | ||
| ); | ||
| assert_eq!( | ||
| build_error(&factory.clone().with_drive(2, "home\0x".to_owned())), | ||
| NixRdpdrBackendFactoryError::InvalidDriveName(2) | ||
| ); | ||
| assert_eq!( | ||
| build_error(&factory.with_printer(String::new(), PrintTarget::DefaultPrinter)), | ||
| NixRdpdrBackendFactoryError::InvalidPrinterName | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn the_printer_takes_an_id_no_drive_uses() { | ||
| let product = NixRdpdrBackendFactory::new("/tmp".to_owned()) | ||
| .with_drive(DEFAULT_PRINTER_DEVICE_ID, "taken".to_owned()) | ||
| .with_printer("printer".to_owned(), PrintTarget::DefaultPrinter) | ||
| .build_rdpdr_backend() | ||
| .expect("valid configuration"); | ||
| assert_eq!( | ||
| product.printer().expect("printer").device_id(), | ||
| DEFAULT_PRINTER_DEVICE_ID - 1 | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| pub mod backend; | ||
| pub mod printer; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[skeptical] Factory accepts drive/printer names the Windows factory and dynamic-drive path reject — low 🟡 — with_drive and with_printer store names verbatim. Drive names are encoded as NUL-terminated UTF-16 with a truncated or DRIVE-fallback PreferredDosName, so an empty name announces an empty device and an embedded NUL truncates it server-side; the printer PrintName is NUL-terminated too. WindowsRdpdrBackendFactory rejects empty/NUL names and Rdpdr::add_dynamic_drive errors on both, leaving this new public factory as the only configuration path that lets a malformed announce through.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 1625d3a.
build_rdpdr_backendnow returnsInvalidDriveNameorInvalidPrinterNamefor an empty name or one with an embedded NUL, like the Windows factory andRdpdr::add_dynamic_drive. Covered bythe_factory_rejects_empty_and_nul_names.