Skip to main content
This guide will walk you through creating a simple RDP client that connects to a server, captures graphics updates, and saves them as an image.

Prerequisites

Ensure you have:
  • Rust 1.88.0 or later installed
  • An RDP server to connect to (Windows machine, RDP server, etc.)
  • Basic familiarity with Rust and async programming

Project Setup

1

Create a new Rust project

cargo new rdp-screenshot
cd rdp-screenshot
2

Add dependencies

Edit Cargo.toml to include IronRDP and required dependencies:
[dependencies]
ironrdp = { version = "0.14", features = ["connector", "session", "graphics"] }
ironrdp-blocking = "0.8"
ironrdp-pdu = "0.7"
tokio-rustls = "0.26"
sspi = { version = "0.18", features = ["network_client"] }
image = { version = "0.25", default-features = false, features = ["png"] }
x509-cert = { version = "0.2", default-features = false, features = ["std"] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3

Set up logging (optional but recommended)

use tracing_subscriber::{prelude::*, EnvFilter};
use tracing::metadata::LevelFilter;

fn setup_logging() -> anyhow::Result<()> {
    let fmt_layer = tracing_subscriber::fmt::layer().compact();
    let env_filter = EnvFilter::builder()
        .with_default_directive(LevelFilter::WARN.into())
        .with_env_var("IRONRDP_LOG")
        .from_env_lossy();

    tracing_subscriber::registry()
        .with(fmt_layer)
        .with(env_filter)
        .try_init()?;

    Ok(())
}
Run with logging:
IRONRDP_LOG=info cargo run

Building a Simple Screenshot Client

Step 1: Configure the Connection

Create a configuration for connecting to the RDP server:
use ironrdp::connector::{self, Credentials, DesktopSize};
use ironrdp::pdu::gcc::KeyboardType;
use ironrdp::pdu::rdp::capability_sets::MajorPlatformType;
use ironrdp_pdu::rdp::client_info::{CompressionType, PerformanceFlags, TimezoneInfo};

fn build_config(
    username: String,
    password: String,
    domain: Option<String>,
) -> anyhow::Result<connector::Config> {
    Ok(connector::Config {
        credentials: Credentials::UsernamePassword { username, password },
        domain,
        enable_tls: false,
        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: DesktopSize {
            width: 1280,
            height: 1024,
        },
        bitmap: None,
        client_build: 0,
        client_name: "ironrdp-quickstart".to_owned(),
        client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(),
        platform: MajorPlatformType::UNIX,
        enable_server_pointer: false,
        request_data: None,
        autologon: false,
        enable_audio_playback: false,
        compression_type: Some(CompressionType::Rdp61),
        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(),
    })
}
The configuration includes many fields for fine-grained control. Most can use sensible defaults as shown above.

Step 2: Establish the Connection

Connect to the RDP server using blocking I/O:
use std::net::TcpStream;
use std::time::Duration;
use ironrdp::connector::{ClientConnector, ConnectionResult};
use ironrdp_blocking::Framed;
use sspi::network_client::reqwest_network_client::ReqwestNetworkClient;
use tokio_rustls::rustls;
use anyhow::Context;

type UpgradedFramed = Framed<rustls::StreamOwned<rustls::ClientConnection, TcpStream>>;

fn connect(
    config: connector::Config,
    server_name: String,
    port: u16,
) -> anyhow::Result<(ConnectionResult, UpgradedFramed)> {
    // Resolve server address
    let server_addr = (server_name.as_str(), port)
        .to_socket_addrs()?
        .next()
        .context("failed to resolve server address")?;

    // Establish TCP connection
    let tcp_stream = TcpStream::connect(server_addr)
        .context("failed to connect to server")?;

    tcp_stream
        .set_read_timeout(Some(Duration::from_secs(3)))
        .expect("set_read_timeout failed");

    let client_addr = tcp_stream.local_addr()?;
    let mut framed = Framed::new(tcp_stream);
    let mut connector = ClientConnector::new(config, client_addr);

    // Begin connection sequence
    let should_upgrade = ironrdp_blocking::connect_begin(&mut framed, &mut connector)
        .context("connection begin failed")?;

    // Perform TLS upgrade
    let initial_stream = framed.into_inner_no_leftover();
    let (upgraded_stream, server_public_key) = 
        tls_upgrade(initial_stream, server_name.clone())?;

    let upgraded = ironrdp_blocking::mark_as_upgraded(should_upgrade, &mut connector);
    let mut upgraded_framed = Framed::new(upgraded_stream);

    // Finalize connection with authentication
    let mut network_client = ReqwestNetworkClient;
    let connection_result = ironrdp_blocking::connect_finalize(
        upgraded,
        connector,
        &mut upgraded_framed,
        &mut network_client,
        server_name.into(),
        server_public_key,
        None,
    )?;

    Ok((connection_result, upgraded_framed))
}

Step 3: TLS Upgrade Helper

Implement TLS upgrade with certificate verification disabled (for testing):
fn tls_upgrade(
    stream: TcpStream,
    server_name: String,
) -> anyhow::Result<(rustls::StreamOwned<rustls::ClientConnection, TcpStream>, Vec<u8>)> {
    use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified};

    #[derive(Debug)]
    struct NoCertificateVerification;

    impl tokio_rustls::rustls::client::danger::ServerCertVerifier for NoCertificateVerification {
        fn verify_server_cert(
            &self,
            _: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
            _: &[tokio_rustls::rustls::pki_types::CertificateDer<'_>],
            _: &tokio_rustls::rustls::pki_types::ServerName<'_>,
            _: &[u8],
            _: tokio_rustls::rustls::pki_types::UnixTime,
        ) -> Result<ServerCertVerified, tokio_rustls::rustls::Error> {
            Ok(ServerCertVerified::assertion())
        }

        fn verify_tls12_signature(
            &self,
            _: &[u8],
            _: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
            _: &tokio_rustls::rustls::DigitallySignedStruct,
        ) -> Result<HandshakeSignatureValid, tokio_rustls::rustls::Error> {
            Ok(HandshakeSignatureValid::assertion())
        }

        fn verify_tls13_signature(
            &self,
            _: &[u8],
            _: &tokio_rustls::rustls::pki_types::CertificateDer<'_>,
            _: &tokio_rustls::rustls::DigitallySignedStruct,
        ) -> Result<HandshakeSignatureValid, tokio_rustls::rustls::Error> {
            Ok(HandshakeSignatureValid::assertion())
        }

        fn supported_verify_schemes(&self) -> Vec<tokio_rustls::rustls::SignatureScheme> {
            use tokio_rustls::rustls::SignatureScheme::*;
            vec![
                RSA_PKCS1_SHA256, ECDSA_NISTP256_SHA256,
                RSA_PSS_SHA256, ED25519,
            ]
        }
    }

    let mut config = rustls::client::ClientConfig::builder()
        .dangerous()
        .with_custom_certificate_verifier(std::sync::Arc::new(NoCertificateVerification))
        .with_no_client_auth();

    config.resumption = rustls::client::Resumption::disabled();
    let config = std::sync::Arc::new(config);

    let server_name = server_name.try_into()?;
    let client = rustls::ClientConnection::new(config, server_name)?;
    let mut tls_stream = rustls::StreamOwned::new(client, stream);

    use std::io::Write;
    tls_stream.flush()?;

    let cert = tls_stream
        .conn
        .peer_certificates()
        .and_then(|certs| certs.first())
        .context("peer certificate missing")?;

    use x509_cert::der::Decode;
    let cert = x509_cert::Certificate::from_der(cert)?;
    let server_public_key = cert
        .tbs_certificate
        .subject_public_key_info
        .subject_public_key
        .as_bytes()
        .context("subject public key not aligned")?
        .to_owned();

    Ok((tls_stream, server_public_key))
}
This example disables certificate verification for simplicity. In production, always validate server certificates properly.

Step 4: Process the Active Stage

Handle the active session and capture graphics updates:
use ironrdp::session::{ActiveStage, ActiveStageOutput};
use ironrdp::session::image::DecodedImage;
use std::io::Write;

fn active_stage(
    connection_result: ConnectionResult,
    mut framed: UpgradedFramed,
    image: &mut DecodedImage,
) -> anyhow::Result<()> {
    let mut active_stage = ActiveStage::new(connection_result);

    loop {
        let (action, payload) = match framed.read_pdu() {
            Ok(frame) => frame,
            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
            Err(e) => return Err(e.into()),
        };

        let outputs = active_stage.process(image, action, &payload)?;

        for output in outputs {
            match output {
                ActiveStageOutput::ResponseFrame(frame) => {
                    framed.write_all(&frame)?;
                }
                ActiveStageOutput::Terminate(_) => return Ok(()),
                _ => {}
            }
        }
    }

    Ok(())
}

Step 5: Complete Example

use std::net::{TcpStream, ToSocketAddrs};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Context;

use ironrdp::connector::{self, Credentials, ConnectionResult};
use ironrdp::pdu::gcc::KeyboardType;
use ironrdp::pdu::rdp::capability_sets::MajorPlatformType;
use ironrdp::session::{ActiveStage, ActiveStageOutput};
use ironrdp::session::image::DecodedImage;
use ironrdp_blocking::Framed;
use ironrdp_pdu::rdp::client_info::{CompressionType, PerformanceFlags, TimezoneInfo};
use sspi::network_client::reqwest_network_client::ReqwestNetworkClient;
use tokio_rustls::rustls;

fn main() -> anyhow::Result<()> {
    let config = build_config(
        "username".to_owned(),
        "password".to_owned(),
        None,
    )?;

    let (connection_result, framed) = connect(
        config,
        "192.168.1.100".to_owned(),
        3389,
    )?;

    let mut image = DecodedImage::new(
        ironrdp_graphics::image_processing::PixelFormat::RgbA32,
        connection_result.desktop_size.width,
        connection_result.desktop_size.height,
    );

    active_stage(connection_result, framed, &mut image)?;

    // Save image
    let img: image::ImageBuffer<image::Rgba<u8>, _> =
        image::ImageBuffer::from_raw(
            u32::from(image.width()),
            u32::from(image.height()),
            image.data(),
        )
        .context("invalid image")?;

    img.save("output.png")?;
    println!("Saved screenshot to output.png");

    Ok(())
}

// Include build_config, connect, tls_upgrade, and active_stage from above

Running the Example

1

Build the project

cargo build --release
2

Run with your RDP server details

./target/release/rdp-screenshot
Or with logging:
IRONRDP_LOG=info ./target/release/rdp-screenshot
3

Check the output

You should see output.png in your project directory containing a screenshot of the remote desktop.

Key Concepts Explained

Connection Lifecycle

1

Initial Connection

connect_begin() starts the RDP handshake over TCP
2

TLS Upgrade

The connection is upgraded to TLS for security
3

Authentication

connect_finalize() completes authentication (CredSSP, NLA, etc.)
4

Active Stage

The session enters active mode, processing graphics updates and user input

Understanding ActiveStage

The ActiveStage is the heart of an RDP session:
pub struct ActiveStage { /* ... */ }

impl ActiveStage {
    pub fn process(
        &mut self,
        image: &mut DecodedImage,
        action: Action,
        payload: &[u8],
    ) -> Result<Vec<ActiveStageOutput>> {
        // Processes incoming PDUs and updates the image buffer
    }
}
Key outputs:
  • ActiveStageOutput::ResponseFrame - Data to send back to the server
  • ActiveStageOutput::GraphicsUpdate - Screen content changed
  • ActiveStageOutput::Terminate - Connection closed

Image Decoding

DecodedImage manages the remote desktop framebuffer:
use ironrdp_graphics::image_processing::PixelFormat;

let mut image = DecodedImage::new(
    PixelFormat::RgbA32,  // 32-bit RGBA format
    1920,                  // Width
    1080,                  // Height
);

// After processing updates, access the raw pixel data:
let pixel_data: &[u8] = image.data();

Building an Async Client

For production use, prefer async I/O with Tokio:
[dependencies]
ironrdp = { version = "0.14", features = ["connector", "session"] }
ironrdp-tokio = "0.5"
tokio = { version = "1", features = ["full"] }
use ironrdp_tokio::TokioFramed;
use tokio::net::TcpStream;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let stream = TcpStream::connect("192.168.1.100:3389").await?;
    let framed = TokioFramed::new(stream);
    
    // Use async versions of connect_begin/finalize
    // ...

    Ok(())
}
Refer to the full ironrdp-client implementation in the repository for a complete async example with GUI support.

