Files
nexus/rust/core/src/clients/rdp.rs
T
Andy Dixon 73c1f77ca3 fix: macOS and Windows test/lint failures; repo.dixon.cx packaging for nexusterminal
CI hadn't run on macOS or Windows for a while (billing); once it did:
- Windows: the RDP shared-drive code and its symlink test are Unix-only
  but were compiled there (and the unused pieces failed clippy). The drive
  type, its impls, imports and test are now cfg(unix); refusing a drive
  request is a free function used everywhere. Other Windows-only warnings
  fixed (serial test import, unused mut, harden_process).
- macOS: the local shell's directory was sampled on the first output
  after Enter, which is the shell echoing the command before a cd runs,
  so restored tabs opened one directory behind. It's now sampled shortly
  after Enter and again once the command has had time to finish.
- packaging/repo.sh publishes the 'nexusterminal' .deb/.rpm/Arch packages
  to repo.dixon.cx from the release's own Linux binaries, verified
  against SHA256SUMS.
- The desktop entry mentions RDP.
2026-10-13 11:36:45 +01:00

743 lines
34 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Andy Dixon
//! RDP client on IronRDP: TLS with NLA (CredSSP), the server certificate pinned through the
//! caller's host-key check, any transport the caller dials (direct, SOCKS5/HTTP proxy or an SSH
//! tunnel), and the desktop decoded into the same RGBA framebuffer the VNC client uses. Like the
//! other clients, `connect` blocks and the session runs on its own thread.
use super::vnc::{ClipboardFn, FrameFn, Framebuffer};
use super::{CloseFn, ErrorFn};
use crate::Result;
use ironrdp::cliprdr::CliprdrClient;
use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackend};
use ironrdp::cliprdr::pdu::{
ClipboardFormat, ClipboardFormatId, ClipboardGeneralCapabilityFlags, FileContentsRequest, FileContentsResponse, FormatDataRequest, FormatDataResponse,
LockDataId,
};
use ironrdp::connector::connection_activation::ConnectionActivationState;
use ironrdp::connector::{self, ClientConnector, ConnectionResult, Credentials, Sequence, ServerName};
use ironrdp::displaycontrol::client::DisplayControlClient;
use ironrdp::dvc::DrdynvcClient;
use ironrdp::graphics::image_processing::PixelFormat;
use ironrdp::input::Database;
pub use ironrdp::input::{MouseButton, MousePosition, Operation, Scancode, WheelRotations};
use ironrdp::pdu::gcc::KeyboardType;
use ironrdp::pdu::rdp::capability_sets::MajorPlatformType;
use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo};
use ironrdp::session::image::DecodedImage;
use ironrdp::session::{ActiveStageBuilder, ActiveStageOutput, fast_path};
use ironrdp_blocking::Framed;
use parking_lot::Mutex;
use sha2::{Digest, Sha256};
use std::net::TcpStream;
use std::sync::Arc;
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::Duration;
/// Opens the TCP stream to the RDP server (directly, via a proxy or through an SSH tunnel).
pub type Dial = Arc<dyn Fn() -> Result<TcpStream> + Send + Sync>;
/// Decides whether to trust the server certificate, given its `SHA256:<base64>` fingerprint.
pub type CertCheck = Arc<dyn Fn(&str) -> std::result::Result<(), String> + Send + Sync>;
#[derive(Clone)]
pub struct Config {
/// Used for the TLS server name and CredSSP's target name.
pub host: String,
pub username: String,
pub domain: String,
pub password: zeroize::Zeroizing<String>,
pub width: u16,
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,
/// Kerberos instead of NTLM: the KDC as `host` or `host:port` (TCP).
pub kerberos_kdc: Option<String>,
/// A local folder to share as a drive (Unix only), and the name it appears under.
pub shared_folder: Option<(std::path::PathBuf, String)>,
/// Offer a printer; jobs are saved as PostScript in this folder.
pub printer: Option<(std::path::PathBuf, super::rdp_devices::PrintedFn)>,
pub dial: Dial,
pub check_cert: CertCheck,
pub connect_timeout: Duration,
pub on_frame: FrameFn,
/// Text the remote desktop copied (the app applies its remote-clipboard preference).
pub on_clipboard: ClipboardFn,
pub on_error: ErrorFn,
pub on_close: CloseFn,
}
enum Input {
Ops(Vec<Operation>),
Resize(u16, u16),
/// Offer local clipboard text to the remote desktop.
Clipboard(String),
Close,
}
pub struct Client {
cfg: Config,
fb: Arc<Mutex<Framebuffer>>,
tx: Mutex<Option<Sender<Input>>>,
/// Local text offered to the remote clipboard, read by the clipboard channel on request.
clip_text: Arc<Mutex<Option<String>>>,
clip_rx: Mutex<Option<Receiver<ClipboardMessage>>>,
}
type Tls = rustls::StreamOwned<rustls::ClientConnection, TcpStream>;
/// How often the session thread looks for queued input while waiting for server data.
const POLL: Duration = Duration::from_millis(15);
impl Client {
pub fn new(cfg: Config) -> Arc<Client> {
Arc::new(Client { cfg, fb: Arc::default(), tx: Mutex::new(None), clip_text: Arc::default(), clip_rx: Mutex::new(None) })
}
pub fn framebuffer(&self) -> Arc<Mutex<Framebuffer>> {
self.fb.clone()
}
/// Connects and logs on, then runs the session on a new thread.
pub fn connect(self: &Arc<Self>) -> Result<()> {
let (framed, result) = self.handshake()?;
let (tx, rx) = mpsc::channel();
*self.tx.lock() = Some(tx);
let me = self.clone();
let (on_error, on_close) = (self.cfg.on_error.clone(), self.cfg.on_close.clone());
std::thread::Builder::new().name("nexus-rdp".into()).spawn(super::guarded(on_error, on_close, move || {
let r = me.run(framed, result, rx);
let closing = me.tx.lock().take().is_none();
if let Err(e) = r
&& !closing
{
(me.cfg.on_error)(e.to_string());
}
(me.cfg.on_close)();
}))?;
Ok(())
}
/// Mouse and keyboard input, applied in order. `Database` turns repeats into no-ops.
pub fn input(&self, ops: Vec<Operation>) {
self.send(Input::Ops(ops));
}
/// Asks the server to resize the desktop (Display Control channel); ignored when it can't.
pub fn resize(&self, width: u16, height: u16) {
self.send(Input::Resize(width, height));
}
/// Makes `text` the remote clipboard's contents (the remote pastes it on demand).
pub fn set_clipboard(&self, text: String) {
self.send(Input::Clipboard(text));
}
pub fn close(&self) {
if let Some(tx) = self.tx.lock().take() {
let _ = tx.send(Input::Close);
}
}
fn send(&self, i: Input) {
if let Some(tx) = &*self.tx.lock() {
let _ = tx.send(i);
}
}
fn connector_config(&self) -> connector::Config {
let c = &self.cfg;
connector::Config {
credentials: Credentials::UsernamePassword { username: c.username.clone(), password: c.password.to_string() },
domain: (!c.domain.is_empty()).then(|| c.domain.clone()),
enable_tls: c.allow_tls_only,
enable_credssp: true,
keyboard_type: KeyboardType::IbmEnhanced,
keyboard_subtype: 0,
keyboard_layout: 0,
keyboard_functional_keys_count: 12,
ime_file_name: String::new(),
dig_product_id: String::new(),
desktop_size: connector::DesktopSize { width: c.width.clamp(200, 8192), height: c.height.clamp(200, 8192) },
bitmap: None,
client_build: 0,
client_name: "Nexus Terminal".into(),
client_dir: "C:\\Windows\\System32\\mstscax.dll".into(),
platform: if cfg!(windows) {
MajorPlatformType::WINDOWS
} else if cfg!(target_os = "macos") {
MajorPlatformType::MACINTOSH
} else {
MajorPlatformType::UNIX
},
enable_server_pointer: false,
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: c.audio,
// ponytail: no bulk compression (the bitmap codecs already compress); enabling it means
// carrying the decompressor across reactivations.
compression_type: None,
pointer_software_rendering: true,
multitransport_flags: None,
performance_flags: PerformanceFlags::default(),
desktop_scale_factor: 0,
hardware_id: None,
license_cache: None,
timezone_info: TimezoneInfo::default(),
alternate_shell: String::new(),
work_dir: String::new(),
}
}
fn handshake(&self) -> Result<(Framed<Tls>, ConnectionResult)> {
let tcp = (self.cfg.dial)()?;
tcp.set_read_timeout(Some(self.cfg.connect_timeout.max(Duration::from_secs(5))))?;
tcp.set_nodelay(true)?;
let local = tcp.local_addr()?;
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())));
}
if self.cfg.shared_folder.is_some() || self.cfg.printer.is_some() {
let mut devices = super::rdp_devices::Devices::default();
#[cfg_attr(not(unix), allow(unused_mut))]
let mut drives = vec![];
#[cfg(unix)]
if let Some((folder, name)) = &self.cfg.shared_folder {
devices = devices.with_drive(folder).map_err(|e| format!("shared folder {}: {e}", folder.display()))?;
drives.push((1, name.clone()));
}
if let Some((dir, on_printed)) = &self.cfg.printer {
devices = devices.with_printer(dir.clone(), on_printed.clone());
}
let mut rdpdr = ironrdp::rdpdr::Rdpdr::new(Box::new(devices), "Nexus".into());
if !drives.is_empty() {
rdpdr = rdpdr.with_drives(Some(drives));
}
if self.cfg.printer.is_some() {
rdpdr = rdpdr.with_printer(2, "Nexus Terminal".into());
}
connector.attach_static_channel(rdpdr);
}
let (clip_tx, clip_rx) = mpsc::channel();
connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard {
tx: clip_tx,
text: self.clip_text.clone(),
on_text: self.cfg.on_clipboard.clone(),
})));
*self.clip_rx.lock() = Some(clip_rx);
let should_upgrade = ironrdp_blocking::connect_begin(&mut framed, &mut connector).map_err(|e| format!("RDP connection failed: {e}"))?;
let tcp = framed.into_inner_no_leftover();
let (tls, cert) = tls_upgrade(tcp, &self.cfg.host)?;
// Pin before CredSSP: no credentials leave the machine for an untrusted certificate.
(self.cfg.check_cert)(&fingerprint(&cert))?;
let upgraded = ironrdp_blocking::mark_as_upgraded(should_upgrade, &mut connector);
let mut framed = Framed::new(tls);
let kerberos = match &self.cfg.kerberos_kdc {
Some(kdc) => {
let kdc = if kdc.contains(':') { kdc.clone() } else { format!("{kdc}:88") };
let hostname = std::env::var("HOSTNAME").or_else(|_| std::env::var("COMPUTERNAME")).unwrap_or_else(|_| "nexus".into());
Some(connector::credssp::KerberosConfig::new(Some(format!("tcp://{kdc}")), hostname).map_err(|e| format!("Kerberos: {e}"))?)
}
None => None,
};
let result = ironrdp_blocking::connect_finalize(
upgraded,
connector,
&mut framed,
&mut KdcClient,
ServerName::new(self.cfg.host.clone()),
server_public_key(&cert)?,
kerberos,
)
.map_err(|e| format!("RDP logon failed: {}", describe(&e)))?;
Ok((framed, result))
}
fn run(&self, mut framed: Framed<Tls>, result: ConnectionResult, rx: Receiver<Input>) -> Result<()> {
framed.get_inner_mut().0.sock.set_read_timeout(Some(POLL))?;
let size = result.desktop_size;
// Check the server-chosen size before DecodedImage allocates it (65535² is 16 GiB, and
// an allocation failure aborts the whole app rather than failing this session).
*self.fb.lock() = Framebuffer::new_checked(size.width, size.height)?;
let mut image = DecodedImage::new(PixelFormat::RgbA32, size.width, size.height);
let factory = result.activation_factory;
let mut stage = ActiveStageBuilder {
static_channels: result.static_channels,
user_channel_id: result.user_channel_id,
io_channel_id: result.io_channel_id,
message_channel_id: result.message_channel_id,
share_id: result.share_id,
compression_type: result.compression_type,
enable_server_pointer: result.enable_server_pointer,
pointer_software_rendering: result.pointer_software_rendering,
}
.build();
let mut input = Database::new();
let clip_rx = self.clip_rx.lock().take();
// A resize waits here until the Display Control channel is open (it opens after logon).
let mut want_size: Option<(u16, u16)> = None;
loop {
let mut outputs = Vec::new();
loop {
match rx.try_recv() {
Ok(Input::Ops(ops)) => {
let events = input.apply(ops);
if !events.is_empty() {
outputs.extend(stage.process_fastpath_input(&mut image, &events)?);
}
}
Ok(Input::Resize(w, h)) => want_size = Some((w, h)).filter(|&s| s != (image.width(), image.height())),
Ok(Input::Clipboard(text)) => {
*self.clip_text.lock() = Some(text);
if let Some(c) = stage.get_svc_processor_mut::<CliprdrClient>()
&& let Ok(msgs) = c.initiate_copy(&[ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)])
{
outputs.push(ActiveStageOutput::ResponseFrame(stage.process_svc_processor_messages(msgs)?));
}
}
Ok(Input::Close) | Err(mpsc::TryRecvError::Disconnected) => {
for o in stage.graceful_shutdown()? {
if let ActiveStageOutput::ResponseFrame(f) = o {
let _ = framed.write_all(&f);
}
}
return Ok(());
}
Err(mpsc::TryRecvError::Empty) => break,
}
}
// Requests the clipboard backend queued while the stage processed server PDUs.
for msg in clip_rx.iter().flat_map(|rx| rx.try_iter()) {
let Some(c) = stage.get_svc_processor_mut::<CliprdrClient>() else { break };
let msgs = match msg {
ClipboardMessage::SendInitiateCopy(formats) => c.initiate_copy(&formats),
ClipboardMessage::SendFormatData(response) => c.submit_format_data(response),
ClipboardMessage::SendInitiatePaste(format) => c.initiate_paste(format),
_ => continue,
};
if let Ok(msgs) = msgs {
outputs.push(ActiveStageOutput::ResponseFrame(stage.process_svc_processor_messages(msgs)?));
}
}
if let Some((w, h)) = want_size {
let (w, h) = ironrdp::displaycontrol::pdu::MonitorLayoutEntry::adjust_display_size(w.into(), h.into());
if let Some(frame) = stage.encode_resize(w, h, None, None) {
outputs.push(ActiveStageOutput::ResponseFrame(frame?));
want_size = None;
}
}
match framed.read_pdu() {
Ok((action, payload)) => outputs.extend(stage.process(&mut image, action, &payload)?),
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
Err(e) => return Err(format!("RDP connection lost: {e}").into()),
}
for out in outputs {
match out {
ActiveStageOutput::ResponseFrame(frame) => framed.write_all(&frame)?,
ActiveStageOutput::GraphicsUpdate(r) => {
copy_rect(&image, &mut self.fb.lock(), r.left, r.top, r.right, r.bottom);
(self.cfg.on_frame)();
}
ActiveStageOutput::DeactivateAll => {
// Deactivation-Reactivation (e.g. after a resize): new desktop size and channel state.
framed.get_inner_mut().0.sock.set_read_timeout(Some(self.cfg.connect_timeout.max(Duration::from_secs(5))))?;
let mut seq = factory.create();
let mut buf = ironrdp::core::WriteBuf::new();
loop {
sequence_step(&mut framed, &mut seq, &mut buf).map_err(|e| format!("RDP reactivation failed: {e}"))?;
if let ConnectionActivationState::Finalized { desktop_size, share_id, enable_server_pointer, pointer_software_rendering } =
seq.connection_activation_state()
{
*self.fb.lock() = Framebuffer::new_checked(desktop_size.width, desktop_size.height)?;
image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height);
stage.set_fastpath_processor(
fast_path::ProcessorBuilder {
io_channel_id: seq.io_channel_id(),
user_channel_id: seq.user_channel_id(),
share_id,
enable_server_pointer,
pointer_software_rendering,
bulk_decompressor: None,
}
.build(),
);
stage.set_share_id(share_id);
stage.set_enable_server_pointer(enable_server_pointer);
break;
}
}
framed.get_inner_mut().0.sock.set_read_timeout(Some(POLL))?;
(self.cfg.on_frame)();
}
ActiveStageOutput::Terminate(reason) => return Err(format!("RDP session ended: {reason}").into()),
_ => {}
}
}
}
}
}
/// Text-only clipboard sharing (CF_UNICODETEXT) over CLIPRDR. Callbacks run inside the active
/// stage, so they queue requests for the session loop instead of answering directly.
struct TextClipboard {
tx: Sender<ClipboardMessage>,
text: Arc<Mutex<Option<String>>>,
on_text: ClipboardFn,
}
impl std::fmt::Debug for TextClipboard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("TextClipboard")
}
}
impl ironrdp::core::AsAny for TextClipboard {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
impl CliprdrBackend for TextClipboard {
fn temporary_directory(&self) -> &str {
""
}
fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags {
ClipboardGeneralCapabilityFlags::empty()
}
fn on_ready(&mut self) {}
fn on_request_format_list(&mut self) {
let formats = if self.text.lock().is_some() { vec![ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)] } else { vec![] };
let _ = self.tx.send(ClipboardMessage::SendInitiateCopy(formats));
}
fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) {}
fn on_remote_copy(&mut self, formats: &[ClipboardFormat]) {
if formats.iter().any(|f| f.id() == ClipboardFormatId::CF_UNICODETEXT) {
let _ = self.tx.send(ClipboardMessage::SendInitiatePaste(ClipboardFormatId::CF_UNICODETEXT));
}
}
fn on_format_data_request(&mut self, request: FormatDataRequest) {
let response = match &*self.text.lock() {
Some(t) if request.format == ClipboardFormatId::CF_UNICODETEXT => FormatDataResponse::new_unicode_string(t),
_ => FormatDataResponse::new_error(),
};
let _ = self.tx.send(ClipboardMessage::SendFormatData(ironrdp::core::IntoOwned::into_owned(response)));
}
fn on_format_data_response(&mut self, response: FormatDataResponse<'_>) {
if !response.is_error()
&& let Ok(text) = response.to_unicode_string()
{
(self.on_text)(text);
}
}
fn on_file_contents_request(&mut self, _: FileContentsRequest) {}
fn on_file_contents_response(&mut self, _: FileContentsResponse<'_>) {}
fn on_lock(&mut self, _: LockDataId) {}
fn on_unlock(&mut self, _: LockDataId) {}
}
/// One step of a connection sequence: read the PDU it waits for (if any), answer if it says to.
fn sequence_step(framed: &mut Framed<Tls>, seq: &mut dyn Sequence, buf: &mut ironrdp::core::WriteBuf) -> Result<()> {
buf.clear();
let written = match seq.next_pdu_hint() {
Some(hint) => {
let pdu = framed.read_by_hint(hint)?;
seq.step(&pdu, buf)?
}
None => seq.step_no_input(buf)?,
};
if written.size().is_some() {
framed.write_all(buf.filled())?;
}
Ok(())
}
/// Copies an updated rectangle (inclusive bounds) from the decoder's image into the shared
/// framebuffer, forcing opaque alpha (RDP leaves it undefined).
fn copy_rect(image: &DecodedImage, fb: &mut Framebuffer, left: u16, top: u16, right: u16, bottom: u16) {
if (fb.width, fb.height) != (image.width(), image.height()) {
return;
}
let (w, stride) = (fb.width as usize, image.stride());
let (x0, x1) = (left as usize, (right as usize).min(w.saturating_sub(1)));
let (y0, y1) = (top as usize, (bottom as usize).min(fb.height as usize - 1));
if x0 > x1 || y0 > y1 {
return;
}
let data = image.data();
for y in y0..=y1 {
let (s, d) = (y * stride + x0 * 4, (y * w + x0) * 4);
let n = (x1 - x0 + 1) * 4;
fb.rgba[d..d + n].copy_from_slice(&data[s..s + n]);
fb.rgba[d..d + n].as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255);
}
fb.dirty = true;
}
/// A transport through an RD Gateway (MS-TSGU over HTTPS WebSocket, Basic authentication). The
/// gateway's certificate is verified against the web PKI. The async gateway stream is bridged to
/// a loopback socket so the blocking RDP client can use it like any other connection.
pub fn gateway_dial(endpoint: String, user: String, password: zeroize::Zeroizing<String>, server: String, port: u16) -> Dial {
Arc::new(move || {
let endpoint = if endpoint.contains(':') { endpoint.clone() } else { format!("{endpoint}:443") };
let target = ironrdp_mstsgu::GwConnectTarget {
gw_endpoint: endpoint,
gw_user: user.clone(),
gw_pass: password.to_string(),
server: server.clone(),
server_port: port,
};
let (gw, _) = super::rt()
.block_on(ironrdp_mstsgu::GwClient::connect(&target, "Nexus Terminal"))
.map_err(|e| format!("RD Gateway {}: {}", target.gw_endpoint, error_chain(&e)))?;
let (client, server_end) = super::ssh::loopback_pair()?;
server_end.set_nonblocking(true)?;
super::rt().spawn(async move {
if let Ok(mut local) = tokio::net::TcpStream::from_std(server_end) {
let mut gw = gw;
let _ = tokio::io::copy_bidirectional(&mut local, &mut gw).await;
}
});
Ok(client)
})
}
/// `SHA256:<unpadded base64>` of the certificate, the format SSH host keys use.
pub fn fingerprint(cert_der: &[u8]) -> String {
use base64::Engine;
format!("SHA256:{}", base64::engine::general_purpose::STANDARD_NO_PAD.encode(Sha256::digest(cert_der)))
}
fn server_public_key(cert_der: &[u8]) -> Result<Vec<u8>> {
use x509_cert::der::Decode;
let cert = x509_cert::Certificate::from_der(cert_der).map_err(|e| format!("RDP server certificate is unreadable: {e}"))?;
Ok(cert.tbs_certificate.subject_public_key_info.subject_public_key.as_bytes().ok_or("RDP server public key is malformed")?.to_vec())
}
/// TLS without CA validation (RDP servers use self-signed certificates): the handshake still
/// proves possession of the certificate's key, and the caller pins its fingerprint.
fn tls_upgrade(mut tcp: TcpStream, host: &str) -> Result<(Tls, Vec<u8>)> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = rustls::ClientConfig::builder_with_provider(provider.clone())
.with_safe_default_protocol_versions()?
.dangerous()
.with_custom_certificate_verifier(Arc::new(Pinned(provider)))
.with_no_client_auth();
let name = rustls::pki_types::ServerName::try_from(host.to_string()).map_err(|e| format!("invalid RDP host name {host:?}: {e}"))?;
let mut conn = rustls::ClientConnection::new(Arc::new(config), name)?;
while conn.is_handshaking() {
conn.complete_io(&mut tcp).map_err(|e| format!("RDP TLS handshake failed: {e}"))?;
}
let cert = conn.peer_certificates().and_then(|c| c.first()).ok_or("RDP server sent no certificate")?.to_vec();
Ok((rustls::StreamOwned::new(conn, tcp), cert))
}
#[derive(Debug)]
struct Pinned(Arc<rustls::crypto::CryptoProvider>);
impl rustls::client::danger::ServerCertVerifier for Pinned {
fn verify_server_cert(
&self,
_: &rustls::pki_types::CertificateDer<'_>,
_: &[rustls::pki_types::CertificateDer<'_>],
_: &rustls::pki_types::ServerName<'_>,
_: &[u8],
_: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.0.signature_verification_algorithms)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.0.signature_verification_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}
/// CredSSP's channel to the Kerberos KDC (NTLM never uses it). TCP only, which every KDC serves;
/// replies are capped so a hostile KDC can't force a huge allocation.
struct KdcClient;
const MAX_KDC_REPLY: u32 = 1 << 20;
impl connector::sspi::network_client::NetworkClient for KdcClient {
fn send(&self, req: &connector::sspi::NetworkRequest) -> connector::sspi::Result<Vec<u8>> {
use connector::sspi::{Error, ErrorKind, network_client::NetworkProtocol};
use std::io::{Read, Write};
let fail = |e: String| Error::new(ErrorKind::NoAuthenticatingAuthority, e);
if req.protocol != NetworkProtocol::Tcp {
return Err(fail(format!("KDC over {:?} is not supported; use a tcp:// KDC", req.protocol)));
}
let host = req.url.host_str().ok_or_else(|| fail("KDC URL has no host".into()))?;
let port = req.url.port().unwrap_or(88);
let mut s = super::proxy::Proxy::default().connect(host, port, Duration::from_secs(5)).map_err(|e| fail(format!("KDC {host}:{port}: {e}")))?;
s.set_read_timeout(Some(Duration::from_secs(10))).map_err(|e| fail(e.to_string()))?;
// The request is already length-prefixed (RFC 4120 7.2.2); so is the reply.
s.write_all(&req.data).map_err(|e| fail(e.to_string()))?;
let mut len = [0u8; 4];
s.read_exact(&mut len).map_err(|e| fail(e.to_string()))?;
let n = u32::from_be_bytes(len);
if n > MAX_KDC_REPLY {
return Err(fail(format!("KDC reply of {n} bytes is too large")));
}
let mut buf = len.to_vec();
buf.resize(4 + n as usize, 0);
s.read_exact(&mut buf[4..]).map_err(|e| fail(e.to_string()))?;
Ok(buf)
}
}
/// IronRDP errors carry their cause in a chain; surface the useful part.
fn describe(e: &connector::ConnectorError) -> String {
error_chain(e)
}
fn error_chain(e: &dyn std::error::Error) -> String {
let mut s = e.to_string();
let mut src = e.source();
while let Some(c) = src {
s = format!("{s}: {c}");
src = c.source();
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kdc_client_speaks_length_prefixed_tcp_and_caps_replies() {
use connector::sspi::network_client::{NetworkClient, NetworkProtocol};
use std::io::{Read, Write};
let kdc = |reply: Vec<u8>| {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
let (mut s, _) = l.accept().unwrap();
let mut req = [0u8; 7];
s.read_exact(&mut req).unwrap();
assert_eq!(req, [0, 0, 0, 3, b'a', b's', b'k']);
s.write_all(&reply).unwrap();
});
connector::sspi::NetworkRequest {
protocol: NetworkProtocol::Tcp,
url: format!("tcp://127.0.0.1:{port}").parse().unwrap(),
data: b"\0\0\0\x03ask".to_vec(),
}
};
assert_eq!(KdcClient.send(&kdc(b"\0\0\0\x02ok".to_vec())).unwrap(), b"\0\0\0\x02ok");
let err = KdcClient.send(&kdc(vec![0xff, 0xff, 0xff, 0xff])).unwrap_err();
assert!(err.to_string().contains("too large"), "{err}");
let udp = connector::sspi::NetworkRequest { protocol: NetworkProtocol::Udp, url: "udp://127.0.0.1:88".parse().unwrap(), data: vec![] };
assert!(KdcClient.send(&udp).is_err());
}
#[test]
fn fingerprints_like_ssh() {
assert_eq!(fingerprint(b"abc"), "SHA256:ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0");
}
#[test]
fn copies_inclusive_rectangles_with_opaque_alpha() {
let mut image = DecodedImage::new(PixelFormat::RgbA32, 4, 3);
let mut fb = Framebuffer::new_checked(4, 3).unwrap();
fb.rgba.fill(0);
copy_rect(&image, &mut fb, 1, 1, 2, 1);
let alpha: Vec<u8> = fb.rgba.chunks(4).map(|p| p[3]).collect();
assert_eq!(alpha, [0, 0, 0, 0, 0, 255, 255, 0, 0, 0, 0, 0]);
// Out-of-range bounds are clipped, a size mismatch is ignored.
copy_rect(&image, &mut fb, 3, 2, 99, 99);
assert_eq!(fb.rgba[(2 * 4 + 3) * 4 + 3], 255);
image = DecodedImage::new(PixelFormat::RgbA32, 8, 8);
copy_rect(&image, &mut fb, 0, 0, 7, 7);
assert_eq!(fb.rgba[3], 0);
}
/// `NEXUS_RDP_TEST="host port user password" cargo test -p nexus-core rdp::tests::live -- --ignored --nocapture`
#[test]
#[ignore]
fn live() {
let spec = std::env::var("NEXUS_RDP_TEST").expect("NEXUS_RDP_TEST");
let v: Vec<&str> = spec.split_whitespace().collect();
let (host, port) = (v[0].to_string(), v[1].parse::<u16>().unwrap());
let frames = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (f2, h2) = (frames.clone(), host.clone());
let seen_cert = Arc::new(Mutex::new(String::new()));
let sc = seen_cert.clone();
let c = Client::new(Config {
host: host.clone(),
username: v[2].into(),
domain: String::new(),
password: zeroize::Zeroizing::new(v[3].into()),
width: 1024,
height: 768,
allow_tls_only: true,
audio: std::env::var("NEXUS_RDP_AUDIO").is_ok(),
kerberos_kdc: None,
shared_folder: std::env::var("NEXUS_RDP_SHARE").ok().map(|p| (p.into(), "nexus".into())),
printer: std::env::var("NEXUS_RDP_PRINT").ok().map(|p| (p.into(), Arc::new(|f: std::path::PathBuf| eprintln!("printed {}", f.display())) as _)),
dial: Arc::new(move || Ok(TcpStream::connect((h2.as_str(), port))?)),
check_cert: Arc::new(move |fp| {
*sc.lock() = fp.to_string();
Ok(())
}),
connect_timeout: Duration::from_secs(15),
on_frame: Arc::new(move || {
f2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}),
on_clipboard: Arc::new(|t| eprintln!("remote clipboard: {t:?}")),
on_error: Arc::new(|e| eprintln!("on_error: {e}")),
on_close: Arc::new(|| eprintln!("on_close")),
});
c.connect().unwrap();
eprintln!("cert {}", seen_cert.lock());
let t = std::time::Instant::now();
while frames.load(std::sync::atomic::Ordering::SeqCst) < 5 && t.elapsed() < Duration::from_secs(20) {
std::thread::sleep(Duration::from_millis(100));
}
c.input(vec![Operation::MouseMove(MousePosition { x: 100, y: 100 })]);
c.input(vec![Operation::KeyPressed(Scancode::from_u8(false, 0x1e)), Operation::KeyReleased(Scancode::from_u8(false, 0x1e))]);
std::thread::sleep(Duration::from_secs(2));
if std::env::var("NEXUS_RDP_CLIP").is_ok() {
c.set_clipboard("from-nexus-123".into());
std::thread::sleep(Duration::from_secs(25));
}
let fb = c.framebuffer().lock().clone();
eprintln!("frames {} size {}x{}", frames.load(std::sync::atomic::Ordering::SeqCst), fb.width, fb.height);
if let Ok(out) = std::env::var("NEXUS_RDP_PNG") {
image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(fb.width.into(), fb.height.into(), fb.rgba.clone()).unwrap())
.to_rgb8()
.save(out)
.unwrap();
}
assert!(frames.load(std::sync::atomic::Ordering::SeqCst) > 0);
c.close();
}
}