Skip to main content
IronRDP implements the RDP connection and session lifecycle using explicit state machines that handle each protocol phase. This design provides precise control and testability without hidden I/O operations.

State Machine Architecture

The connection process is split across two primary crates:
  • ironrdp-connector: Handles connection establishment up to the “Connected” state
  • ironrdp-session: Manages the active RDP session after connection

Why State Machines?

IronRDP’s state machine design enforces several architectural invariants:
  1. No hidden I/O: Core tier crates never perform network operations
  2. Explicit state transitions: Each step is visible and controllable
  3. Testability: States can be tested in isolation
  4. Async-agnostic: Works with blocking, async, or custom I/O strategies
pub trait Sequence: Send {
    fn next_pdu_hint(&self) -> Option<&dyn PduHint>;
    fn state(&self) -> &dyn State;
    fn step(&mut self, input: &[u8], output: &mut WriteBuf) -> ConnectorResult<Written>;
}
See ironrdp-connector/src/lib.rs:327-337.

Connection States

The ClientConnector state machine progresses through these states:

State Diagram

Step-by-Step Connection Flow

1
1. Initialize Connector
2
Create a ClientConnector with your configuration:
3
use ironrdp_connector::{ClientConnector, Config, DesktopSize, Credentials};
use std::net::{SocketAddr, IpAddr, Ipv4Addr};

let config = Config {
    desktop_size: DesktopSize { width: 1920, height: 1080 },
    desktop_scale_factor: 100,
    enable_tls: true,
    enable_credssp: true,
    credentials: Credentials::UsernamePassword {
        username: "admin".to_string(),
        password: "P@ssw0rd".to_string(),
    },
    domain: Some("CORP".to_string()),
    client_build: 7601,
    client_name: "ironrdp-client".to_string(),
    // ... (other fields with defaults)
};

let client_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 0);
let mut connector = ClientConnector::new(config, client_addr);
4
Initial state: ConnectionInitiationSendRequest
5
2. Connection Initiation
6
The connector sends an X.224 Connection Request:
7
use ironrdp_core::WriteBuf;
use ironrdp_connector::{Sequence, Written};

let mut output = WriteBuf::new();
let written = connector.step(&[], &mut output)?;

if let Written::Size(size) = written {
    let request_pdu = output.filled();
    // Send request_pdu to server via TCP socket
    stream.write_all(request_pdu)?;
}
8
What happens internally:
9
// ironrdp-connector/src/connection.rs:245-291
let mut security_protocol = nego::SecurityProtocol::empty();

if self.config.enable_tls {
    security_protocol.insert(nego::SecurityProtocol::SSL);
}

if self.config.enable_credssp {
    security_protocol.insert(
        nego::SecurityProtocol::HYBRID | nego::SecurityProtocol::HYBRID_EX
    );
}

let connection_request = nego::ConnectionRequest {
    nego_data: /* cookie or None */,
    flags: nego::RequestFlags::empty(),
    protocol: security_protocol,
};
10
State transitions to: ConnectionInitiationWaitConfirm
11
3. Receive Connection Confirm
12
Read the server’s X.224 Connection Confirm:
13
let mut input_buf = vec![0u8; 4096];
let bytes_read = stream.read(&mut input_buf)?;
let input = &input_buf[..bytes_read];

let written = connector.step(input, &mut output)?;
14
What happens internally:
15
// ironrdp-connector/src/connection.rs:293-323
let connection_confirm = decode::<X224<nego::ConnectionConfirm>>(input)?;

let (flags, selected_protocol) = match connection_confirm {
    nego::ConnectionConfirm::Response { flags, protocol } => (flags, protocol),
    nego::ConnectionConfirm::Failure { code } => {
        return Err(/* NegotiationFailure error */);
    }
};

