diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0260bd4..8b41c9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev libgtk-3-dev openssh-server + sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev libgtk-3-dev libasound2-dev openssh-server - run: cargo test --locked --workspace - run: cargo fmt --all --check - run: cargo clippy --locked --workspace --all-targets -- -D warnings diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 71a0a4e..678f4e6 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -21,6 +21,7 @@ jobs: working-directory: rust/fuzz steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - run: sudo apt-get update && sudo apt-get install -y libasound2-dev - run: rustup toolchain install nightly --profile minimal - run: cargo +nightly install cargo-fuzz --locked - run: cargo +nightly fuzz run ${{ matrix.target }} -- -max_total_time=120 -rss_limit_mb=2048 diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml index 16f69d2..89a8a72 100644 --- a/.github/workflows/release-builds.yml +++ b/.github/workflows/release-builds.yml @@ -39,7 +39,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev libgtk-3-dev zip + sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev libgtk-3-dev libasound2-dev zip - name: Test run: cargo test --locked --workspace diff --git a/docs/build-and-release.md b/docs/build-and-release.md index cd18922..24c8aca 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -17,7 +17,7 @@ The toolchain is pinned in `rust/rust-toolchain.toml`; `rustup` installs it on f ```bash sudo apt-get install -y build-essential pkg-config libxkbcommon-dev libgl1-mesa-dev \ - libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev libgtk-3-dev zip + libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev libgtk-3-dev libasound2-dev zip ``` - **macOS:** Xcode command-line tools (`xcode-select --install`). diff --git a/docs/overview.md b/docs/overview.md index 5f3294b..1c7a471 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -148,6 +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. ### VNC capabilities diff --git a/docs/user-manual.md b/docs/user-manual.md index 19fdda9..436a8f7 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -167,6 +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. - **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/Cargo.lock b/rust/Cargo.lock index 9ffe73b..1082905 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -264,6 +264,28 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android-activity" version = "0.6.1" @@ -273,7 +295,7 @@ dependencies = [ "android-properties", "bitflags 2.13.2", "cc", - "jni", + "jni 0.22.4", "libc", "log", "ndk", @@ -949,6 +971,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.5" @@ -1154,6 +1182,50 @@ dependencies = [ "libc", ] +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "cpal" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2 0.5.0", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.5", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + [[package]] name = "cpubits" version = "0.1.1" @@ -1362,6 +1434,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.11.1" @@ -2758,7 +2836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" dependencies = [ "core-foundation-sys", - "mach2", + "mach2 0.4.3", ] [[package]] @@ -2775,6 +2853,8 @@ dependencies = [ "ironrdp-graphics", "ironrdp-input", "ironrdp-pdu", + "ironrdp-rdpdr", + "ironrdp-rdpsnd", "ironrdp-session", "ironrdp-svc", ] @@ -2921,6 +3001,59 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "ironrdp-rdpdr" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece9e67ccf992eb8ce5ec0416d3950e2e199d4b1e2523f91af192205279fc970" +dependencies = [ + "bitflags 2.13.2", + "ironrdp-core", + "ironrdp-error", + "ironrdp-pdu", + "ironrdp-svc", + "tracing", +] + +[[package]] +name = "ironrdp-rdpdr-native" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e964857871c3a49cf5e642320908a0be73c923f7f037c347c33f23001113e7" +dependencies = [ + "ironrdp-core", + "ironrdp-pdu", + "ironrdp-rdpdr", + "ironrdp-svc", + "nix 0.31.3", + "tracing", +] + +[[package]] +name = "ironrdp-rdpsnd" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1a4f7092ae8fdc0a2f61be8e100277dc6f4ad51b85455dc9e8157979993ea2" +dependencies = [ + "bitflags 2.13.2", + "ironrdp-core", + "ironrdp-pdu", + "ironrdp-svc", + "tracing", +] + +[[package]] +name = "ironrdp-rdpsnd-native" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d2d36d9bb45863f9bc5225bf3bccda125fafb1b225c1f99dbeaeebb258e7dc" +dependencies = [ + "cpal", + "ironrdp-error", + "ironrdp-rdpsnd", + "tracing", +] + [[package]] name = "ironrdp-session" version = "0.11.0" @@ -2980,6 +3113,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -3239,6 +3388,15 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3493,6 +3651,8 @@ dependencies = [ "image", "ironrdp", "ironrdp-blocking", + "ironrdp-rdpdr-native", + "ironrdp-rdpsnd-native", "keyring", "libc", "parking_lot", @@ -3780,6 +3940,31 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2 0.6.5", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-cloud-kit" version = "0.2.2" @@ -3804,6 +3989,29 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.5", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", +] + [[package]] name = "objc2-core-data" version = "0.2.2" @@ -3823,7 +4031,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.5", ] @@ -3891,6 +4101,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", + "libc", "objc2 0.6.5", "objc2-core-foundation", ] @@ -5471,7 +5682,7 @@ dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", "io-kit-sys", - "mach2", + "mach2 0.4.3", "nix 0.26.4", "scopeguard", "unescaper", @@ -6996,6 +7207,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -7032,6 +7252,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -7074,6 +7309,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7086,6 +7327,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7098,6 +7345,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7122,6 +7375,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7134,6 +7393,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7146,6 +7411,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7158,6 +7429,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/rust/app/src/profile_editor.rs b/rust/app/src/profile_editor.rs index 00a7441..d22c51d 100644 --- a/rust/app/src/profile_editor.rs +++ b/rust/app/src/profile_editor.rs @@ -700,6 +700,34 @@ impl ProfileEditor { "Plain TLS logon, e.g. xrdp. Less secure: the session is set up before you authenticate.", ); }); + section(ui, "Devices", |ui| { + check(ui, "Play Remote Audio", &mut c.audio, ""); + if cfg!(unix) { + label(ui, "Shared Folder"); + ui.horizontal(|ui| { + if widgets::button(ui, icon::FOLDER_OPEN, "Choose…").clicked() + && let Some(dir) = rfd::FileDialog::new().set_title("Folder to share with the remote desktop").pick_folder() + { + c.shared_folder = dir.to_string_lossy().into_owned(); + } + if !c.shared_folder.is_empty() && ui.button("Stop Sharing").clicked() { + c.shared_folder.clear(); + } + }); + err(ui, errs, "rdp.sharedFolder"); + widgets::hint( + ui, + &if c.shared_folder.is_empty() { + "Nothing is shared.".to_string() + } else { + format!( + "{} appears on the remote desktop as a drive named Nexus; the server can read and change everything in it, and nothing outside it.", + c.shared_folder + ) + }, + ); + } + }); section(ui, "Display", |ui| { check(ui, "Scale to Fit", &mut c.scale_to_fit, ""); num(ui, "Width (0 = fit tab)", &mut c.width, 0..=8192); diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml index 8728703..021056c 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -36,10 +36,14 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } webpki-roots = "1" -ironrdp = { version = "0.17", features = ["connector", "session", "graphics", "pdu", "svc", "dvc", "displaycontrol", "input", "cliprdr"] } +ironrdp = { version = "0.17", features = ["connector", "session", "graphics", "pdu", "svc", "dvc", "displaycontrol", "input", "cliprdr", "rdpsnd", "rdpdr"] } +ironrdp-rdpsnd-native = { version = "0.7", default-features = false } ironrdp-blocking = "0.10" x509-cert = { version = "0.2", default-features = false, features = ["std"] } +[target.'cfg(unix)'.dependencies] +ironrdp-rdpdr-native = "0.7" + [dev-dependencies] tiny_http = "0.12" diff --git a/rust/core/src/clients/mod.rs b/rust/core/src/clients/mod.rs index ab04765..8b025e5 100644 --- a/rust/core/src/clients/mod.rs +++ b/rust/core/src/clients/mod.rs @@ -40,6 +40,8 @@ impl Callbacks { pub mod proxy; pub mod raw; pub mod rdp; +#[cfg(unix)] +pub mod rdp_drive; 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 8faee0c..f3451b6 100644 --- a/rust/core/src/clients/rdp.rs +++ b/rust/core/src/clients/rdp.rs @@ -48,6 +48,10 @@ pub struct Config { pub height: u16, /// Also allow plain TLS (graphical logon) for servers without NLA, such as xrdp. pub allow_tls_only: bool, + /// Play the remote desktop's sound locally. + 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)>, pub dial: Dial, pub check_cert: CertCheck, pub connect_timeout: Duration, @@ -165,7 +169,7 @@ impl Client { request_data: None, // Also hands the credentials to TLS-only servers (xrdp), which would otherwise show their own login screen. autologon: !c.password.is_empty(), - enable_audio_playback: false, + enable_audio_playback: c.audio, // ponytail: no bulk compression (the bitmap codecs already compress); enabling it means // carrying the decompressor across reactivations. compression_type: None, @@ -189,6 +193,14 @@ impl Client { let mut framed = Framed::new(tcp); let drdynvc = DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))); let mut connector = ClientConnector::new(self.connector_config(), local).with_static_channel(drdynvc); + 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())]))); + } let (clip_tx, clip_rx) = mpsc::channel(); connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard { tx: clip_tx, @@ -570,6 +582,8 @@ mod tests { width: 1024, height: 768, 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())), 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_drive.rs b/rust/core/src/clients/rdp_drive.rs new file mode 100644 index 0000000..589999a --- /dev/null +++ b/rust/core/src/clients/rdp_drive.rs @@ -0,0 +1,131 @@ +//! 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 { + 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> { + 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, call: ScardCall) -> PduResult<()> { + self.inner.handle_scard_call(req, call) + } + + fn handle_drive_io_request(&mut self, req: ServerDriveIoRequest) -> PduResult> { + 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(); + } +} diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs index 5e68bbe..ac0b9fe 100644 --- a/rust/core/src/models.rs +++ b/rust/core/src/models.rs @@ -409,6 +409,10 @@ pub struct RdpConfig { /// Allow plain TLS logon for servers without Network Level Authentication (e.g. xrdp). #[serde(rename = "allowTLSOnly")] pub allow_tls_only: bool, + /// Play remote audio locally. + pub audio: bool, + /// Local folder shared with the remote desktop as a drive (empty: none). Linux and macOS. + pub shared_folder: String, /// Trust policy for the server's TLS certificate (pinned like an SSH host key). #[serde(deserialize_with = "lenient")] pub host_key_policy: HostKeyPolicy, @@ -433,6 +437,7 @@ impl RdpConfig { RdpConfig { scale_to_fit: true, dynamic_resolution: true, + audio: true, proxy_type: "none".into(), ssh_port: DEFAULT_SSH_PORT.into(), ssh_auth_method: "publickey".into(), diff --git a/rust/core/src/sessions/protocols.rs b/rust/core/src/sessions/protocols.rs index e649b88..5817d6c 100644 --- a/rust/core/src/sessions/protocols.rs +++ b/rust/core/src/sessions/protocols.rs @@ -354,6 +354,8 @@ impl Manager { width, height, allow_tls_only: rc.allow_tls_only, + audio: rc.audio, + shared_folder: (!rc.shared_folder.trim().is_empty()).then(|| (rc.shared_folder.trim().into(), "Nexus".into())), dial, check_cert, connect_timeout: timeout, diff --git a/rust/core/src/validation.rs b/rust/core/src/validation.rs index 4ddd083..acc24e4 100644 --- a/rust/core/src/validation.rs +++ b/rust/core/src/validation.rs @@ -334,6 +334,10 @@ pub fn validate_rdp_config(cfg: &RdpConfig) -> Result<(), ValidationErrors> { } t => errs.add("rdp.proxyType", format!("unsupported proxy type {t:?}")), } + let share = cfg.shared_folder.trim(); + if !share.is_empty() && !std::path::Path::new(share).is_dir() { + errs.add("rdp.sharedFolder", "the shared folder must be an existing folder"); + } if cfg.ssh_tunnel { if !matches!(cfg.proxy_type.as_str(), "" | "none") { errs.add("rdp.proxyType", "use either a proxy or an SSH tunnel, not both");