Skip to main content

netget/server/rdp/
actions.rs

1//! RDP server actions — the connection-negotiation slice of [MS-RDPBCGR].
2//!
3//! This implements the very first exchange of the Remote Desktop Protocol: the TPKT-framed
4//! X.224 Connection Request / Connection Confirm carrying the RDP negotiation
5//! ([MS-RDPBCGR] 2.2.1.1 / 2.2.1.2). The LLM decides how the server negotiates — which security
6//! protocol to select, or which failure code to reject with. See `src/server/rdp/CLAUDE.md` for
7//! exactly where this slice stops (before MCS/GCC, security, capabilities and any bitmap).
8
9use crate::llm::actions::{
10    protocol_trait::{ActionResult, Protocol, Server},
11    ActionDefinition, Parameter,
12};
13use crate::protocol::log_template::LogTemplate;
14use crate::protocol::EventType;
15use crate::state::app_state::AppState;
16use anyhow::{Context, Result};
17use serde_json::json;
18use std::sync::LazyLock;
19
20/// RDP protocol handler (stateless: describes actions/events, builds negotiation bytes).
21#[derive(Default)]
22pub struct RdpProtocol;
23
24impl RdpProtocol {
25    pub fn new() -> Self {
26        Self
27    }
28}
29
30impl Protocol for RdpProtocol {
31    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
32        // No startup parameters: the negotiation decision is made per connection by the model.
33        Vec::new()
34    }
35
36    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
37        Vec::new()
38    }
39
40    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
41        vec![
42            send_negotiation_response_action(),
43            reject_connection_action(),
44        ]
45    }
46
47    fn protocol_name(&self) -> &'static str {
48        "RDP"
49    }
50
51    fn get_event_types(&self) -> Vec<EventType> {
52        get_rdp_event_types()
53    }
54
55    fn stack_name(&self) -> &'static str {
56        "ETH>IP>TCP>TPKT>X224>RDP"
57    }
58
59    fn keywords(&self) -> Vec<&'static str> {
60        // Distinctive, never a bare "desktop" or "remote" (the Bluetooth "remote" profile
61        // already claims that substring and broke resolution once).
62        vec!["rdp", "mstsc", "ms-rdpbcgr", "freerdp"]
63    }
64
65    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
66        use crate::protocol::metadata::{
67            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
68        };
69
70        ProtocolMetadataV2::builder()
71            .state(DevelopmentState::Experimental)
72            .privilege_requirement(PrivilegeRequirement::None)
73            .well_known_port(3389)
74            .implementation(
75                "Hand-written TPKT + X.224 with the [MS-RDPBCGR] RDP negotiation (CR/CC); no crate",
76            )
77            .llm_control("The negotiation decision: which security protocol to select, or reject with which failure code")
78            .e2e_testing("Raw TCP client sending a real X.224 Connection Request; asserts CC bytes vs MS-RDPBCGR literals")
79            .notes(
80                "SLICE: TPKT + X.224 connection negotiation ONLY ([MS-RDPBCGR] 2.2.1.1/2.2.1.2). \
81                 The server parses the client's Connection Request (routing cookie / mstshash \
82                 username, requestedProtocols) and answers a Connection Confirm the model chooses. \
83                 It STOPS before MCS/GCC, the security exchange, the capability exchange and any \
84                 bitmap output — so no desktop frame is rendered and a client does not reach a \
85                 session. No real RDP client (xfreerdp/mstsc) was available in this environment to \
86                 test past negotiation; correctness is pinned against RFC-derived literal bytes.",
87            )
88            .max_inbound_bytes(crate::server::rdp::MAX_X224_LEN)
89            .build()
90    }
91
92    fn description(&self) -> &'static str {
93        "RDP server: the TPKT/X.224 connection-negotiation handshake (model picks the response)"
94    }
95
96    fn example_prompt(&self) -> &'static str {
97        "Act as an RDP server on port 3389 that requires TLS security during negotiation"
98    }
99
100    fn group_name(&self) -> &'static str {
101        "Network Services"
102    }
103
104    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
105        use crate::llm::actions::StartupExamples;
106
107        // Deterministic: select TLS security for every connection request, no
108        // LLM call.
109        let script = r#"import json, sys
110data = json.load(sys.stdin)
111event = data["event"]
112if data["event_type_id"] == "rdp_connection_request":
113    actions = [{"type": "send_rdp_negotiation_response", "selected_protocol": "TLS"}]
114else:
115    actions = []
116print(json.dumps({"actions": actions}))"#;
117
118        StartupExamples::new(
119            // LLM mode
120            json!({
121                "type": "open_server",
122                "port": 3389,
123                "base_stack": "rdp",
124                "instruction": "RDP server that selects TLS security during negotiation"
125            }),
126            // Script mode
127            json!({
128                "type": "open_server",
129                "port": 3389,
130                "base_stack": "rdp",
131                "event_handlers": [{
132                    "event_pattern": "rdp_connection_request",
133                    "handler": {
134                        "type": "script",
135                        "language": "python",
136                        "code": script
137                    }
138                }]
139            }),
140            // Static mode
141            json!({
142                "type": "open_server",
143                "port": 3389,
144                "base_stack": "rdp",
145                "event_handlers": [{
146                    "event_pattern": "rdp_connection_request",
147                    "handler": {
148                        "type": "static",
149                        "actions": [{
150                            "type": "send_rdp_negotiation_response",
151                            "selected_protocol": "TLS"
152                        }]
153                    }
154                }]
155            }),
156        )
157    }
158}
159
160impl Server for RdpProtocol {
161    fn spawn(
162        &self,
163        ctx: crate::protocol::SpawnContext,
164    ) -> std::pin::Pin<
165        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
166    > {
167        Box::pin(async move {
168            if let Some(params) = ctx.startup_params.as_ref() {
169                let _ = params.allowed_parameters();
170            }
171            use crate::server::rdp::RdpServer;
172            let listen_addr = ctx.legacy_listen_addr();
173            RdpServer::spawn_with_llm_actions(
174                listen_addr,
175                ctx.llm_client,
176                ctx.state,
177                ctx.status_tx,
178                ctx.server_id,
179            )
180            .await
181        })
182    }
183
184    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
185        let action_type = action
186            .get("type")
187            .and_then(|v| v.as_str())
188            .context("Missing 'type' field in action")?;
189
190        match action_type {
191            "send_rdp_negotiation_response" => {
192                let name = action
193                    .get("selected_protocol")
194                    .and_then(|v| v.as_str())
195                    .context("Missing 'selected_protocol' parameter")?;
196                let selected = protocol_name_to_value(name).ok_or_else(|| {
197                    anyhow::anyhow!(
198                        "Invalid 'selected_protocol' {name:?}. Valid values: RDP, TLS, HYBRID \
199                         (CredSSP/NLA), RDSTLS, HYBRID_EX."
200                    )
201                })?;
202                let extended = action
203                    .get("extended_client_data")
204                    .and_then(|v| v.as_bool())
205                    .unwrap_or(false);
206                let flags = if extended {
207                    NEG_RSP_EXTENDED_CLIENT_DATA_SUPPORTED
208                } else {
209                    0
210                };
211                Ok(ActionResult::Output(build_negotiation_response(
212                    selected, flags,
213                )))
214            }
215            "reject_rdp_connection" => {
216                let name = action
217                    .get("failure_code")
218                    .and_then(|v| v.as_str())
219                    .context("Missing 'failure_code' parameter")?;
220                let code = failure_name_to_value(name).ok_or_else(|| {
221                    anyhow::anyhow!(
222                        "Invalid 'failure_code' {name:?}. Valid values: SSL_REQUIRED_BY_SERVER, \
223                         SSL_NOT_ALLOWED_BY_SERVER, SSL_CERT_NOT_ON_SERVER, INCONSISTENT_FLAGS, \
224                         HYBRID_REQUIRED_BY_SERVER, SSL_WITH_USER_AUTH_REQUIRED_BY_SERVER."
225                    )
226                })?;
227                Ok(ActionResult::Output(build_negotiation_failure(code)))
228            }
229            // Not offered to the model (the negotiation slice always answers with a CC and then
230            // half-closes on its own), but the dashboard's "disconnect this peer" injects this
231            // through the peer command task, which half-closes the write side on this result.
232            "close_connection" => Ok(ActionResult::CloseConnection),
233            other => Err(anyhow::anyhow!("Unknown RDP action: {other}")),
234        }
235    }
236}
237
238// ============================================================================
239// [MS-RDPBCGR] wire constants and byte builders
240// ============================================================================
241
242/// X.224 Connection Request TPDU code (CR, high nibble 0xE).
243pub const X224_TPDU_CONNECTION_REQUEST: u8 = 0xE0;
244/// X.224 Connection Confirm TPDU code (CC, high nibble 0xD).
245pub const X224_TPDU_CONNECTION_CONFIRM: u8 = 0xD0;
246
247/// RDP_NEG_REQ type octet ([MS-RDPBCGR] 2.2.1.1.1).
248pub const TYPE_RDP_NEG_REQ: u8 = 0x01;
249/// RDP_NEG_RSP type octet ([MS-RDPBCGR] 2.2.1.2.1).
250pub const TYPE_RDP_NEG_RSP: u8 = 0x02;
251/// RDP_NEG_FAILURE type octet ([MS-RDPBCGR] 2.2.1.2.2).
252pub const TYPE_RDP_NEG_FAILURE: u8 = 0x03;
253
254/// EXTENDED_CLIENT_DATA_SUPPORTED flag in RDP_NEG_RSP.
255pub const NEG_RSP_EXTENDED_CLIENT_DATA_SUPPORTED: u8 = 0x01;
256
257/// Map an RDP negotiation protocol name to its `requestedProtocols`/`selectedProtocol` value.
258pub fn protocol_name_to_value(name: &str) -> Option<u32> {
259    match name.trim().to_ascii_uppercase().as_str() {
260        "RDP" | "PROTOCOL_RDP" | "STANDARD" => Some(0x0000_0000),
261        "TLS" | "SSL" | "PROTOCOL_SSL" => Some(0x0000_0001),
262        "HYBRID" | "CREDSSP" | "NLA" | "PROTOCOL_HYBRID" => Some(0x0000_0002),
263        "RDSTLS" | "PROTOCOL_RDSTLS" => Some(0x0000_0004),
264        "HYBRID_EX" | "PROTOCOL_HYBRID_EX" => Some(0x0000_0008),
265        "RDSAAD" | "PROTOCOL_RDSAAD" => Some(0x0000_0010),
266        _ => None,
267    }
268}
269
270/// Names of every negotiation protocol bit set in a `requestedProtocols` bitmask.
271///
272/// A client that sends no RDP_NEG_REQ implicitly requests standard RDP security, which is why the
273/// caller passes `0` and gets `["RDP"]`.
274pub fn protocol_value_to_names(value: u32) -> Vec<&'static str> {
275    if value == 0 {
276        return vec!["RDP"];
277    }
278    let mut names = Vec::new();
279    if value & 0x0000_0001 != 0 {
280        names.push("TLS");
281    }
282    if value & 0x0000_0002 != 0 {
283        names.push("HYBRID");
284    }
285    if value & 0x0000_0004 != 0 {
286        names.push("RDSTLS");
287    }
288    if value & 0x0000_0008 != 0 {
289        names.push("HYBRID_EX");
290    }
291    if value & 0x0000_0010 != 0 {
292        names.push("RDSAAD");
293    }
294    if names.is_empty() {
295        names.push("UNKNOWN");
296    }
297    names
298}
299
300/// Map an RDP negotiation failure name to its `failureCode` value ([MS-RDPBCGR] 2.2.1.2.2).
301pub fn failure_name_to_value(name: &str) -> Option<u32> {
302    match name.trim().to_ascii_uppercase().as_str() {
303        "SSL_REQUIRED_BY_SERVER" => Some(0x0000_0001),
304        "SSL_NOT_ALLOWED_BY_SERVER" => Some(0x0000_0002),
305        "SSL_CERT_NOT_ON_SERVER" => Some(0x0000_0003),
306        "INCONSISTENT_FLAGS" => Some(0x0000_0004),
307        "HYBRID_REQUIRED_BY_SERVER" => Some(0x0000_0005),
308        "SSL_WITH_USER_AUTH_REQUIRED_BY_SERVER" => Some(0x0000_0006),
309        _ => None,
310    }
311}
312
313/// The `failureCode` used on the fail-closed path when the model gives no usable answer.
314pub const DEFAULT_FAILURE_CODE: u32 = 0x0000_0001; // SSL_REQUIRED_BY_SERVER
315
316/// Build a full TPKT + X.224 Connection Confirm carrying an RDP_NEG_RSP.
317///
318/// Layout ([MS-RDPBCGR] 2.2.1.2): TPKT(4) + X.224 CC header(7) + RDP_NEG_RSP(8) = 19 bytes.
319pub fn build_negotiation_response(selected_protocol: u32, flags: u8) -> Vec<u8> {
320    let nego = build_rdp_neg(TYPE_RDP_NEG_RSP, flags, selected_protocol);
321    wrap_connection_confirm(&nego)
322}
323
324/// Build a full TPKT + X.224 Connection Confirm carrying an RDP_NEG_FAILURE.
325pub fn build_negotiation_failure(failure_code: u32) -> Vec<u8> {
326    // RDP_NEG_FAILURE has a reserved flags octet that is always 0.
327    let nego = build_rdp_neg(TYPE_RDP_NEG_FAILURE, 0, failure_code);
328    wrap_connection_confirm(&nego)
329}
330
331/// The 8-byte RDP_NEG_* structure: type(1), flags(1), length(2 LE = 0x0008), payload(4 LE).
332fn build_rdp_neg(neg_type: u8, flags: u8, payload: u32) -> [u8; 8] {
333    let mut buf = [0u8; 8];
334    buf[0] = neg_type;
335    buf[1] = flags;
336    buf[2..4].copy_from_slice(&8u16.to_le_bytes());
337    buf[4..8].copy_from_slice(&payload.to_le_bytes());
338    buf
339}
340
341/// Wrap an 8-byte RDP_NEG_* structure in a TPKT + X.224 Connection Confirm.
342fn wrap_connection_confirm(nego: &[u8]) -> Vec<u8> {
343    debug_assert_eq!(nego.len(), 8);
344    // X.224 CC header after the LI byte: code(1) + dstRef(2) + srcRef(2) + class(1) = 6 bytes.
345    let li: u8 = 6 + nego.len() as u8; // = 14 (0x0E)
346    let total_len: u16 = 4 + 1 + li as u16; // TPKT(4) + LI(1) + header+nego = 19
347
348    let mut out = Vec::with_capacity(total_len as usize);
349    // TPKT header (RFC 1006 / [MS-RDPBCGR] 2.2.1.2).
350    out.push(0x03); // version
351    out.push(0x00); // reserved
352    out.extend_from_slice(&total_len.to_be_bytes());
353    // X.224 Connection Confirm.
354    out.push(li);
355    out.push(X224_TPDU_CONNECTION_CONFIRM);
356    out.extend_from_slice(&[0x00, 0x00]); // DST-REF
357    out.extend_from_slice(&[0x00, 0x00]); // SRC-REF
358    out.push(0x00); // class option
359    out.extend_from_slice(nego);
360    out
361}
362
363// ============================================================================
364// Action definitions
365// ============================================================================
366
367fn send_negotiation_response_action() -> ActionDefinition {
368    ActionDefinition {
369        name: "send_rdp_negotiation_response".to_string(),
370        description: "Accept the RDP connection by sending an X.224 Connection Confirm with an \
371            RDP_NEG_RSP that selects one security protocol. Choose from the protocols the client \
372            offered in the event's requested_protocols."
373            .to_string(),
374        parameters: vec![
375            Parameter {
376                name: "selected_protocol".to_string(),
377                type_hint: "string".to_string(),
378                description: "Security protocol to select: \"RDP\" (standard RDP security), \
379                    \"TLS\" (SSL/TLS), \"HYBRID\" (CredSSP/NLA), \"RDSTLS\", or \"HYBRID_EX\"."
380                    .to_string(),
381                required: true,
382            },
383            Parameter {
384                name: "extended_client_data".to_string(),
385                type_hint: "boolean".to_string(),
386                description: "Set the EXTENDED_CLIENT_DATA_SUPPORTED flag. Default false."
387                    .to_string(),
388                required: false,
389            },
390        ],
391        example: json!({
392            "type": "send_rdp_negotiation_response",
393            "selected_protocol": "TLS"
394        }),
395        log_template: Some(
396            LogTemplate::new()
397                .with_info("-> RDP NEG_RSP {selected_protocol}")
398                .with_debug("RDP negotiation response: {selected_protocol}"),
399        ),
400    }
401}
402
403fn reject_connection_action() -> ActionDefinition {
404    ActionDefinition {
405        name: "reject_rdp_connection".to_string(),
406        description: "Reject the RDP connection by sending an X.224 Connection Confirm with an \
407            RDP_NEG_FAILURE. Use this to demand a protocol the client did not offer (e.g. reject \
408            with SSL_REQUIRED_BY_SERVER when the client only offered standard RDP)."
409            .to_string(),
410        parameters: vec![Parameter {
411            name: "failure_code".to_string(),
412            type_hint: "string".to_string(),
413            description: "One of: SSL_REQUIRED_BY_SERVER, SSL_NOT_ALLOWED_BY_SERVER, \
414                SSL_CERT_NOT_ON_SERVER, INCONSISTENT_FLAGS, HYBRID_REQUIRED_BY_SERVER, \
415                SSL_WITH_USER_AUTH_REQUIRED_BY_SERVER."
416                .to_string(),
417            required: true,
418        }],
419        example: json!({
420            "type": "reject_rdp_connection",
421            "failure_code": "SSL_REQUIRED_BY_SERVER"
422        }),
423        log_template: Some(
424            LogTemplate::new()
425                .with_info("-> RDP NEG_FAILURE {failure_code}")
426                .with_debug("RDP negotiation failure: {failure_code}"),
427        ),
428    }
429}
430
431pub static SEND_NEGOTIATION_RESPONSE_ACTION: LazyLock<ActionDefinition> =
432    LazyLock::new(send_negotiation_response_action);
433pub static REJECT_CONNECTION_ACTION: LazyLock<ActionDefinition> =
434    LazyLock::new(reject_connection_action);
435
436// ============================================================================
437// Event type — emitted once per connection by mod.rs, with actions attached.
438// ============================================================================
439
440/// Raised when a client's X.224 Connection Request (with RDP negotiation) arrives.
441pub static RDP_CONNECTION_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
442    EventType::new(
443        "rdp_connection_request",
444        "An RDP client sent its X.224 Connection Request. Decide how to negotiate: accept with \
445         send_rdp_negotiation_response selecting one of the offered security protocols, or reject \
446         with reject_rdp_connection.",
447        json!({
448            "type": "send_rdp_negotiation_response",
449            "selected_protocol": "TLS"
450        }),
451    )
452    .with_parameters(vec![
453        Parameter {
454            name: "cookie_username".to_string(),
455            type_hint: "string".to_string(),
456            description: "The username from the client's `Cookie: mstshash=...` routing token, or \
457                empty if none was sent."
458                .to_string(),
459            required: true,
460        },
461        Parameter {
462            name: "requested_protocols".to_string(),
463            type_hint: "array".to_string(),
464            description: "Security protocols the client offered, as names (e.g. [\"RDP\"], \
465                [\"TLS\",\"HYBRID\"]). \"RDP\" alone means standard RDP security (no RDP_NEG_REQ)."
466                .to_string(),
467            required: true,
468        },
469        Parameter {
470            name: "requested_protocols_flags".to_string(),
471            type_hint: "number".to_string(),
472            description: "The raw flags octet from the client's RDP_NEG_REQ (0 if none)."
473                .to_string(),
474            required: true,
475        },
476    ])
477    .with_actions(vec![
478        SEND_NEGOTIATION_RESPONSE_ACTION.clone(),
479        REJECT_CONNECTION_ACTION.clone(),
480    ])
481    .with_alternative_example(json!({
482        "type": "reject_rdp_connection",
483        "failure_code": "SSL_REQUIRED_BY_SERVER"
484    }))
485    .with_log_template(
486        LogTemplate::new()
487            .with_info("RDP CR from {client_ip}:{client_port} user='{cookie_username}'")
488            .with_debug("RDP connection request: {json_pretty(.)}"),
489    )
490});
491
492/// All event types this protocol can emit.
493pub fn get_rdp_event_types() -> Vec<EventType> {
494    vec![RDP_CONNECTION_REQUEST_EVENT.clone()]
495}