// Verify server selected a protocol we offered
if !selected_protocol.intersects(requested_protocol) {
    return Err(/* protocol mismatch error */);
}
16
State transitions to: EnhancedSecurityUpgrade
17
4. TLS Upgrade
18
When the state is EnhancedSecurityUpgrade, user code must perform the TLS handshake:
19
if connector.should_perform_security_upgrade() {
    // Perform TLS handshake on the TCP stream
    let tls_stream = tls_connector.connect(server_name, stream)?;
    
    // Mark upgrade as complete
    connector.mark_security_upgrade_as_done();
    
    // Update stream reference
    stream = tls_stream;
}
20
IronRDP doesn’t perform I/O, so TLS setup is the application’s responsibility. You can use:
21
  • rustls with tokio-rustls or async-rustls
  • native-tls
  • Any TLS implementation
  • 22
    See crates/ironrdp-tls for TLS boilerplate helpers.
    23
    State transitions to: Credssp or BasicSettingsExchangeSendInitial
    24
    5. CredSSP Authentication (NLA)
    25
    If CredSSP is negotiated:
    26
    if connector.should_perform_credssp() {
        // Perform CredSSP authentication using the sspi crate
        // This is also handled by user code
        perform_credssp_auth(&mut stream, &config.credentials)?;
        
        connector.mark_credssp_as_done();
    }
    
    27
    IronRDP exposes sspi re-exports but leaves CredSSP execution to user code. See ironrdp-connector/src/credssp.rs for reference.
    28
    State transitions to: BasicSettingsExchangeSendInitial
    29
    6. Basic Settings Exchange
    30
    Client sends MCS Connect-Initial with GCC blocks:
    31
    let written = connector.step(&[], &mut output)?;
    if let Written::Size(_) = written {
        stream.write_all(output.filled())?;
    }
    
    32
    GCC blocks sent:
    33
  • ClientCoreData: Desktop size, color depth, keyboard layout, client name, RDP version
  • ClientSecurityData: Encryption methods (empty for Enhanced RDP Security)
  • ClientNetworkData: List of static virtual channels to open
  • 34
    // ironrdp-connector/src/connection.rs:668-728
    ClientGccBlocks {
        core: ClientCoreData {
            version: RdpVersion::V5_PLUS,
            desktop_width: config.desktop_size.width,
            desktop_height: config.desktop_size.height,
            keyboard_layout: config.keyboard_layout,
            client_name: config.client_name.clone(),
            optional_data: ClientCoreOptionalData {
                supported_color_depths: Some(supported_color_depths),
                early_capability_flags: Some(early_capability_flags),
                server_selected_protocol: Some(selected_protocol),
                // ...
            },
        },
        security: ClientSecurityData { /* ... */ },
        network: Some(ClientNetworkData { channels }),
        // ...
    }
    
    35
    State transitions to: BasicSettingsExchangeWaitResponse
    36
    Server responds with MCS Connect-Response containing:
    37
  • ServerCoreData: RDP version, early capability flags
  • ServerSecurityData: Server random, certificates (if applicable)
  • ServerNetworkData: Assigned MCS channel IDs for static channels
  • 38
    output.clear();
    let bytes_read = stream.read(&mut input_buf)?;
    let written = connector.step(&input_buf[..bytes_read], &mut output)?;
    
    39
    The connector maps client static channels to their assigned server channel IDs:
    40
    // ironrdp-connector/src/connection.rs:401-409
    let zipped: Vec<_> = self
        .static_channels
        .type_ids()
        .zip(static_channel_ids.iter().copied())
        .collect();
    
    zipped.into_iter().for_each(|(channel, channel_id)| {
        self.static_channels.attach_channel_id(channel, channel_id);
    });
    
    41
    State transitions to: ChannelConnection
    42
    7. Channel Connection
    43
    Join each static channel using MCS Channel Join Request/Confirm:
    44
    while !matches!(connector.state(), ClientConnectorState::SecureSettingsExchange { .. }) {
        output.clear();
        let written = connector.step(&[], &mut output)?;
        
        if let Written::Size(_) = written {
            stream.write_all(output.filled())?;
            let bytes_read = stream.read(&mut input_buf)?;
            connector.step(&input_buf[..bytes_read], &mut output)?;
        }
    }
    
    45
    This step can be skipped if the server advertises ServerEarlyCapabilityFlags::SKIP_CHANNELJOIN_SUPPORTED.
    46
    State transitions to: SecureSettingsExchange
    47
    8. Secure Settings Exchange
    48
    Client sends Client Info PDU with credentials and settings:
    49
    // ironrdp-connector/src/connection.rs:741-815
    let client_info = ClientInfo {
        credentials: Credentials {
            username: config.credentials.username().unwrap_or("").to_owned(),
            password: config.credentials.secret().to_owned(),
            domain: config.domain.clone(),
        },
        flags: flags,  // Includes COMPRESSION if compression_type is set
        compression_type: config.compression_type.unwrap_or(CompressionType::K8),
        alternate_shell: config.alternate_shell.clone(),
        work_dir: config.work_dir.clone(),
        extra_info: ExtendedClientInfo {
            address: client_addr.ip().to_string(),
            dir: config.client_dir.clone(),
            optional_data: ExtendedClientOptionalInfo::builder()
                .timezone(config.timezone_info.clone())
                .performance_flags(config.performance_flags)
                .build(),
        },
    };
    
    50
    State transitions through: ConnectTimeAutoDetectionLicensingExchangeMultitransportBootstrapping
    51
    9. Licensing Exchange
    52
    Server sends license information. Client may respond with cached license data:
    53
    let license_exchange = LicenseExchangeSequence::new(
        io_channel_id,
        username.to_owned(),
        domain.clone(),
        hardware_id,
        license_cache,  // Arc<dyn LicenseCache>
    );
    
    54
    Implement LicenseCache trait to persist licenses:
    55
    pub trait LicenseCache: Send + Sync {
        fn get(&self, server_name: &str) -> Option<Vec<u8>>;
        fn store(&self, server_name: &str, license: Vec<u8>);
    }
    
    56
    See ironrdp-connector/src/license_exchange.rs.
    57
    10. Capabilities Exchange
    58
    Server sends Demand Active PDU with capability sets. Client responds with Confirm Active PDU:
    59
    loop {
        output.clear();
        let bytes_read = stream.read(&mut input_buf)?;
        let written = connector.step(&input_buf[..bytes_read], &mut output)?;
        
        if let Written::Size(_) = written {
            stream.write_all(output.filled())?;
        }
        
        if connector.state().is_terminal() {
            break;
        }
    }
    
    60
    Capability sets negotiated:
    61
  • General, Bitmap, Order, Pointer, Input
  • Virtual Channel, Sound, Surface Commands
  • Offscreen Bitmap Cache, Glyph Cache
  • 62
    State transitions to: ConnectionFinalization
    63
    11. Connection Finalization
    64
    Final PDU exchange:
    65
  • Synchronize PDU
  • Control Cooperate PDU
  • Control Granted Control PDU
  • Font Map PDU
  • 66
    State transitions to: Connected
    67
    12. Extract Connection Result
    68
    Once connected, extract the ConnectionResult:
    69
    use ironrdp_connector::{ClientConnectorState, ConnectionResult};
    
    let result = match connector.state {
        ClientConnectorState::Connected { result } => result,
        _ => panic!("Expected Connected state"),
    };
    
    let ConnectionResult {
        io_channel_id,
        user_channel_id,
        share_id,
        static_channels,
        desktop_size,
        connection_activation,
        compression_type,
        ..  
    } = result;
    

    Active Session Management

    After connection, use ironrdp-session::ActiveStage to handle the active session:
    use ironrdp_session::{ActiveStage, ActiveStageOutput};
    
    let mut session = connection_activation.into_active_stage();
    
    loop {
        let mut buf = vec![0u8; 8192];
        let bytes_read = stream.read(&mut buf)?;
        
        match session.process(&buf[..bytes_read], &mut output)? {
            ActiveStageOutput::GraphicsUpdate(update) => {
                // Render graphics update
            }
            ActiveStageOutput::PointerUpdate(pointer) => {
                // Update mouse pointer
            }
            ActiveStageOutput::Terminated(reason) => {
                // Handle disconnection
                break;
            }
            // ... other output types
        }
        
        if output.filled_len() > 0 {
            stream.write_all(output.filled())?;
            output.clear();
        }
    }
    
    See ironrdp-session/src/active_stage.rs for the complete ActiveStageOutput enum.

    Adding Static Virtual Channels

    Register static channels before stepping through the connector:
    use ironrdp_svc::{SvcClientProcessor, SvcMessage};
    use ironrdp_cliprdr::CliprdrClient;
    
    let cliprdr = CliprdrClient::new();
    let connector = ClientConnector::new(config, client_addr)
        .with_static_channel(cliprdr);
    
    Channels must implement SvcClientProcessor:
    pub trait SvcClientProcessor: SvcProcessor {}
    
    pub trait SvcProcessor: AsAny + fmt::Debug + Send {
        fn channel_name(&self) -> ChannelName;
        fn start(&mut self) -> PduResult<Vec<SvcMessage>>;
        fn process(&mut self, payload: &[u8]) -> PduResult<Vec<SvcMessage>>;
    }
    
    See Virtual Channels for details.

    Error Handling

    The connector returns ConnectorResult<T> for all operations:
    pub type ConnectorResult<T> = Result<T, ConnectorError>;
    
    pub enum ConnectorErrorKind {
        Encode(EncodeError),
        Decode(DecodeError),
        Credssp(sspi::Error),
        Reason(String),
        AccessDenied,
        Negotiation(NegotiationFailure),
        // ...
    }
    
    Negotiation failures provide user-friendly messages:
    match error.kind() {
        ConnectorErrorKind::Negotiation(failure) => {
            eprintln!("Connection failed: {}", failure);
            // "server requires Enhanced RDP Security with CredSSP"
        }
        // ...
    }
    
    See ironrdp-connector/src/lib.rs:35-82 for NegotiationFailure definitions.

    Best Practices

    Do:
    • Check connector.state().is_terminal() to know when connection is complete
    • Use connector.next_pdu_hint() to determine expected PDU size for buffer allocation
    • Clear WriteBuf between steps to avoid accumulating data
    • Handle all ConnectorError variants appropriately
    Don’t:
    • Call step() when in a terminal state
    • Reuse the same input buffer without clearing between reads
    • Assume specific byte sizes — always check Written result
    • Skip security upgrade or CredSSP steps when indicated by state

    Example: Complete Connection Loop

    use ironrdp_connector::{ClientConnector, Sequence, Written};
    use ironrdp_core::WriteBuf;
    
    let mut connector = ClientConnector::new(config, client_addr);
    let mut output = WriteBuf::new();
    let mut input_buf = vec![0u8; 8192];
    
    loop {
        // Check for external I/O requirements
        if connector.should_perform_security_upgrade() {
            stream = perform_tls_upgrade(stream)?;
            connector.mark_security_upgrade_as_done();
            continue;
        }
        
        if connector.should_perform_credssp() {
            perform_credssp(&mut stream, &config)?;
            connector.mark_credssp_as_done();
            continue;
        }
        
        // Step the state machine
        output.clear();
        let written = connector.step(&[], &mut output)?;
        
        // Send output if any
        if let Written::Size(_) = written {
            stream.write_all(output.filled())?;
        }
        
        // Check if we're done
        if connector.state().is_terminal() {
            break;
        }
        
        // Read response
        let bytes_read = stream.read(&mut input_buf)?;
        connector.step(&input_buf[..bytes_read], &mut output)?;
    }
    
    // Extract result
    let result = match connector.state {
        ClientConnectorState::Connected { result } => result,
        _ => unreachable!(),
    };
    

    References

    • State Machine Trait: ironrdp-connector/src/lib.rs:327-337
    • Connector Implementation: ironrdp-connector/src/connection.rs
    • Channel Connection: ironrdp-connector/src/channel_connection.rs
    • License Exchange: ironrdp-connector/src/license_exchange.rs
    • Connection Activation: ironrdp-connector/src/connection_activation.rs
    • Active Session: ironrdp-session/src/active_stage.rs

    Build docs developers (and LLMs) love