Files
nexus/rust/core/src/clients/mod.rs
T
Andy Dixon 50c0104d57 feat(core): RDP client on IronRDP
clients::rdp connects over any caller-dialled TCP stream (direct, proxy
or SSH tunnel), upgrades to TLS and logs on with NLA (CredSSP/NTLM),
optionally falling back to TLS-only logon for servers without NLA such
as xrdp (credentials are passed so they log straight in). The server
certificate is pinned through a caller check on its SHA256 fingerprint
before any credentials are sent. The desktop decodes into the VNC
client's RGBA framebuffer; input (IronRDP's scancode/Unicode/mouse
operations), Display Control resizes and the deactivation-reactivation
sequence run on the session thread.

IronRDP's releases need three vendored patches (vendor/README.md):
picky and sspi with their RustCrypto release-candidate pins relaxed so
they share russh's stable versions (their own suites, 402 tests, pass
against them), and ironrdp-session with two unreleased upstream bitmap
fixes (#1486 stride, #1524 RDP6 orientation) without which xrdp desktops
render sheared.

Verified live against xrdp (scottyhardy/docker-remote-desktop): logon,
full desktop rendering, keyboard and mouse input.
2026-10-12 10:50:24 +01:00

154 lines
5.7 KiB
Rust

//! Protocol clients. Conventions, shared by every client so the session manager treats them alike:
//! - `connect()` blocks the calling thread (the manager calls it from a worker thread, as Go
//! called it from a goroutine) and returns once the connection is usable.
//! - After connecting, the client owns its own reader thread(s) and reports through callbacks:
//! data, a fatal error, and close (exactly once, whether remote or local).
//! - `write`/`resize`/`close` are callable from any thread; `close` is idempotent and must not
//! fire `on_close` re-entrantly while holding locks.
pub mod github;
pub mod irc;
pub mod local;
pub mod sftp;
pub mod ssh;
use std::sync::{Arc, OnceLock};
/// The shared tokio runtime for async protocol stacks (russh). Blocking clients use threads.
pub fn rt() -> &'static tokio::runtime::Runtime {
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
RT.get_or_init(|| tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().thread_name("nexus-net").build().expect("tokio runtime"))
}
pub type DataFn = Arc<dyn Fn(&[u8]) + Send + Sync>;
pub type ErrorFn = Arc<dyn Fn(String) + Send + Sync>;
pub type CloseFn = Arc<dyn Fn() + Send + Sync>;
#[derive(Clone)]
pub struct Callbacks {
pub on_data: DataFn,
pub on_error: ErrorFn,
pub on_close: CloseFn,
}
impl Callbacks {
pub fn noop() -> Callbacks {
Callbacks { on_data: Arc::new(|_| {}), on_error: Arc::new(|_| {}), on_close: Arc::new(|| {}) }
}
}
pub mod proxy;
pub mod raw;
pub mod rdp;
pub mod serial;
pub mod telnet;
pub mod vnc;
/// Called with protocol negotiation debug messages (Go's `onDebug`).
pub type DebugFn = Arc<dyn Fn(String) + Send + Sync>;
/// TCP dial shared by the Telnet and RAW clients: Go's `net.DialTimeout` with a 30 s default.
/// Returns the stream and the `host:port` string used in error messages.
pub(crate) fn dial(host: &str, port: i64, timeout_secs: i64) -> (String, std::io::Result<std::net::TcpStream>) {
use std::net::{TcpStream, ToSocketAddrs};
let timeout = std::time::Duration::from_secs(if timeout_secs > 0 { timeout_secs as u64 } else { 30 });
// Go's net.JoinHostPort brackets IPv6 literals.
let addr = if host.contains(':') { format!("[{host}]:{port}") } else { format!("{host}:{port}") };
let result = (|| {
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, "no addresses found");
for sa in addr.to_socket_addrs()? {
match TcpStream::connect_timeout(&sa, timeout) {
Ok(s) => return Ok(s),
Err(e) => last = e,
}
}
Err(last)
})();
(addr, result)
}
/// The TCP connection lifecycle shared by the Telnet and RAW clients: one writer-side stream behind
/// a lock, a reader thread that feeds `on_read`, and Go's error/close semantics (a read error or EOF
/// reports `read error: …` then closes; `close` is idempotent and fires `on_close` once).
pub(crate) struct TcpConn {
stream: parking_lot::Mutex<Option<std::net::TcpStream>>,
on_error: ErrorFn,
on_close: CloseFn,
}
impl TcpConn {
pub(crate) fn new(on_error: ErrorFn, on_close: CloseFn) -> TcpConn {
TcpConn { stream: parking_lot::Mutex::new(None), on_error, on_close }
}
pub(crate) fn connect(self: &Arc<Self>, stream: std::net::TcpStream, mut on_read: impl FnMut(&[u8]) + Send + 'static) -> crate::Result<()> {
let mut reader = stream.try_clone()?;
{
let mut guard = self.stream.lock();
if guard.is_some() {
return Err("already connected".into());
}
*guard = Some(stream);
}
let me = self.clone();
std::thread::spawn(move || {
use std::io::Read;
let mut buf = [0u8; 4096];
loop {
let err = match reader.read(&mut buf) {
Ok(0) => "EOF".to_string(),
Ok(n) => {
on_read(&buf[..n]);
continue;
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => e.to_string(),
};
// A local close also ends the read; Go reported that as an error too, we don't.
if me.is_connected() {
(me.on_error)(format!("read error: {err}"));
me.close();
}
return;
}
});
Ok(())
}
pub(crate) fn write(&self, data: &[u8]) -> crate::Result<()> {
use std::io::Write;
let guard = self.stream.lock();
let mut s = guard.as_ref().ok_or("not connected")?;
s.write_all(data)?;
Ok(())
}
pub(crate) fn is_connected(&self) -> bool {
self.stream.lock().is_some()
}
pub(crate) fn close(&self) {
let taken = self.stream.lock().take();
if let Some(s) = taken {
let _ = s.shutdown(std::net::Shutdown::Both);
(self.on_close)();
}
}
}
/// True for the encoding names Go mapped to ISO 8859-1.
pub(crate) fn is_latin1(encoding: &str) -> bool {
matches!(encoding.trim().to_ascii_lowercase().as_str(), "iso-8859-1" | "latin1" | "latin-1")
}
/// UTF-8 → ISO 8859-1. Like Go's charmap encoder, input that isn't valid UTF-8 or holds a
/// character above U+00FF is returned unchanged.
pub(crate) fn utf8_to_latin1(data: &[u8]) -> Vec<u8> {
std::str::from_utf8(data).ok().and_then(|s| s.chars().map(|c| u8::try_from(c).ok()).collect()).unwrap_or_else(|| data.to_vec())
}
/// ISO 8859-1 → UTF-8 (every byte maps to the code point of the same value).
pub(crate) fn latin1_to_utf8(data: &[u8]) -> String {
data.iter().map(|&b| b as char).collect()
}