feat(rdp): printer redirection, saved as PostScript
An optional 'Nexus Terminal' printer (IronRDP's PostScript driver announce) appears on the remote desktop; each job is written to 'Nexus print <time>.ps' in Downloads, with a toast saying where. Jobs are capped at 256 MiB and empty jobs leave no file. The shared drive and the printer now share one RDPDR backend (rdp_devices); the printer works on every platform, the drive on Linux and macOS. Tested with a unit test driving create/write/close; xrdp has no printer redirection, so not exercised against a server.
This commit is contained in:
+1
-1
@@ -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).
|
- 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.
|
- 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.
|
- 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
|
### VNC capabilities
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
- **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**.
|
- **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.
|
- **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.
|
- **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.
|
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.
|
||||||
|
|||||||
@@ -485,6 +485,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Warning { message, .. } => self.s.error(message),
|
Event::Warning { message, .. } => self.s.error(message),
|
||||||
|
Event::Info { message, .. } => self.s.success(message),
|
||||||
Event::Irc { session, event } => {
|
Event::Irc { session, event } => {
|
||||||
if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
|
if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
|
||||||
v.on_event(event);
|
v.on_event(event);
|
||||||
|
|||||||
@@ -702,6 +702,7 @@ impl ProfileEditor {
|
|||||||
});
|
});
|
||||||
section(ui, "Devices", |ui| {
|
section(ui, "Devices", |ui| {
|
||||||
check(ui, "Play Remote Audio", &mut c.audio, "");
|
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) {
|
if cfg!(unix) {
|
||||||
label(ui, "Shared Folder");
|
label(ui, "Shared Folder");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ impl Callbacks {
|
|||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod raw;
|
pub mod raw;
|
||||||
pub mod rdp;
|
pub mod rdp;
|
||||||
#[cfg(unix)]
|
pub mod rdp_devices;
|
||||||
pub mod rdp_drive;
|
|
||||||
pub mod serial;
|
pub mod serial;
|
||||||
pub mod telnet;
|
pub mod telnet;
|
||||||
pub mod vnc;
|
pub mod vnc;
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ pub struct Config {
|
|||||||
pub audio: bool,
|
pub audio: bool,
|
||||||
/// A local folder to share as a drive (Unix only), and the name it appears under.
|
/// A local folder to share as a drive (Unix only), and the name it appears under.
|
||||||
pub shared_folder: Option<(std::path::PathBuf, String)>,
|
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 dial: Dial,
|
||||||
pub check_cert: CertCheck,
|
pub check_cert: CertCheck,
|
||||||
pub connect_timeout: Duration,
|
pub connect_timeout: Duration,
|
||||||
@@ -196,10 +198,25 @@ impl Client {
|
|||||||
if self.cfg.audio {
|
if self.cfg.audio {
|
||||||
connector.attach_static_channel(ironrdp::rdpsnd::client::Rdpsnd::new(Box::new(ironrdp_rdpsnd_native::cpal::RdpsndBackend::new())));
|
connector.attach_static_channel(ironrdp::rdpsnd::client::Rdpsnd::new(Box::new(ironrdp_rdpsnd_native::cpal::RdpsndBackend::new())));
|
||||||
}
|
}
|
||||||
|
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)]
|
#[cfg(unix)]
|
||||||
if let Some((folder, name)) = &self.cfg.shared_folder {
|
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()))?;
|
devices = devices.with_drive(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())])));
|
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();
|
let (clip_tx, clip_rx) = mpsc::channel();
|
||||||
connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard {
|
connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard {
|
||||||
@@ -584,6 +601,7 @@ mod tests {
|
|||||||
allow_tls_only: true,
|
allow_tls_only: true,
|
||||||
audio: std::env::var("NEXUS_RDP_AUDIO").is_ok(),
|
audio: std::env::var("NEXUS_RDP_AUDIO").is_ok(),
|
||||||
shared_folder: std::env::var("NEXUS_RDP_SHARE").ok().map(|p| (p.into(), "nexus".into())),
|
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))?)),
|
dial: Arc::new(move || Ok(TcpStream::connect((h2.as_str(), port))?)),
|
||||||
check_cert: Arc::new(move |fp| {
|
check_cert: Arc::new(move |fp| {
|
||||||
*sc.lock() = fp.to_string();
|
*sc.lock() = fp.to_string();
|
||||||
|
|||||||
@@ -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<dyn Fn(PathBuf) + Send + Sync>;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Devices {
|
||||||
|
#[cfg(unix)]
|
||||||
|
drive: Option<ConfinedDrive>,
|
||||||
|
printer: Option<Spool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Devices> {
|
||||||
|
self.drive = Some(ConfinedDrive::new(folder)?);
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print jobs go to `dir` as `Nexus print <time>.ps`.
|
||||||
|
pub fn with_printer(mut self, dir: PathBuf, on_printed: PrintedFn) -> Devices {
|
||||||
|
self.printer = Some(Spool { dir, on_printed, jobs: HashMap::new(), next_id: 1 });
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Spool {
|
||||||
|
dir: PathBuf,
|
||||||
|
on_printed: PrintedFn,
|
||||||
|
jobs: HashMap<u32, (std::fs::File, PathBuf, u64)>,
|
||||||
|
next_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Spool {
|
||||||
|
fn handle(&mut self, req: PrinterIoRequest) -> PduResult<Vec<SvcMessage>> {
|
||||||
|
let pdu = match req {
|
||||||
|
PrinterIoRequest::Create(r) => {
|
||||||
|
let name = format!("Nexus print {}.ps", chrono::Local::now().format("%Y-%m-%d %H.%M.%S"));
|
||||||
|
let path = self.dir.join(name);
|
||||||
|
match std::fs::File::create(&path) {
|
||||||
|
Ok(f) => {
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id += 1;
|
||||||
|
self.jobs.insert(id, (f, path, 0));
|
||||||
|
RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
|
||||||
|
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::SUCCESS),
|
||||||
|
file_id: id,
|
||||||
|
information: Information::FILE_OPENED,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, dir = %self.dir.display(), "couldn't create a print file");
|
||||||
|
RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
|
||||||
|
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::ACCESS_DENIED),
|
||||||
|
file_id: 0,
|
||||||
|
information: Information::empty(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PrinterIoRequest::Write(r) => {
|
||||||
|
let n = r.write_data.len() as u64;
|
||||||
|
let ok = match self.jobs.get_mut(&r.device_io_request.file_id) {
|
||||||
|
Some((f, _, written)) if *written + n <= MAX_PRINT_JOB => {
|
||||||
|
*written += n;
|
||||||
|
f.write_all(&r.write_data).is_ok()
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
RdpdrPdu::DeviceWriteResponse(DeviceWriteResponse {
|
||||||
|
device_io_reply: DeviceIoResponse::new(r.device_io_request, if ok { NtStatus::SUCCESS } else { NtStatus::UNSUCCESSFUL }),
|
||||||
|
length: if ok { n as u32 } else { 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
PrinterIoRequest::Close(r) => {
|
||||||
|
if let Some((f, path, written)) = self.jobs.remove(&r.device_io_request.file_id) {
|
||||||
|
drop(f);
|
||||||
|
if written == 0 {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
} else {
|
||||||
|
(self.on_printed)(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RdpdrPdu::DeviceCloseResponse(DeviceCloseResponse { device_io_response: DeviceIoResponse::new(r.device_io_request, NtStatus::SUCCESS) })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(vec![SvcMessage::from(pdu)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RdpdrBackend for Devices {
|
||||||
|
fn handle_server_device_announce_response(&mut self, _: ServerDeviceAnnounceResponse) -> PduResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_scard_call(&mut self, _: DeviceControlRequest<ScardIoCtlCode>, _: ScardCall) -> PduResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_drive_io_request(&mut self, req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
if let Some(d) = &mut self.drive {
|
||||||
|
return d.handle(req);
|
||||||
|
}
|
||||||
|
ConfinedDrive::refuse(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_printer_io_request(&mut self, req: PrinterIoRequest) -> PduResult<Vec<SvcMessage>> {
|
||||||
|
match &mut self.printer {
|
||||||
|
Some(p) => p.handle(req),
|
||||||
|
None => Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCloseResponse(DeviceCloseResponse {
|
||||||
|
device_io_response: DeviceIoResponse::new(req.into_device_io_request(), NtStatus::NOT_SUPPORTED),
|
||||||
|
}))]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ConfinedDrive {
|
||||||
|
#[cfg(unix)]
|
||||||
|
inner: NixRdpdrBackend,
|
||||||
|
#[cfg_attr(not(unix), allow(dead_code))]
|
||||||
|
base: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfinedDrive {
|
||||||
|
pub fn new(folder: &Path) -> std::io::Result<ConfinedDrive> {
|
||||||
|
let base = folder.canonicalize()?;
|
||||||
|
if !base.is_dir() {
|
||||||
|
return Err(std::io::Error::other(format!("{} is not a folder", base.display())));
|
||||||
|
}
|
||||||
|
Ok(ConfinedDrive {
|
||||||
|
#[cfg(unix)]
|
||||||
|
inner: NixRdpdrBackend::new(base.to_string_lossy().trim_end_matches('/').to_string()),
|
||||||
|
base,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a server path (`\dir\file`, Windows separators) stays inside the share.
|
||||||
|
fn allowed(&self, server_path: &str) -> bool {
|
||||||
|
let rel = server_path.replace('\\', "/");
|
||||||
|
if rel.contains('\0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut p = self.base.clone();
|
||||||
|
for c in rel.split('/').filter(|c| !c.is_empty() && *c != ".") {
|
||||||
|
if c == ".." {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
p.push(c);
|
||||||
|
}
|
||||||
|
// The deepest part that exists must resolve (through any symlinks) inside the share; what
|
||||||
|
// doesn't exist yet can't be a link.
|
||||||
|
let mut probe = p.as_path();
|
||||||
|
loop {
|
||||||
|
if probe.symlink_metadata().is_ok() {
|
||||||
|
return probe.canonicalize().is_ok_and(|real| real.starts_with(&self.base));
|
||||||
|
}
|
||||||
|
match probe.parent() {
|
||||||
|
Some(parent) if parent.starts_with(&self.base) => probe = parent,
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refuse(req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
|
||||||
|
tracing::warn!("RDP server asked for a path outside the shared folder; refused");
|
||||||
|
let pdu = match req {
|
||||||
|
ServerDriveIoRequest::ServerCreateDriveRequest(r) => RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
|
||||||
|
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::ACCESS_DENIED),
|
||||||
|
file_id: 0,
|
||||||
|
information: Information::empty(),
|
||||||
|
}),
|
||||||
|
ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => RdpdrPdu::ClientDriveQueryDirectoryResponse(ClientDriveQueryDirectoryResponse {
|
||||||
|
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::NO_MORE_FILES),
|
||||||
|
buffer: None,
|
||||||
|
}),
|
||||||
|
ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => RdpdrPdu::ClientDriveSetInformationResponse(
|
||||||
|
ClientDriveSetInformationResponse::new(&r, NtStatus::ACCESS_DENIED).map_err(|e| ironrdp::pdu::encode_err!(e))?,
|
||||||
|
),
|
||||||
|
_ => return Ok(vec![]),
|
||||||
|
};
|
||||||
|
Ok(vec![SvcMessage::from(pdu)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfinedDrive {
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn handle(&mut self, req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
|
||||||
|
let ok = match &req {
|
||||||
|
ServerDriveIoRequest::ServerCreateDriveRequest(r) => self.allowed(&r.path),
|
||||||
|
ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => r.initial_query == 0 || self.allowed(r.path.trim_end_matches('*')),
|
||||||
|
ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => match &r.set_buffer {
|
||||||
|
FileInformationClass::Rename(info) => self.allowed(&info.file_name),
|
||||||
|
_ => true,
|
||||||
|
},
|
||||||
|
// Everything else names a file by an id that a checked create handed out.
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
if ok { self.inner.handle_drive_io_request(req) } else { Self::refuse(req) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn print_jobs_are_saved_and_capped() {
|
||||||
|
use ironrdp::rdpdr::pdu::efs::{
|
||||||
|
CreateDisposition, CreateOptions, DesiredAccess, DeviceCloseRequest, DeviceCreateRequest, DeviceIoRequest, DeviceWriteRequest, FileAttributes,
|
||||||
|
MajorFunction, SharedAccess,
|
||||||
|
};
|
||||||
|
let dir = std::env::temp_dir().join(format!("nexus-print-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let printed: std::sync::Arc<parking_lot::Mutex<Vec<PathBuf>>> = Default::default();
|
||||||
|
let p2 = printed.clone();
|
||||||
|
let mut dev = Devices::default().with_printer(dir.clone(), std::sync::Arc::new(move |f| p2.lock().push(f)));
|
||||||
|
let io = |file_id, major| DeviceIoRequest { device_id: 2, file_id, completion_id: 1, major_function: major, minor_function: 0.into() };
|
||||||
|
let create = || {
|
||||||
|
PrinterIoRequest::Create(DeviceCreateRequest {
|
||||||
|
device_io_request: io(0, MajorFunction::Create),
|
||||||
|
desired_access: DesiredAccess::empty(),
|
||||||
|
allocation_size: 0,
|
||||||
|
file_attributes: FileAttributes::empty(),
|
||||||
|
shared_access: SharedAccess::empty(),
|
||||||
|
create_disposition: CreateDisposition::FILE_OPEN,
|
||||||
|
create_options: CreateOptions::empty(),
|
||||||
|
path: String::new(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let write = |id, data: &[u8]| {
|
||||||
|
PrinterIoRequest::Write(DeviceWriteRequest { device_io_request: io(id, MajorFunction::Write), offset: 0, write_data: data.to_vec() })
|
||||||
|
};
|
||||||
|
let close = |id| PrinterIoRequest::Close(DeviceCloseRequest { device_io_request: io(id, MajorFunction::Close) });
|
||||||
|
dev.handle_printer_io_request(create()).unwrap();
|
||||||
|
dev.handle_printer_io_request(write(1, b"%!PS-Adobe-3.0\n")).unwrap();
|
||||||
|
dev.handle_printer_io_request(write(1, b"showpage\n")).unwrap();
|
||||||
|
dev.handle_printer_io_request(close(1)).unwrap();
|
||||||
|
let files = printed.lock().clone();
|
||||||
|
assert_eq!(files.len(), 1);
|
||||||
|
assert_eq!(std::fs::read_to_string(&files[0]).unwrap(), "%!PS-Adobe-3.0\nshowpage\n");
|
||||||
|
// An empty job leaves no file behind; writes past the cap are refused.
|
||||||
|
dev.handle_printer_io_request(create()).unwrap();
|
||||||
|
dev.handle_printer_io_request(close(2)).unwrap();
|
||||||
|
dev.handle_printer_io_request(create()).unwrap();
|
||||||
|
dev.printer.as_mut().unwrap().jobs.get_mut(&3).unwrap().2 = MAX_PRINT_JOB;
|
||||||
|
dev.handle_printer_io_request(write(3, b"x")).unwrap();
|
||||||
|
assert_eq!(dev.printer.as_ref().unwrap().jobs[&3].2, MAX_PRINT_JOB);
|
||||||
|
assert_eq!(printed.lock().len(), 1);
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_paths_stay_inside_the_share() {
|
||||||
|
let root = std::env::temp_dir().join(format!("nexus-drive-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
let share = root.join("share");
|
||||||
|
std::fs::create_dir_all(share.join("sub")).unwrap();
|
||||||
|
std::fs::write(root.join("secret"), "x").unwrap();
|
||||||
|
std::os::unix::fs::symlink(&root, share.join("escape")).unwrap();
|
||||||
|
std::os::unix::fs::symlink(share.join("sub"), share.join("inside")).unwrap();
|
||||||
|
let d = ConfinedDrive::new(&share).unwrap();
|
||||||
|
for ok in ["\\", "\\sub", "\\sub\\new.txt", "\\new\\deeper\\file", "\\inside\\x", "\\.\\sub"] {
|
||||||
|
assert!(d.allowed(ok), "{ok}");
|
||||||
|
}
|
||||||
|
for bad in ["\\..\\secret", "\\sub\\..\\..\\secret", "\\escape\\secret", "\\escape\\new", "\\a\0b"] {
|
||||||
|
assert!(!d.allowed(bad), "{bad:?}");
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
//! Drive redirection for RDP, confined to one shared 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. Unix only (the native
|
|
||||||
//! backend has no Windows implementation).
|
|
||||||
|
|
||||||
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, DeviceControlRequest, DeviceCreateResponse, DeviceIoResponse, FileInformationClass,
|
|
||||||
Information, NtStatus, ServerDeviceAnnounceResponse, ServerDriveIoRequest,
|
|
||||||
};
|
|
||||||
use ironrdp::rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode};
|
|
||||||
use ironrdp::svc::SvcMessage;
|
|
||||||
use ironrdp_rdpdr_native::backend::NixRdpdrBackend;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ConfinedDrive {
|
|
||||||
inner: NixRdpdrBackend,
|
|
||||||
base: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl_as_any!(ConfinedDrive);
|
|
||||||
|
|
||||||
impl ConfinedDrive {
|
|
||||||
pub fn new(folder: &Path) -> std::io::Result<ConfinedDrive> {
|
|
||||||
let base = folder.canonicalize()?;
|
|
||||||
if !base.is_dir() {
|
|
||||||
return Err(std::io::Error::other(format!("{} is not a folder", base.display())));
|
|
||||||
}
|
|
||||||
Ok(ConfinedDrive { inner: NixRdpdrBackend::new(base.to_string_lossy().trim_end_matches('/').to_string()), base })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a server path (`\dir\file`, Windows separators) stays inside the share.
|
|
||||||
fn allowed(&self, server_path: &str) -> bool {
|
|
||||||
let rel = server_path.replace('\\', "/");
|
|
||||||
if rel.contains('\0') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let mut p = self.base.clone();
|
|
||||||
for c in rel.split('/').filter(|c| !c.is_empty() && *c != ".") {
|
|
||||||
if c == ".." {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
p.push(c);
|
|
||||||
}
|
|
||||||
// The deepest part that exists must resolve (through any symlinks) inside the share; what
|
|
||||||
// doesn't exist yet can't be a link.
|
|
||||||
let mut probe = p.as_path();
|
|
||||||
loop {
|
|
||||||
if probe.symlink_metadata().is_ok() {
|
|
||||||
return probe.canonicalize().is_ok_and(|real| real.starts_with(&self.base));
|
|
||||||
}
|
|
||||||
match probe.parent() {
|
|
||||||
Some(parent) if parent.starts_with(&self.base) => probe = parent,
|
|
||||||
_ => return false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refuse(req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
|
|
||||||
tracing::warn!("RDP server asked for a path outside the shared folder; refused");
|
|
||||||
let pdu = match req {
|
|
||||||
ServerDriveIoRequest::ServerCreateDriveRequest(r) => RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
|
|
||||||
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::ACCESS_DENIED),
|
|
||||||
file_id: 0,
|
|
||||||
information: Information::empty(),
|
|
||||||
}),
|
|
||||||
ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => RdpdrPdu::ClientDriveQueryDirectoryResponse(ClientDriveQueryDirectoryResponse {
|
|
||||||
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::NO_MORE_FILES),
|
|
||||||
buffer: None,
|
|
||||||
}),
|
|
||||||
ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => RdpdrPdu::ClientDriveSetInformationResponse(
|
|
||||||
ClientDriveSetInformationResponse::new(&r, NtStatus::ACCESS_DENIED).map_err(|e| ironrdp::pdu::encode_err!(e))?,
|
|
||||||
),
|
|
||||||
_ => return Ok(vec![]),
|
|
||||||
};
|
|
||||||
Ok(vec![SvcMessage::from(pdu)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RdpdrBackend for ConfinedDrive {
|
|
||||||
fn handle_server_device_announce_response(&mut self, pdu: ServerDeviceAnnounceResponse) -> PduResult<()> {
|
|
||||||
self.inner.handle_server_device_announce_response(pdu)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_scard_call(&mut self, req: DeviceControlRequest<ScardIoCtlCode>, call: ScardCall) -> PduResult<()> {
|
|
||||||
self.inner.handle_scard_call(req, call)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_drive_io_request(&mut self, req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
|
|
||||||
let ok = match &req {
|
|
||||||
ServerDriveIoRequest::ServerCreateDriveRequest(r) => self.allowed(&r.path),
|
|
||||||
ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => r.initial_query == 0 || self.allowed(r.path.trim_end_matches('*')),
|
|
||||||
ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => match &r.set_buffer {
|
|
||||||
FileInformationClass::Rename(info) => self.allowed(&info.file_name),
|
|
||||||
_ => true,
|
|
||||||
},
|
|
||||||
// Everything else names a file by an id that a checked create handed out.
|
|
||||||
_ => true,
|
|
||||||
};
|
|
||||||
if ok { self.inner.handle_drive_io_request(req) } else { Self::refuse(req) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_paths_stay_inside_the_share() {
|
|
||||||
let root = std::env::temp_dir().join(format!("nexus-drive-{}", std::process::id()));
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
|
||||||
let share = root.join("share");
|
|
||||||
std::fs::create_dir_all(share.join("sub")).unwrap();
|
|
||||||
std::fs::write(root.join("secret"), "x").unwrap();
|
|
||||||
std::os::unix::fs::symlink(&root, share.join("escape")).unwrap();
|
|
||||||
std::os::unix::fs::symlink(share.join("sub"), share.join("inside")).unwrap();
|
|
||||||
let d = ConfinedDrive::new(&share).unwrap();
|
|
||||||
for ok in ["\\", "\\sub", "\\sub\\new.txt", "\\new\\deeper\\file", "\\inside\\x", "\\.\\sub"] {
|
|
||||||
assert!(d.allowed(ok), "{ok}");
|
|
||||||
}
|
|
||||||
for bad in ["\\..\\secret", "\\sub\\..\\..\\secret", "\\escape\\secret", "\\escape\\new", "\\a\0b"] {
|
|
||||||
assert!(!d.allowed(bad), "{bad:?}");
|
|
||||||
}
|
|
||||||
std::fs::remove_dir_all(root).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -413,6 +413,8 @@ pub struct RdpConfig {
|
|||||||
pub audio: bool,
|
pub audio: bool,
|
||||||
/// Local folder shared with the remote desktop as a drive (empty: none). Linux and macOS.
|
/// Local folder shared with the remote desktop as a drive (empty: none). Linux and macOS.
|
||||||
pub shared_folder: String,
|
pub shared_folder: String,
|
||||||
|
/// Offer a printer; print jobs are saved as PostScript files in Downloads.
|
||||||
|
pub redirect_printer: bool,
|
||||||
/// Trust policy for the server's TLS certificate (pinned like an SSH host key).
|
/// Trust policy for the server's TLS certificate (pinned like an SSH host key).
|
||||||
#[serde(deserialize_with = "lenient")]
|
#[serde(deserialize_with = "lenient")]
|
||||||
pub host_key_policy: HostKeyPolicy,
|
pub host_key_policy: HostKeyPolicy,
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ pub enum Event {
|
|||||||
session: String,
|
session: String,
|
||||||
error: String,
|
error: String,
|
||||||
},
|
},
|
||||||
|
/// Something the user might like to know (e.g. where a print job was saved).
|
||||||
|
Info {
|
||||||
|
session: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
/// Something the user should know about that didn't stop the session (e.g. a credential that
|
/// Something the user should know about that didn't stop the session (e.g. a credential that
|
||||||
/// couldn't be saved).
|
/// couldn't be saved).
|
||||||
Warning {
|
Warning {
|
||||||
|
|||||||
@@ -356,6 +356,12 @@ impl Manager {
|
|||||||
allow_tls_only: rc.allow_tls_only,
|
allow_tls_only: rc.allow_tls_only,
|
||||||
audio: rc.audio,
|
audio: rc.audio,
|
||||||
shared_folder: (!rc.shared_folder.trim().is_empty()).then(|| (rc.shared_folder.trim().into(), "Nexus".into())),
|
shared_folder: (!rc.shared_folder.trim().is_empty()).then(|| (rc.shared_folder.trim().into(), "Nexus".into())),
|
||||||
|
printer: rc.redirect_printer.then(|| {
|
||||||
|
let (me, sid) = (self.clone(), id.to_string());
|
||||||
|
let on_printed: clients::rdp_devices::PrintedFn =
|
||||||
|
Arc::new(move |f: PathBuf| me.emit(Event::Info { session: sid.clone(), message: format!("Print job saved to {}", f.display()) }));
|
||||||
|
(downloads_dir(), on_printed)
|
||||||
|
}),
|
||||||
dial,
|
dial,
|
||||||
check_cert,
|
check_cert,
|
||||||
connect_timeout: timeout,
|
connect_timeout: timeout,
|
||||||
@@ -418,3 +424,10 @@ pub(super) fn connect_vnc_client(
|
|||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where print jobs and similar go: ~/Downloads when it exists, else the home folder.
|
||||||
|
fn downloads_dir() -> PathBuf {
|
||||||
|
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
|
||||||
|
let d = home.join("Downloads");
|
||||||
|
if d.is_dir() { d } else { home }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user