Skip to main content

netget/server/rdp/
mod.rs

1//! RDP server — the TPKT + X.224 connection-negotiation slice of [MS-RDPBCGR].
2//!
3//! Scope (be honest, per `src/server/rdp/CLAUDE.md`): this reads a client's TPKT-framed X.224
4//! Connection Request, parses the `Cookie: mstshash=` routing token and the RDP_NEG_REQ
5//! (`requestedProtocols`), and answers with an X.224 Connection Confirm the LLM chooses —
6//! RDP_NEG_RSP selecting a security protocol, or RDP_NEG_FAILURE. It STOPS there. It does not
7//! implement MCS/GCC, the security exchange, the capability exchange, or any bitmap, so no
8//! desktop frame is produced and a real client does not reach a session. A client completing the
9//! negotiation is real, verifiable progress against [MS-RDPBCGR] 2.2.1.1/2.2.1.2 and nothing more
10//! is claimed.
11//!
12//! The negotiation decision is genuine LLM reasoning: which security protocol to select given
13//! what the client offered, or whether to demand one it did not (a rejection).
14
15pub mod actions;
16
17use crate::llm::action_helper::call_llm;
18use crate::llm::actions::protocol_trait::ActionResult;
19use crate::llm::ollama_client::OllamaClient;
20use crate::protocol::Event;
21use crate::server::connection::ConnectionId;
22use crate::state::app_state::AppState;
23use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
24use crate::{console_debug, console_info, console_warn};
25use actions::{
26    build_negotiation_failure, protocol_value_to_names, RdpProtocol, DEFAULT_FAILURE_CODE,
27    RDP_CONNECTION_REQUEST_EVENT, TYPE_RDP_NEG_FAILURE, TYPE_RDP_NEG_REQ, TYPE_RDP_NEG_RSP,
28    X224_TPDU_CONNECTION_REQUEST,
29};
30use anyhow::{anyhow, Result};
31use std::net::SocketAddr;
32use std::sync::Arc;
33use std::time::Duration;
34use tokio::io::{AsyncReadExt, AsyncWriteExt};
35use tokio::net::TcpStream;
36use tokio::sync::{mpsc, Mutex};
37use tracing::{debug, error, info, warn};
38
39/// Largest X.224 Connection Request accepted, in bytes. A real CR is well under 512 bytes
40/// (routing cookie + RDP_NEG_REQ + optional correlation info); the TPKT length field is
41/// client-controlled, so it is bounded before anything is allocated.
42pub const MAX_X224_LEN: usize = 2048;
43
44/// What one client sent in its X.224 Connection Request.
45struct ConnectionRequest {
46    /// Username from a `Cookie: mstshash=...` routing token, empty if absent.
47    cookie_username: String,
48    /// `requestedProtocols` bitmask (0 when no RDP_NEG_REQ was present = standard RDP security).
49    requested_protocols: u32,
50    /// The RDP_NEG_REQ flags octet (0 when absent).
51    flags: u8,
52}
53
54/// RDP server (negotiation slice).
55pub struct RdpServer;
56
57/// How long to wait for the first byte from a peer that has only connected.
58///
59/// RDP is client-speaks-first: the X.224 Connection Request is the very first thing on the
60/// wire, sent by `mstsc`, FreeRDP or rdesktop from inside their own connect path with no user
61/// interaction in between. Thirty seconds is far longer than that takes, and a peer that has
62/// sent nothing has negotiated nothing.
63const FIRST_BYTE_READ_TIMEOUT: Duration = Duration::from_secs(30);
64
65/// How long to wait for the rest of a frame the peer has already begun.
66///
67/// Shorter than the first bound, because it is a narrower claim: these bytes belong to a TPKT
68/// frame whose header has already arrived, and a Connection Request is at most
69/// [`MAX_X224_LEN`] bytes that a real client writes in one go. Nothing legitimate pauses in the
70/// middle of it, so fifteen seconds is generous for a slow link and still bounds a peer that
71/// sends `0x03` and stops.
72///
73/// This server's whole session is one request and one reply, so there is no third case: the
74/// model round-trip happens after the request has been read in full, and the connection closes
75/// after the Connection Confirm.
76const IN_FRAME_READ_TIMEOUT: Duration = Duration::from_secs(15);
77
78/// Concurrent connections this server admits.
79///
80/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. Each connection may buffer up to
81/// [`MAX_X224_LEN`], so this is the multiplier that turns that per-connection bound into a
82/// total one.
83const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
84
85/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes: nothing.
86///
87/// RDP's own refusal is an X.224 Connection Confirm carrying `RDP_NEG_FAILURE`, and it cannot
88/// be sent here: it is a *reply*, and a capped peer is refused before its Connection Request
89/// has been read, so there is no `srcRef` to answer and no requested protocol to fail. A
90/// fabricated Connection Confirm would also be a positive assertion — it tells the client which
91/// security protocol to speak next — which is precisely what a refusal must not do. The peer
92/// gets a clean EOF and the refusal is recorded where it can carry a reason: `accept_bounded`
93/// logs it at WARN with `decision=fail_closed_connection_cap`.
94const CONNECTION_CAP_REFUSAL: &[u8] = b"";
95
96/// The read half of an RDP connection, with both deadlines applied to every read.
97///
98/// [`crate::server::accept_bounded::IdleTimeoutReader`] rather than one `tokio::time::timeout`,
99/// because the Connection Request is read in two steps — the four-byte TPKT header, then the
100/// X.224 body whose length that header declares — and a peer that sends the header and stalls
101/// would otherwise be unbounded. Its deadline is armed lazily, only while a read is pending, so
102/// the model round-trip that follows runs with no clock against it.
103type RdpReader = crate::server::accept_bounded::IdleTimeoutReader<tokio::io::ReadHalf<TcpStream>>;
104
105impl RdpServer {
106    /// Bind and spawn the accept loop. Awaits the bind so failure is returned as `Err`; registers
107    /// the accept-loop `JoinHandle` so `stop_server` releases the socket.
108    pub async fn spawn_with_llm_actions(
109        listen_addr: SocketAddr,
110        llm_client: OllamaClient,
111        app_state: Arc<AppState>,
112        status_tx: mpsc::UnboundedSender<String>,
113        server_id: crate::state::ServerId,
114    ) -> Result<SocketAddr> {
115        let listener =
116            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
117        let local_addr = listener.local_addr()?;
118
119        // Keep the address last on the line: the E2E harness parses the port from after "on ".
120        console_info!(
121            status_tx,
122            "RDP server (negotiation slice) listening on {}",
123            local_addr
124        );
125
126        let task_registrar = app_state.clone();
127        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
128        let accept_handle = tokio::spawn(async move {
129            loop {
130                match crate::server::accept_bounded::accept_bounded(
131                    &listener,
132                    &limiter,
133                    CONNECTION_CAP_REFUSAL,
134                    "RDP",
135                    Some(&status_tx),
136                )
137                .await
138                {
139                    Ok((stream, remote_addr, permit)) => {
140                        let connection_id =
141                            ConnectionId::new(app_state.get_next_unified_id().await);
142                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
143
144                        info!("RDP client connected from {}", remote_addr);
145                        let _ = status_tx
146                            .send(format!("[INFO] RDP client connected from {}", remote_addr));
147
148                        let llm_clone = llm_client.clone();
149                        let state_clone = app_state.clone();
150                        let status_clone = status_tx.clone();
151                        // Tracked, not detached: stop_server must abort this task too.
152                        let task_owner = app_state.clone();
153                        task_owner
154                            .spawn_server_task(server_id, async move {
155                                // Held for the life of the connection: releasing it here would
156                                // cap the accept rate rather than the number of live
157                                // connections.
158                                let _permit = permit;
159                                if let Err(e) = Self::handle_connection(
160                                    stream,
161                                    connection_id,
162                                    remote_addr,
163                                    local_addr_conn,
164                                    server_id,
165                                    state_clone,
166                                    status_clone,
167                                    llm_clone,
168                                )
169                                .await
170                                {
171                                    error!("RDP connection error: {}", e);
172                                }
173                            })
174                            .await;
175                    }
176                    Err(e) => {
177                        error!("RDP accept failed: {}", e);
178                        let _ = status_tx.send(format!("✗ RDP accept failed, stopping: {}", e));
179                        break;
180                    }
181                }
182            }
183        });
184
185        task_registrar
186            .register_server_task(server_id, accept_handle)
187            .await;
188
189        Ok(local_addr)
190    }
191
192    /// One connection: read the X.224 CR, consult the model, write the CC, then close.
193    #[allow(clippy::too_many_arguments)]
194    async fn handle_connection(
195        stream: TcpStream,
196        connection_id: ConnectionId,
197        remote_addr: SocketAddr,
198        local_addr: SocketAddr,
199        server_id: crate::state::ServerId,
200        app_state: Arc<AppState>,
201        status_tx: mpsc::UnboundedSender<String>,
202        llm_client: OllamaClient,
203    ) -> Result<()> {
204        let (read_half, write_half) = tokio::io::split(stream);
205        // Every read below goes through the deadline pair; nothing else does.
206        let mut read_half: RdpReader = crate::server::accept_bounded::IdleTimeoutReader::with_first(
207            read_half,
208            FIRST_BYTE_READ_TIMEOUT,
209            IN_FRAME_READ_TIMEOUT,
210        );
211        let write_half = Arc::new(Mutex::new(write_half));
212
213        let now = crate::utils::clock::Instant::now();
214        let conn_state = ConnectionState {
215            id: connection_id,
216            remote_addr,
217            local_addr,
218            bytes_sent: 0,
219            bytes_received: 0,
220            packets_sent: 0,
221            packets_received: 0,
222            last_activity: now,
223            status: ConnectionStatus::Active,
224            status_changed_at: now,
225            protocol_info: ProtocolConnectionInfo::empty(),
226        };
227        app_state
228            .add_connection_to_server(server_id, conn_state)
229            .await;
230        let _ = status_tx.send("__UPDATE_UI__".to_string());
231
232        // Peer messaging: the dashboard's "message this peer" / "disconnect this peer" inject
233        // actions into THIS connection through the same executor the LLM path uses. Registered
234        // before the negotiation LLM call, because a manual `*` rule can park that call for
235        // minutes and the operator must be able to reach the connection while it waits. Every
236        // RDP wire verb returns ActionResult::Output (or CloseConnection), so the generic peer
237        // task covers the whole vocabulary — there is no Custom-result gap.
238        let protocol: Arc<RdpProtocol> = Arc::new(RdpProtocol::new());
239        let peer_rx = crate::server::peer_support::register_peer_channel(
240            &app_state,
241            server_id,
242            connection_id.as_u32(),
243        )
244        .await;
245        crate::server::peer_support::spawn_peer_command_task(
246            peer_rx,
247            protocol.clone(),
248            app_state.clone(),
249            server_id,
250            connection_id.as_u32(),
251            write_half.clone(),
252            status_tx.clone(),
253        );
254
255        let result = Self::negotiate(
256            &mut read_half,
257            &write_half,
258            connection_id,
259            server_id,
260            &app_state,
261            &status_tx,
262            &llm_client,
263            &protocol,
264        )
265        .await;
266
267        // Every exit path lands here: malformed framing, LLM error/fail-closed, or a clean CC.
268        app_state
269            .remove_peer_handle(server_id, connection_id.as_u32())
270            .await;
271        app_state
272            .remove_connection_from_server(server_id, connection_id)
273            .await;
274        let _ = status_tx.send("__UPDATE_UI__".to_string());
275
276        result
277    }
278
279    /// Read and parse the CR, raise the event, and write the model's (or fail-closed) CC.
280    #[allow(clippy::too_many_arguments)]
281    async fn negotiate(
282        read_half: &mut RdpReader,
283        write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
284        connection_id: ConnectionId,
285        server_id: crate::state::ServerId,
286        app_state: &Arc<AppState>,
287        status_tx: &mpsc::UnboundedSender<String>,
288        llm_client: &OllamaClient,
289        protocol: &Arc<RdpProtocol>,
290    ) -> Result<()> {
291        let (request, bytes_in) = match read_connection_request(read_half).await {
292            Ok(req) => req,
293            Err(e) => {
294                // Malformed framing: the stream cannot be resynchronised, so close.
295                console_warn!(status_tx, "RDP malformed connection request: {}", e);
296                return Ok(());
297            }
298        };
299        app_state
300            .update_connection_stats(
301                server_id,
302                connection_id,
303                Some(bytes_in as u64),
304                None,
305                Some(1),
306                None,
307            )
308            .await;
309
310        let protocol_names = protocol_value_to_names(request.requested_protocols);
311        console_debug!(
312            status_tx,
313            "RDP CR: user='{}', requested={:?}, flags={:#04x}",
314            request.cookie_username,
315            protocol_names,
316            request.flags
317        );
318
319        let event = Event::new(
320            &RDP_CONNECTION_REQUEST_EVENT,
321            serde_json::json!({
322                "cookie_username": request.cookie_username,
323                "requested_protocols": protocol_names,
324                "requested_protocols_flags": request.flags,
325            }),
326        );
327
328        let response_bytes = match call_llm(
329            llm_client,
330            app_state,
331            server_id,
332            Some(connection_id),
333            &event,
334            protocol.as_ref(),
335        )
336        .await
337        {
338            Ok(execution) => {
339                for message in execution.messages {
340                    let _ = status_tx.send(message);
341                }
342                // Take the first Output the model produced (an accept or a reject CC).
343                let mut chosen = None;
344                for result in execution.protocol_results {
345                    if let ActionResult::Output(bytes) = result {
346                        chosen = Some(bytes);
347                        break;
348                    }
349                }
350                match chosen {
351                    Some(bytes) => {
352                        // `decision=` matters most here, because the wire cannot carry the
353                        // distinction: a model that deliberately answers
354                        // `reject_rdp_connection` with SSL_REQUIRED_BY_SERVER produces bytes
355                        // identical to the fail-closed default below. `model_accept` vs
356                        // `model_reject` is read off the RDP_NEG type octet of the message the
357                        // executor actually built, not off what the model was asked for.
358                        let decision = match negotiation_kind(&bytes) {
359                            Some(TYPE_RDP_NEG_RSP) => "model_accept",
360                            Some(TYPE_RDP_NEG_FAILURE) => "model_reject",
361                            _ => "model_other",
362                        };
363                        debug!(
364                            "RDP {} decision={} ({} bytes)",
365                            connection_id,
366                            decision,
367                            bytes.len()
368                        );
369                        console_debug!(status_tx, "RDP {} decision={}", connection_id, decision);
370                        bytes
371                    }
372                    None => {
373                        // Fail closed: no usable action came back. Do NOT fall through to a
374                        // permissive default (e.g. silently accepting standard RDP). Send an
375                        // explicit negotiation failure and close — indistinguishable on the
376                        // wire from the model deliberately rejecting with the same code, which
377                        // is exactly why the tag below is the only record that separates them.
378                        let tag = if execution.failures.is_empty() {
379                            "fail_closed_model_silent"
380                        } else {
381                            "fail_closed_action_error"
382                        };
383                        warn!(
384                            "RDP {} decision={}: no usable negotiation action; \
385                             answering SSL_REQUIRED_BY_SERVER",
386                            connection_id, tag
387                        );
388                        console_warn!(
389                            status_tx,
390                            "RDP {} decision={}: no usable negotiation action, failing closed (SSL_REQUIRED_BY_SERVER)",
391                            connection_id,
392                            tag
393                        );
394                        build_negotiation_failure(DEFAULT_FAILURE_CODE)
395                    }
396                }
397            }
398            Err(e) => {
399                // The peer gets the same RDP_NEG_FAILURE whatever went wrong — there is no
400                // free-text field in an X.224 Connection Confirm in which a `WireFailure`
401                // category could be carried, and inventing a failureCode to encode "the
402                // backend is busy" would be telling the client something [MS-RDPBCGR] does not
403                // mean. So the category stays in the log, next to the error.
404                warn!(
405                    "RDP {} decision=fail_closed_llm_error ({:?}): {}",
406                    connection_id,
407                    crate::utils::WireFailure::classify(&e),
408                    e
409                );
410                console_warn!(
411                    status_tx,
412                    "RDP {} decision=fail_closed_llm_error, failing closed (SSL_REQUIRED_BY_SERVER): {}",
413                    connection_id,
414                    e
415                );
416                build_negotiation_failure(DEFAULT_FAILURE_CODE)
417            }
418        };
419
420        {
421            let mut writer = write_half.lock().await;
422            writer.write_all(&response_bytes).await?;
423            writer.flush().await?;
424        }
425        app_state
426            .update_connection_stats(
427                server_id,
428                connection_id,
429                None,
430                Some(response_bytes.len() as u64),
431                None,
432                Some(1),
433            )
434            .await;
435        console_debug!(
436            status_tx,
437            "RDP sent {}-byte Connection Confirm to {}",
438            response_bytes.len(),
439            connection_id
440        );
441
442        // The slice ends at negotiation: we do not implement MCS/GCC, so there is nothing further
443        // to exchange. Half-close so the client sees a clean end after the CC.
444        {
445            let mut writer = write_half.lock().await;
446            let _ = writer.shutdown().await;
447        }
448        debug!("RDP negotiation complete for {}, closing", connection_id);
449        Ok(())
450    }
451}
452
453/// The RDP_NEG_* type octet of an encoded Connection Confirm, if it carries one.
454///
455/// Layout ([MS-RDPBCGR] 2.2.1.2): TPKT(4) + LI(1) + code(1) + dstRef(2) + srcRef(2) + class(1),
456/// then the 8-byte RDP_NEG_* structure at offset 11. Reading it back is what lets the log say
457/// whether the model accepted or refused without trusting the action name — the executor is
458/// what decides, and only the bytes it produced know.
459fn negotiation_kind(confirm: &[u8]) -> Option<u8> {
460    confirm.get(11).copied()
461}
462
463/// Read one TPKT-framed X.224 Connection Request and parse its RDP negotiation fields.
464///
465/// Every length is bounded before allocation: the TPKT length field is client-controlled.
466async fn read_connection_request(read_half: &mut RdpReader) -> Result<(ConnectionRequest, usize)> {
467    // TPKT header: version(1)=0x03, reserved(1), length(2, big-endian, total incl. this header).
468    let mut tpkt = [0u8; 4];
469    read_half.read_exact(&mut tpkt).await?;
470    if tpkt[0] != 0x03 {
471        return Err(anyhow!("not a TPKT frame (first byte {:#04x})", tpkt[0]));
472    }
473    let total_len = u16::from_be_bytes([tpkt[2], tpkt[3]]) as usize;
474    if total_len < 11 || total_len > MAX_X224_LEN {
475        // 11 = TPKT(4) + minimal X.224 CR header(7).
476        return Err(anyhow!("implausible TPKT length {total_len}"));
477    }
478
479    let x224_len = total_len - 4;
480    let mut x224 = vec![0u8; x224_len];
481    read_half.read_exact(&mut x224).await?;
482
483    // X.224 CR header: LI(1), code(1), dstRef(2), srcRef(2), class(1) = 7 bytes.
484    if x224.len() < 7 {
485        return Err(anyhow!("X.224 CR too short ({} bytes)", x224.len()));
486    }
487    let code = x224[1];
488    if code != X224_TPDU_CONNECTION_REQUEST {
489        return Err(anyhow!(
490            "X.224 TPDU is not a Connection Request (code {:#04x})",
491            code
492        ));
493    }
494
495    // Variable part after the fixed 7-byte header: optional routing cookie then optional
496    // RDP_NEG_REQ (and possibly RDP_CORRELATION_INFO, which we ignore).
497    let variable = &x224[7..];
498    let (cookie_username, after_cookie) = parse_routing_cookie(variable);
499    let (requested_protocols, flags) = parse_rdp_neg_req(after_cookie);
500
501    // Total wire bytes consumed: the 4-byte TPKT header plus the X.224 body.
502    let bytes_in = 4 + x224.len();
503    Ok((
504        ConnectionRequest {
505            cookie_username,
506            requested_protocols,
507            flags,
508        },
509        bytes_in,
510    ))
511}
512
513/// Parse an optional `Cookie: mstshash=<user>\r\n` (or `Cookie: msts=<token>\r\n`) routing token.
514///
515/// Returns the extracted `mstshash` username (empty if none or if it was a non-mstshash routing
516/// token) and the slice of `data` following the token.
517fn parse_routing_cookie(data: &[u8]) -> (String, &[u8]) {
518    const COOKIE_PREFIX: &[u8] = b"Cookie: ";
519    if !data.starts_with(COOKIE_PREFIX) {
520        return (String::new(), data);
521    }
522    // Find the terminating CRLF.
523    let Some(crlf) = data.windows(2).position(|w| w == b"\r\n") else {
524        return (String::new(), data);
525    };
526    let line = &data[COOKIE_PREFIX.len()..crlf];
527    let rest = &data[crlf + 2..];
528
529    const MSTSHASH: &[u8] = b"mstshash=";
530    let username = if line.starts_with(MSTSHASH) {
531        String::from_utf8_lossy(&line[MSTSHASH.len()..]).to_string()
532    } else {
533        String::new()
534    };
535    (username, rest)
536}
537
538/// Parse an optional 8-byte RDP_NEG_REQ (type 0x01) at the start of `data`.
539///
540/// Returns `(requestedProtocols, flags)`, defaulting to `(0, 0)` — standard RDP security — when
541/// no RDP_NEG_REQ is present, which is exactly what an absent structure means in [MS-RDPBCGR].
542fn parse_rdp_neg_req(data: &[u8]) -> (u32, u8) {
543    if data.len() < 8 || data[0] != TYPE_RDP_NEG_REQ {
544        return (0, 0);
545    }
546    let flags = data[1];
547    // data[2..4] is the length field (always 0x0008); requestedProtocols is little-endian.
548    let requested = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
549    (requested, flags)
550}