feat(rdp): remote audio and a shared folder redirected as a drive

- Audio: the RDPSND channel with IronRDP's cpal playback backend (PCM;
  Opus left out to avoid another native library). On by default for new
  RDP profiles. Building now needs the ALSA headers on Linux
  (libasound2-dev), added to CI, release and fuzz jobs and the build docs.
- Shared folder (Linux/macOS): one local folder appears on the remote
  desktop as a drive. IronRDP's native backend joins server paths onto
  the share unchecked, so it is wrapped in ConfinedDrive: create, list
  and rename paths with '..' components, NUL bytes, or that resolve
  through a symlink outside the share are refused (ACCESS_DENIED);
  tests cover the escapes.

Verified against xrdp (with FUSE): the remote read the shared file, and
writes, mkdir and rename landed in the local folder. Not verified:
audio playback (the test container has no sound module), and xrdp lists
the drive as 'ignored' and can't list its directories (an IronRDP/xrdp
naming and listing mismatch, fixed upstream for the name).
This commit is contained in:
Andy Dixon
2026-10-12 18:07:49 +01:00
parent 6476ccff28
commit b798422735
15 changed files with 478 additions and 8 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: | run: |
sudo apt-get update 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 test --locked --workspace
- run: cargo fmt --all --check - run: cargo fmt --all --check
- run: cargo clippy --locked --workspace --all-targets -- -D warnings - run: cargo clippy --locked --workspace --all-targets -- -D warnings
+1
View File
@@ -21,6 +21,7 @@ jobs:
working-directory: rust/fuzz working-directory: rust/fuzz
steps: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - 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: rustup toolchain install nightly --profile minimal
- run: cargo +nightly install cargo-fuzz --locked - run: cargo +nightly install cargo-fuzz --locked
- run: cargo +nightly fuzz run ${{ matrix.target }} -- -max_total_time=120 -rss_limit_mb=2048 - run: cargo +nightly fuzz run ${{ matrix.target }} -- -max_total_time=120 -rss_limit_mb=2048
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: | run: |
sudo apt-get update 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 - name: Test
run: cargo test --locked --workspace run: cargo test --locked --workspace
+1 -1
View File
@@ -17,7 +17,7 @@ The toolchain is pinned in `rust/rust-toolchain.toml`; `rustup` installs it on f
```bash ```bash
sudo apt-get install -y build-essential pkg-config libxkbcommon-dev libgl1-mesa-dev \ 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`). - **macOS:** Xcode command-line tools (`xcode-select --install`).
+1
View File
@@ -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). - 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.
### VNC capabilities ### VNC capabilities
+1
View File
@@ -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. - **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.
- **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.
+280 -3
View File
@@ -264,6 +264,28 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "android-activity" name = "android-activity"
version = "0.6.1" version = "0.6.1"
@@ -273,7 +295,7 @@ dependencies = [
"android-properties", "android-properties",
"bitflags 2.13.2", "bitflags 2.13.2",
"cc", "cc",
"jni", "jni 0.22.4",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -949,6 +971,12 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.5" version = "1.0.5"
@@ -1154,6 +1182,50 @@ dependencies = [
"libc", "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]] [[package]]
name = "cpubits" name = "cpubits"
version = "0.1.1" version = "0.1.1"
@@ -1362,6 +1434,12 @@ dependencies = [
"parking_lot_core", "parking_lot_core",
] ]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.1" version = "2.11.1"
@@ -2758,7 +2836,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [ dependencies = [
"core-foundation-sys", "core-foundation-sys",
"mach2", "mach2 0.4.3",
] ]
[[package]] [[package]]
@@ -2775,6 +2853,8 @@ dependencies = [
"ironrdp-graphics", "ironrdp-graphics",
"ironrdp-input", "ironrdp-input",
"ironrdp-pdu", "ironrdp-pdu",
"ironrdp-rdpdr",
"ironrdp-rdpsnd",
"ironrdp-session", "ironrdp-session",
"ironrdp-svc", "ironrdp-svc",
] ]
@@ -2921,6 +3001,59 @@ dependencies = [
"x509-cert", "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]] [[package]]
name = "ironrdp-session" name = "ironrdp-session"
version = "0.11.0" version = "0.11.0"
@@ -2980,6 +3113,22 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 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]] [[package]]
name = "jni" name = "jni"
version = "0.22.4" version = "0.22.4"
@@ -3239,6 +3388,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "mach2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -3493,6 +3651,8 @@ dependencies = [
"image", "image",
"ironrdp", "ironrdp",
"ironrdp-blocking", "ironrdp-blocking",
"ironrdp-rdpdr-native",
"ironrdp-rdpsnd-native",
"keyring", "keyring",
"libc", "libc",
"parking_lot", "parking_lot",
@@ -3780,6 +3940,31 @@ dependencies = [
"objc2-foundation 0.3.2", "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]] [[package]]
name = "objc2-cloud-kit" name = "objc2-cloud-kit"
version = "0.2.2" version = "0.2.2"
@@ -3804,6 +3989,29 @@ dependencies = [
"objc2-foundation 0.2.2", "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]] [[package]]
name = "objc2-core-data" name = "objc2-core-data"
version = "0.2.2" version = "0.2.2"
@@ -3823,7 +4031,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.6.2",
"dispatch2", "dispatch2",
"libc",
"objc2 0.6.5", "objc2 0.6.5",
] ]
@@ -3891,6 +4101,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.6.2", "block2 0.6.2",
"libc",
"objc2 0.6.5", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -5471,7 +5682,7 @@ dependencies = [
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"io-kit-sys", "io-kit-sys",
"mach2", "mach2 0.4.3",
"nix 0.26.4", "nix 0.26.4",
"scopeguard", "scopeguard",
"unescaper", "unescaper",
@@ -6996,6 +7207,15 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -7032,6 +7252,21 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -7074,6 +7309,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -7086,6 +7327,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -7098,6 +7345,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -7122,6 +7375,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@@ -7134,6 +7393,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@@ -7146,6 +7411,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -7158,6 +7429,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
+28
View File
@@ -700,6 +700,34 @@ impl ProfileEditor {
"Plain TLS logon, e.g. xrdp. Less secure: the session is set up before you authenticate.", "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| { section(ui, "Display", |ui| {
check(ui, "Scale to Fit", &mut c.scale_to_fit, ""); check(ui, "Scale to Fit", &mut c.scale_to_fit, "");
num(ui, "Width (0 = fit tab)", &mut c.width, 0..=8192); num(ui, "Width (0 = fit tab)", &mut c.width, 0..=8192);
+5 -1
View File
@@ -36,10 +36,14 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
webpki-roots = "1" 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" ironrdp-blocking = "0.10"
x509-cert = { version = "0.2", default-features = false, features = ["std"] } x509-cert = { version = "0.2", default-features = false, features = ["std"] }
[target.'cfg(unix)'.dependencies]
ironrdp-rdpdr-native = "0.7"
[dev-dependencies] [dev-dependencies]
tiny_http = "0.12" tiny_http = "0.12"
+2
View File
@@ -40,6 +40,8 @@ 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_drive;
pub mod serial; pub mod serial;
pub mod telnet; pub mod telnet;
pub mod vnc; pub mod vnc;
+15 -1
View File
@@ -48,6 +48,10 @@ pub struct Config {
pub height: u16, pub height: u16,
/// Also allow plain TLS (graphical logon) for servers without NLA, such as xrdp. /// Also allow plain TLS (graphical logon) for servers without NLA, such as xrdp.
pub allow_tls_only: bool, 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 dial: Dial,
pub check_cert: CertCheck, pub check_cert: CertCheck,
pub connect_timeout: Duration, pub connect_timeout: Duration,
@@ -165,7 +169,7 @@ impl Client {
request_data: None, request_data: None,
// Also hands the credentials to TLS-only servers (xrdp), which would otherwise show their own login screen. // Also hands the credentials to TLS-only servers (xrdp), which would otherwise show their own login screen.
autologon: !c.password.is_empty(), 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 // ponytail: no bulk compression (the bitmap codecs already compress); enabling it means
// carrying the decompressor across reactivations. // carrying the decompressor across reactivations.
compression_type: None, compression_type: None,
@@ -189,6 +193,14 @@ impl Client {
let mut framed = Framed::new(tcp); let mut framed = Framed::new(tcp);
let drdynvc = DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))); 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); 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(); 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 {
tx: clip_tx, tx: clip_tx,
@@ -570,6 +582,8 @@ mod tests {
width: 1024, width: 1024,
height: 768, height: 768,
allow_tls_only: true, 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))?)), 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();
+131
View File
@@ -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<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();
}
}
+5
View File
@@ -409,6 +409,10 @@ pub struct RdpConfig {
/// Allow plain TLS logon for servers without Network Level Authentication (e.g. xrdp). /// Allow plain TLS logon for servers without Network Level Authentication (e.g. xrdp).
#[serde(rename = "allowTLSOnly")] #[serde(rename = "allowTLSOnly")]
pub allow_tls_only: bool, 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). /// 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,
@@ -433,6 +437,7 @@ impl RdpConfig {
RdpConfig { RdpConfig {
scale_to_fit: true, scale_to_fit: true,
dynamic_resolution: true, dynamic_resolution: true,
audio: true,
proxy_type: "none".into(), proxy_type: "none".into(),
ssh_port: DEFAULT_SSH_PORT.into(), ssh_port: DEFAULT_SSH_PORT.into(),
ssh_auth_method: "publickey".into(), ssh_auth_method: "publickey".into(),
+2
View File
@@ -354,6 +354,8 @@ impl Manager {
width, width,
height, height,
allow_tls_only: rc.allow_tls_only, 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, dial,
check_cert, check_cert,
connect_timeout: timeout, connect_timeout: timeout,
+4
View File
@@ -334,6 +334,10 @@ pub fn validate_rdp_config(cfg: &RdpConfig) -> Result<(), ValidationErrors> {
} }
t => errs.add("rdp.proxyType", format!("unsupported proxy type {t:?}")), 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 cfg.ssh_tunnel {
if !matches!(cfg.proxy_type.as_str(), "" | "none") { if !matches!(cfg.proxy_type.as_str(), "" | "none") {
errs.add("rdp.proxyType", "use either a proxy or an SSH tunnel, not both"); errs.add("rdp.proxyType", "use either a proxy or an SSH tunnel, not both");