diff --git a/docs/overview.md b/docs/overview.md index 1c7a471..22cc17f 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -148,7 +148,7 @@ Use Nexus Terminal when you need to: - Scale-to-fit or 1:1, a fixed resolution or one that follows the tab (Display Control). - Keyboard (scancodes for keys and shortcuts, Unicode for text), mouse and wheel; Ctrl+Alt+Del; text clipboard sharing in both directions. - Password storage modes and domain logon. -- Remote audio playback, and a shared folder redirected as a drive (Linux and macOS), confined to that folder. +- Remote audio playback, a printer whose jobs are saved as PostScript files, and a shared folder redirected as a drive (Linux and macOS), confined to that folder. ### VNC capabilities diff --git a/docs/user-manual.md b/docs/user-manual.md index 436a8f7..cd2f8e1 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -167,7 +167,7 @@ Use SSH tunnelling for VNC whenever possible. If a VNC profile has no tunnel, Ne - **RDP**: domain (optional), password storage mode, **Certificate Policy** and **Allow servers without NLA**. The username, host and port (3389) are the profile's own. - **Display**: **Scale to Fit**, a fixed **Width** and **Height** (0 × 0 fits the tab) and **Resize With Tab**. - **Proxy**: **SOCKS5** or **HTTP CONNECT**, with an optional username and password. -- **Devices**: **Play Remote Audio**, and on Linux and macOS a **Shared Folder** that appears on the remote desktop as a drive named Nexus. The server can read and change everything in that folder and nothing outside it: paths that try to leave it (`..`, or symlinks pointing elsewhere) are refused. +- **Devices**: **Play Remote Audio**; **Redirect Printer**, which adds a *Nexus Terminal* printer on the remote desktop whose jobs are saved as PostScript files in your Downloads folder (up to 256 MiB per job); and on Linux and macOS a **Shared Folder** that appears on the remote desktop as a drive named Nexus. The server can read and change everything in that folder and nothing outside it: paths that try to leave it (`..`, or symlinks pointing elsewhere) are refused. - **SSH Tunnel**: carry RDP through SSH, as for VNC. Use either a proxy or a tunnel, not both. Nexus Terminal logs on with Network Level Authentication (CredSSP, NTLM), so your password is checked before a session is set up. Servers without NLA, such as xrdp, need **Allow servers without NLA**; the password is then passed to the server's own logon. Kerberos and smart cards are not supported. diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index a2b4a73..09f9c4a 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -485,6 +485,7 @@ impl App { } } Event::Warning { message, .. } => self.s.error(message), + Event::Info { message, .. } => self.s.success(message), Event::Irc { session, event } => { if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) { v.on_event(event); diff --git a/rust/app/src/profile_editor.rs b/rust/app/src/profile_editor.rs index d22c51d..09ec01e 100644 --- a/rust/app/src/profile_editor.rs +++ b/rust/app/src/profile_editor.rs @@ -702,6 +702,7 @@ impl ProfileEditor { }); section(ui, "Devices", |ui| { check(ui, "Play Remote Audio", &mut c.audio, ""); + check(ui, "Redirect Printer", &mut c.redirect_printer, "Adds a \"Nexus Terminal\" printer; jobs are saved as PostScript files in Downloads"); if cfg!(unix) { label(ui, "Shared Folder"); ui.horizontal(|ui| { diff --git a/rust/core/src/clients/mod.rs b/rust/core/src/clients/mod.rs index 8b025e5..6caa286 100644 --- a/rust/core/src/clients/mod.rs +++ b/rust/core/src/clients/mod.rs @@ -40,8 +40,7 @@ impl Callbacks { pub mod proxy; pub mod raw; pub mod rdp; -#[cfg(unix)] -pub mod rdp_drive; +pub mod rdp_devices; pub mod serial; pub mod telnet; pub mod vnc; diff --git a/rust/core/src/clients/rdp.rs b/rust/core/src/clients/rdp.rs index f3451b6..7465826 100644 --- a/rust/core/src/clients/rdp.rs +++ b/rust/core/src/clients/rdp.rs @@ -52,6 +52,8 @@ pub struct Config { pub audio: bool, /// A local folder to share as a drive (Unix only), and the name it appears under. pub shared_folder: Option<(std::path::PathBuf, String)>, + /// Offer a printer; jobs are saved as PostScript in this folder. + pub printer: Option<(std::path::PathBuf, super::rdp_devices::PrintedFn)>, pub dial: Dial, pub check_cert: CertCheck, pub connect_timeout: Duration, @@ -196,10 +198,25 @@ impl Client { if self.cfg.audio { connector.attach_static_channel(ironrdp::rdpsnd::client::Rdpsnd::new(Box::new(ironrdp_rdpsnd_native::cpal::RdpsndBackend::new()))); } - #[cfg(unix)] - if let Some((folder, name)) = &self.cfg.shared_folder { - let drive = super::rdp_drive::ConfinedDrive::new(folder).map_err(|e| format!("shared folder {}: {e}", folder.display()))?; - connector.attach_static_channel(ironrdp::rdpdr::Rdpdr::new(Box::new(drive), "Nexus".into()).with_drives(Some(vec![(1, name.clone())]))); + if self.cfg.shared_folder.is_some() || self.cfg.printer.is_some() { + let mut devices = super::rdp_devices::Devices::default(); + let mut drives = vec![]; + #[cfg(unix)] + if let Some((folder, name)) = &self.cfg.shared_folder { + devices = devices.with_drive(folder).map_err(|e| format!("shared folder {}: {e}", folder.display()))?; + drives.push((1, name.clone())); + } + if let Some((dir, on_printed)) = &self.cfg.printer { + devices = devices.with_printer(dir.clone(), on_printed.clone()); + } + let mut rdpdr = ironrdp::rdpdr::Rdpdr::new(Box::new(devices), "Nexus".into()); + if !drives.is_empty() { + rdpdr = rdpdr.with_drives(Some(drives)); + } + if self.cfg.printer.is_some() { + rdpdr = rdpdr.with_printer(2, "Nexus Terminal".into()); + } + connector.attach_static_channel(rdpdr); } let (clip_tx, clip_rx) = mpsc::channel(); connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard { @@ -584,6 +601,7 @@ mod tests { allow_tls_only: true, audio: std::env::var("NEXUS_RDP_AUDIO").is_ok(), shared_folder: std::env::var("NEXUS_RDP_SHARE").ok().map(|p| (p.into(), "nexus".into())), + printer: std::env::var("NEXUS_RDP_PRINT").ok().map(|p| (p.into(), Arc::new(|f: std::path::PathBuf| eprintln!("printed {}", f.display())) as _)), dial: Arc::new(move || Ok(TcpStream::connect((h2.as_str(), port))?)), check_cert: Arc::new(move |fp| { *sc.lock() = fp.to_string(); diff --git a/rust/core/src/clients/rdp_devices.rs b/rust/core/src/clients/rdp_devices.rs new file mode 100644 index 0000000..bc2655d --- /dev/null +++ b/rust/core/src/clients/rdp_devices.rs @@ -0,0 +1,307 @@ +//! RDP device redirection: a shared folder as a drive, and a virtual printer whose jobs are saved +//! as PostScript files. +//! +//! The drive is confined to one folder. IronRDP's native backend joins the server's paths onto +//! the share without checking them, so a hostile server could reach `\..\..\.ssh\id_ed25519`. +//! Every path the server sends is checked here first: no `..` components, and after resolving +//! symlinks it must stay inside the share. Drives are Unix only (the native backend has no Windows +//! implementation); the printer works everywhere. + +use ironrdp::core::impl_as_any; +use ironrdp::pdu::PduResult; +use ironrdp::rdpdr::RdpdrBackend; +use ironrdp::rdpdr::pdu::RdpdrPdu; +use ironrdp::rdpdr::pdu::efs::{ + ClientDriveQueryDirectoryResponse, ClientDriveSetInformationResponse, DeviceCloseResponse, DeviceControlRequest, DeviceCreateResponse, DeviceIoResponse, + DeviceWriteResponse, FileInformationClass, Information, NtStatus, PrinterIoRequest, ServerDeviceAnnounceResponse, ServerDriveIoRequest, +}; +use ironrdp::rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode}; +use ironrdp::svc::SvcMessage; +#[cfg(unix)] +use ironrdp_rdpdr_native::backend::NixRdpdrBackend; +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// A print job may not grow past this (a hostile server could otherwise fill the disk). +const MAX_PRINT_JOB: u64 = 256 << 20; + +/// Called with the file a finished print job was saved to. +pub type PrintedFn = std::sync::Arc; + +#[derive(Default)] +pub struct Devices { + #[cfg(unix)] + drive: Option, + printer: Option, +} + +impl std::fmt::Debug for Devices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Devices") + } +} + +impl_as_any!(Devices); + +impl Devices { + #[cfg(unix)] + pub fn with_drive(mut self, folder: &Path) -> std::io::Result { + self.drive = Some(ConfinedDrive::new(folder)?); + Ok(self) + } + + /// Print jobs go to `dir` as `Nexus print