1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
7};
8use crate::protocol::log_template::LogTemplate;
9use crate::protocol::{ConnectContext, EventType};
10use crate::state::app_state::AppState;
11use anyhow::{bail, ensure, Context, Result};
12use serde_json::{json, Value};
13use std::sync::LazyLock;
14
15pub const DEFAULT_WIDTH: u16 = 1024;
16pub const DEFAULT_HEIGHT: u16 = 768;
17pub const MIN_SIDE: u16 = 200;
18pub const MAX_SIDE: u16 = 4096;
20pub const DEFAULT_SECURITY: &str = "nla";
23pub const MAX_TEXT_CHARS: usize = 4096;
25pub const MAX_KEYS: usize = 64;
27pub const DEFAULT_GRID: u64 = 4;
28pub const MAX_GRID: u64 = 16;
29pub const DEFAULT_SETTLE_MS: u64 = 500;
31pub const MAX_SETTLE_WAIT_MS: u64 = 10_000;
33
34#[derive(Default)]
35pub struct RdpClientProtocol;
36impl RdpClientProtocol {
37 pub fn new() -> Self {
38 Self
39 }
40}
41
42fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
43 Parameter {
44 name: name.into(),
45 type_hint: type_hint.into(),
46 description: description.into(),
47 required,
48 }
49}
50
51fn action(
52 name: &str,
53 description: &str,
54 parameters: Vec<Parameter>,
55 example: Value,
56) -> ActionDefinition {
57 ActionDefinition {
58 name: name.into(),
59 description: description.into(),
60 parameters,
61 example,
62 log_template: Some(
63 LogTemplate::new().with_info(format!("-> rdp {}", name.trim_start_matches("rdp_"))),
64 ),
65 }
66}
67
68fn type_action() -> ActionDefinition {
69 action(
70 "rdp_type",
71 "Type text on the remote desktop, as keystrokes (US layout; other characters as Unicode key events).",
72 vec![p("text", "string", "The text to type; a newline presses Enter", true)],
73 json!({"type":"rdp_type","text":"hello world"}),
74 )
75}
76
77fn keys_action() -> ActionDefinition {
78 action(
79 "rdp_keys",
80 "Press keys or key combinations in order, e.g. enter, tab, ctrl+a, alt+f4, ctrl+alt+delete, f5, up, a.",
81 vec![p("keys", "array", "Key names; join modifiers with + (ctrl, shift, alt, win)", true)],
82 json!({"type":"rdp_keys","keys":["ctrl+a","backspace","enter"]}),
83 )
84}
85
86fn mouse_action() -> ActionDefinition {
87 action(
88 "rdp_mouse",
89 "Move the mouse to a point on the remote desktop and act there.",
90 vec![
91 p("x", "number", "Pixels from the left edge", true),
92 p("y", "number", "Pixels from the top edge", true),
93 p(
94 "action",
95 "string",
96 "move, click (default), double_click, press, release, scroll_up or scroll_down",
97 false,
98 ),
99 p("button", "string", "left (default), right or middle", false),
100 ],
101 json!({"type":"rdp_mouse","x":100,"y":200,"action":"click","button":"left"}),
102 )
103}
104
105fn read_screen_action() -> ActionDefinition {
106 action(
107 "rdp_read_screen",
108 "Look at the remote desktop: once it has been quiet for settle_ms, its size, most common colour and a grid of each cell's most common colour arrive as rdp_screen.",
109 vec![
110 p("grid", "number", "Cells per side of the colour grid (1-16, default 4)", false),
111 p(
112 "settle_ms",
113 "number",
114 "How long the screen must go without updates first (default 500)",
115 false,
116 ),
117 ],
118 json!({"type":"rdp_read_screen","grid":4}),
119 )
120}
121
122fn disconnect_action() -> ActionDefinition {
123 action(
124 "disconnect",
125 "Log off politely (shutdown request) and close the connection.",
126 vec![],
127 json!({"type":"disconnect"}),
128 )
129}
130
131pub fn actions() -> Vec<ActionDefinition> {
132 vec![
133 type_action(),
134 keys_action(),
135 mouse_action(),
136 read_screen_action(),
137 disconnect_action(),
138 ]
139}
140
141fn ev(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
142 EventType::new(id, description, read_screen_action().example.clone())
143 .with_parameters(params)
144 .with_actions(actions())
145}
146
147pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
148 ev(
149 "rdp_connected",
150 "The RDP connection sequence finished: the session is live and accepts input.",
151 vec![
152 p("server", "string", "The server's address", true),
153 p(
154 "security",
155 "string",
156 "tls or nla (CredSSP), as the server selected",
157 true,
158 ),
159 p("width", "number", "Desktop width the server granted", true),
160 p(
161 "height",
162 "number",
163 "Desktop height the server granted",
164 true,
165 ),
166 ],
167 )
168});
169
170pub static SCREEN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
171 ev(
172 "rdp_screen",
173 "What the remote desktop looks like now, as colours.",
174 vec![
175 p(
176 "width",
177 "number",
178 "Width in pixels of the desktop this reading covers; an rdp_mouse x must be below it",
179 true,
180 ),
181 p(
182 "height",
183 "number",
184 "Height in pixels of the desktop this reading covers; an rdp_mouse y must be below it",
185 true,
186 ),
187 p(
188 "dominant_color",
189 "string",
190 "The most common colour, #rrggbb",
191 true,
192 ),
193 p(
194 "dominant_share",
195 "number",
196 "Fraction of the screen in that colour (0-1)",
197 true,
198 ),
199 p(
200 "grid",
201 "array",
202 "Rows top to bottom, each cell's most common colour (#rrggbb) left to right",
203 true,
204 ),
205 p(
206 "graphics_updates",
207 "number",
208 "Screen regions the server has drawn since connecting",
209 true,
210 ),
211 p(
212 "settled",
213 "boolean",
214 "False when the screen kept changing past the wait",
215 true,
216 ),
217 ],
218 )
219});
220
221pub static DISCONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
222 ev(
223 "rdp_disconnected",
224 "The session ended.",
225 vec![p(
226 "reason",
227 "string",
228 "Why: the server's reason, or the error",
229 true,
230 )],
231 )
232});
233
234pub static ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
235 ev(
236 "rdp_error",
237 "An action could not be carried out.",
238 vec![
239 p("action", "string", "The action's type", true),
240 p("message", "string", "What went wrong", true),
241 ],
242 )
243});
244
245#[derive(Debug, Clone, PartialEq)]
247pub enum Input {
248 Text(String),
249 Keys(Vec<String>),
250 Mouse {
251 x: u16,
252 y: u16,
253 action: String,
254 button: String,
255 },
256 ReadScreen {
257 grid: u64,
258 settle_ms: u64,
259 },
260}
261
262const MOUSE_ACTIONS: [&str; 7] = [
263 "move",
264 "click",
265 "double_click",
266 "press",
267 "release",
268 "scroll_up",
269 "scroll_down",
270];
271
272pub fn check(v: &Value) -> Result<Input> {
273 match v["type"].as_str().unwrap_or_default() {
274 "rdp_type" => {
275 let text = v["text"].as_str().context("text is required")?;
276 ensure!(
277 text.chars().count() <= MAX_TEXT_CHARS,
278 "text is at most {MAX_TEXT_CHARS} characters"
279 );
280 Ok(Input::Text(text.to_string()))
281 }
282 "rdp_keys" => {
283 let keys: Vec<String> = v["keys"]
284 .as_array()
285 .context("keys is an array of key names")?
286 .iter()
287 .map(|k| {
288 k.as_str()
289 .map(str::to_string)
290 .context("each key is a string")
291 })
292 .collect::<Result<_>>()?;
293 ensure!(
294 !keys.is_empty() && keys.len() <= MAX_KEYS,
295 "keys holds 1 to {MAX_KEYS} entries"
296 );
297 for k in &keys {
298 super::keys::combo(k)?;
299 }
300 Ok(Input::Keys(keys))
301 }
302 "rdp_mouse" => {
303 let coord = |k: &str| -> Result<u16> {
304 let n = v[k]
305 .as_u64()
306 .with_context(|| format!("{k} is a number of pixels"))?;
307 ensure!(n < u64::from(MAX_SIDE), "{k} is off the desktop");
308 Ok(n as u16)
309 };
310 let action = v["action"].as_str().unwrap_or("click").to_string();
311 ensure!(
312 MOUSE_ACTIONS.contains(&action.as_str()),
313 "action is one of {}",
314 MOUSE_ACTIONS.join(", ")
315 );
316 let button = v["button"].as_str().unwrap_or("left").to_string();
317 ensure!(
318 ["left", "right", "middle"].contains(&button.as_str()),
319 "button is left, right or middle"
320 );
321 Ok(Input::Mouse {
322 x: coord("x")?,
323 y: coord("y")?,
324 action,
325 button,
326 })
327 }
328 "rdp_read_screen" => {
329 let grid = v["grid"].as_u64().unwrap_or(DEFAULT_GRID);
330 ensure!((1..=MAX_GRID).contains(&grid), "grid is 1 to {MAX_GRID}");
331 let settle_ms = v["settle_ms"].as_u64().unwrap_or(DEFAULT_SETTLE_MS);
332 ensure!(
333 settle_ms <= MAX_SETTLE_WAIT_MS,
334 "settle_ms is at most {MAX_SETTLE_WAIT_MS}"
335 );
336 Ok(Input::ReadScreen { grid, settle_ms })
337 }
338 other => bail!("unknown RDP action {other:?}"),
339 }
340}
341
342impl Protocol for RdpClientProtocol {
343 fn protocol_name(&self) -> &'static str {
344 "RDP"
345 }
346 fn stack_name(&self) -> &'static str {
347 "ETH>IP>TCP>TLS>RDP"
348 }
349 fn keywords(&self) -> Vec<&'static str> {
350 vec!["rdp", "rdp client", "remote desktop", "mstsc", "3389"]
351 }
352 fn description(&self) -> &'static str {
353 "RDP client: connects to a remote desktop (TLS or NLA), types, presses keys, clicks, and reads the screen back as colours"
354 }
355 fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
356 actions()
357 }
358 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
359 vec![]
360 }
361 fn get_event_types(&self) -> Vec<EventType> {
362 vec![
363 CONNECTED_EVENT.clone(),
364 SCREEN_EVENT.clone(),
365 DISCONNECTED_EVENT.clone(),
366 ERROR_EVENT.clone(),
367 ]
368 }
369 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
370 let s = |name: &str, description: &str, example: Value, default: Option<Value>| {
371 ParameterDefinition {
372 name: name.into(),
373 type_hint: if example.is_number() {
374 "number"
375 } else {
376 "string"
377 }
378 .into(),
379 description: description.into(),
380 required: false,
381 example,
382 default,
383 }
384 };
385 vec![
386 s(
387 "username",
388 "Account to log on as",
389 json!("alice"),
390 None,
391 ),
392 s(
393 "password",
394 "That account's password. Under nla (the default) it is proven through CredSSP; only security tls sends the password itself, inside TLS to a server whose certificate is not verified",
395 json!("s3cret"),
396 None,
397 ),
398 s(
399 "domain",
400 "Windows domain, if the account has one",
401 json!("CORP"),
402 None,
403 ),
404 s(
405 "security",
406 "nla (default: CredSSP/NTLM proves the password without handing it over), tls (the password is sent in the Client Info PDU inside TLS; the server's certificate is not verified, so anyone able to intercept the connection receives it — use only on a trusted network, or with no password to reach a login screen), or any (offer both; while a password is set, a server that selects TLS-only is refused)",
407 json!("nla"),
408 Some(json!(DEFAULT_SECURITY)),
409 ),
410 s(
411 "width",
412 "Desktop width to ask for (200-4096)",
413 json!(1280),
414 Some(json!(DEFAULT_WIDTH)),
415 ),
416 s(
417 "height",
418 "Desktop height to ask for (200-4096)",
419 json!(800),
420 Some(json!(DEFAULT_HEIGHT)),
421 ),
422 ]
423 }
424 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
425 use crate::protocol::metadata::*;
426 ProtocolMetadataV2::builder()
427 .state(DevelopmentState::Beta)
428 .privilege_requirement(PrivilegeRequirement::None)
429 .implementation("IronRDP (ironrdp-connector 0.10, ironrdp-session 0.11): X.224 negotiation, TLS upgrade (rustls, the server's certificate is not verified, as with mstsc's first-use prompt answered yes), CredSSP/NTLM for NLA, MCS/GCC, licensing, capability exchange and finalization, deactivation-reactivation, and fast-path and slow-path graphics decoded into a framebuffer. Input goes out as fast-path scancode, Unicode and mouse events through ironrdp-input")
430 .llm_control("What to type, which keys to press, where to click, and when to look at the screen; the screen comes back as its size, most common colour and a grid of each cell's most common colour")
431 .e2e_testing("tests/client/rdp: two independent servers — FreeRDP's shadow server (freerdp-shadow-cli3) sharing an Xvfb display, with NLA against an NTLM SAM file, where the model's clicks and keystrokes are read back from xev on that display and the screen colours are checked against xsetroot; and xrdp 0.9 with TLS, whose login screen colour is checked")
432 .notes("No certificate verification and no Kerberos (NTLM only). security defaults to nla, so the password is proven through CredSSP; security tls sends it in the Client Info PDU to a server whose certificate is not verified, which an interceptor can read, and security any refuses a server selecting TLS-only while a password is set. A desktop size the server grants above 4096 pixels a side is refused, at connection and at reactivation. No clipboard, audio, drive or other virtual channels. The screen is described by colour, not text. Standard RDP security (RC4) is not offered.")
433 .build()
434 }
435 fn example_prompt(&self) -> &'static str {
436 "Connect to the remote desktop at 10.0.0.5, open the Start menu and tell me what colour the screen is"
437 }
438 fn get_startup_examples(&self) -> StartupExamples {
439 let llm = json!({"type":"open_client","protocol":"RDP","remote_addr":"127.0.0.1:3389",
440 "startup_params":{"username":"alice","password":"s3cret","security":"nla"},
441 "instruction":"Once connected, read the screen and report its main colour"});
442 let mut static_example = llm.clone();
443 static_example["event_handlers"] = json!([
444 {"event_pattern":"rdp_connected","handler":{"type":"static","actions":[read_screen_action().example]}},
445 {"event_pattern":"*","handler":{"type":"static","actions":[]}}
446 ]);
447 let mut scripted = static_example.clone();
448 scripted["event_handlers"][0] = json!({"event_pattern":"rdp_connected","handler":{"type":"script","language":"python",
449 "code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'rdp_mouse','x':e['width']//2,'y':e['height']//2}]}))"}});
450 StartupExamples::new(llm, scripted, static_example)
451 }
452 fn group_name(&self) -> &'static str {
453 "Remote Access"
454 }
455}
456
457impl Client for RdpClientProtocol {
458 fn connect(
459 &self,
460 ctx: ConnectContext,
461 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
462 {
463 Box::pin(super::connect(ctx))
464 }
465 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
466 if v["type"] == "disconnect" {
467 return Ok(ClientActionResult::Disconnect);
468 }
469 check(&v)?;
470 Ok(ClientActionResult::Custom {
471 name: v["type"].as_str().unwrap_or_default().to_string(),
472 data: v,
473 })
474 }
475}