Skip to main content

netget/client/rdp/
actions.rs

1//! What the model can do as an RDP client: type, press keys, move and click the mouse, and
2//! read the screen back as colours.
3use 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;
18/// The largest desktop side this client asks for, and the largest it accepts from the server.
19pub const MAX_SIDE: u16 = 4096;
20/// The `security` setting when none is given: NLA only, so the password is proven through
21/// CredSSP and never sent to a server that has not proven it knows it.
22pub const DEFAULT_SECURITY: &str = "nla";
23/// Most characters one rdp_type sends.
24pub const MAX_TEXT_CHARS: usize = 4096;
25/// Most key combinations one rdp_keys sends.
26pub const MAX_KEYS: usize = 64;
27pub const DEFAULT_GRID: u64 = 4;
28pub const MAX_GRID: u64 = 16;
29/// How long the screen must be quiet before rdp_read_screen answers, by default.
30pub const DEFAULT_SETTLE_MS: u64 = 500;
31/// The longest a read waits for the screen to settle; past it, the screen is read as it is.
32pub 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/// An input action, validated.
246#[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}