Common Issues

Symptom: TcpStream::connect hangs or times outSolutions:
  • Verify the server is reachable (ping the host)
  • Check firewall rules (port 3389 must be open)
  • Ensure RDP is enabled on the target machine
Symptom: Error during connect_finalizeSolutions:
  • Verify username and password are correct
  • Check domain name (use None for local accounts)
  • Ensure CredSSP is enabled on the server
Symptom: Screenshot is saved but appears blankSolutions:
  • Increase the read timeout (some servers send updates slowly)
  • Add a delay before saving the image to capture more updates
  • Check logs for errors during graphics decoding
Symptom: Error during tls_upgradeSolutions:
  • Ensure the server supports TLS 1.2 or later
  • Try with enable_tls: false for testing (not recommended for production)
  • Check server certificate validity

Next Steps

Now that you’ve built a basic client, explore:

Building Servers

Create custom RDP servers with IronRDP

Virtual Channels

Implement clipboard, audio, and custom channels

Examples

Study the full screenshot and server examples

API Documentation

Deep dive into the complete API reference

Real-World Examples

The IronRDP repository includes production-ready examples:

Screenshot Example

Located at crates/ironrdp/examples/screenshot.rs - a complete blocking client in ~500 lines:
cargo run --example=screenshot -- \
  --host 192.168.1.100 \
  -u Administrator \
  -p MyPassword123 \
  -o screenshot.png \
  --compression-enabled true \
  --compression-level 3

Server Example

Located at crates/ironrdp/examples/server.rs - a minimal RDP server:
cargo run --example=server -- \
  --bind-addr 0.0.0.0:3389 \
  --user testuser \
  --pass testpass \
  --sec hybrid
Study these examples for production patterns, error handling, and best practices.

Build docs developers (and LLMs) love