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}