netget/client/telnet/mod.rs
1//! Telnet client implementation with option negotiation
2pub mod actions;
3
4pub use actions::TelnetClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::io::{AsyncReadExt, AsyncWriteExt};
10use tokio::net::TcpStream;
11use tokio::sync::{mpsc, Mutex};
12use tracing::{debug, error, info, trace};
13
14use crate::client::llm_budget::call_llm_for_client;
15use crate::client::telnet::actions::{
16 TELNET_CLIENT_CONNECTED_EVENT, TELNET_CLIENT_DATA_RECEIVED_EVENT,
17};
18use crate::llm::ollama_client::OllamaClient;
19use crate::llm::ClientLlmResult;
20use crate::logging::patterns;
21use crate::protocol::Event;
22use crate::state::app_state::AppState;
23use crate::state::{ClientId, ClientStatus};
24
25/// Telnet protocol constants
26const IAC: u8 = 255; // Interpret As Command
27const WILL: u8 = 251;
28const WONT: u8 = 252;
29const DO: u8 = 253;
30const DONT: u8 = 254;
31const SB: u8 = 250; // Subnegotiation Begin
32const SE: u8 = 240; // Subnegotiation End
33
34/// Per-client data for LLM handling.
35///
36/// There used to be an `Idle`/`Processing`/`Accumulating` state machine and a `queued_data`
37/// buffer here, copied from the TCP *server*. **Nothing could reach either.** The read loop is
38/// one sequential task: it reads, handles the data inline — LLM call included — and only then
39/// comes back to read again, so the state was always `Idle` at the point it was examined and
40/// `queued_data` was never appended to. The `Processing` and `Accumulating` arms were
41/// unreachable, and the line clearing `queued_data` after every turn read as "data arriving
42/// mid-call is dropped" when in fact no data could arrive mid-call at all.
43///
44/// The server-side machine exists because a server has two LLM entry points and genuinely can
45/// be re-entered. Copying its shape here bought nothing and described a concurrency this
46/// client does not have. (The root `CLAUDE.md` notes the same thing about
47/// `state/machine.rs`: a generic `StateMachine<S>` nothing uses, hand-rolled everywhere.)
48struct ClientData {
49 memory: String,
50}
51
52/// Telnet client that connects to a remote Telnet server
53pub struct TelnetClient;
54
55impl TelnetClient {
56 /// Connect to a Telnet server with integrated LLM actions
57 pub async fn connect_with_llm_actions(
58 remote_addr: String,
59 llm_client: OllamaClient,
60 app_state: Arc<AppState>,
61 status_tx: mpsc::UnboundedSender<String>,
62 client_id: ClientId,
63 ) -> Result<SocketAddr> {
64 // Resolve and connect
65 let stream = TcpStream::connect(&remote_addr)
66 .await
67 .context(format!("Failed to connect to {}", remote_addr))?;
68
69 let local_addr = stream.local_addr()?;
70 let remote_sock_addr = stream.peer_addr()?;
71
72 info!(
73 "Telnet client {} {} {} (local: {})",
74 client_id,
75 patterns::TELNET_CLIENT_CONNECTED,
76 remote_sock_addr,
77 local_addr
78 );
79
80 // Update client state
81 app_state
82 .update_client_status(client_id, ClientStatus::Connected)
83 .await;
84 let _ = status_tx.send(format!("[CLIENT] Telnet client {} connected", client_id));
85 let _ = status_tx.send("__UPDATE_UI__".to_string());
86
87 // Split stream
88 let (mut read_half, write_half) = tokio::io::split(stream);
89 let write_half_arc = Arc::new(Mutex::new(write_half));
90
91 // Initialize client data
92 let client_data = Arc::new(Mutex::new(ClientData {
93 memory: String::new(),
94 }));
95
96 // Clone for connected event
97 let write_half_for_connected = write_half_arc.clone();
98
99 // Command channel: lets the dashboard (and any programmatic caller)
100 // inject actions into this loop via AppState::send_to_client.
101 //
102 // Registered BEFORE the connected event is handled: a `manual` routing
103 // rule can park that event at the dashboard for minutes, and until
104 // registration the UI reports "no command channel" — reading as a
105 // protocol limitation when it is only a queue.
106 let mut command_rx =
107 crate::client::command_support::register_command_channel(&app_state, client_id).await;
108
109 // Call LLM with telnet_connected event
110 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
111 let event = Event::new(
112 &TELNET_CLIENT_CONNECTED_EVENT,
113 serde_json::json!({
114 "remote_addr": remote_sock_addr.to_string(),
115 }),
116 );
117
118 match call_llm_for_client(
119 &llm_client,
120 &app_state,
121 client_id.to_string(),
122 &instruction,
123 &client_data.lock().await.memory,
124 Some(&event),
125 &crate::client::telnet::actions::TelnetClientProtocol,
126 &status_tx,
127 )
128 .await
129 {
130 Ok(result) => {
131 // Update memory if provided
132 if let Some(new_memory) = result.memory_updates {
133 client_data.lock().await.memory = new_memory;
134 }
135
136 // Execute actions from LLM response
137 for action in result.actions {
138 if let Some(action_type) = action["type"].as_str() {
139 match action_type {
140 "send_command" => {
141 if let Some(command) = action["command"].as_str() {
142 let command_line = format!("{}\r\n", command);
143 let mut write_guard = write_half_for_connected.lock().await;
144 if let Err(e) =
145 write_guard.write_all(command_line.as_bytes()).await
146 {
147 error!("Failed to send command after connect: {}", e);
148 } else if let Err(e) = write_guard.flush().await {
149 error!("Failed to flush after connect: {}", e);
150 } else {
151 info!(
152 "{} {}",
153 patterns::TELNET_CLIENT_SENT_COMMAND,
154 command
155 );
156 }
157 }
158 }
159 "send_text" => {
160 if let Some(text) = action["text"].as_str() {
161 let mut write_guard = write_half_for_connected.lock().await;
162 if let Err(e) = write_guard.write_all(text.as_bytes()).await
163 {
164 error!("Failed to send text after connect: {}", e);
165 } else if let Err(e) = write_guard.flush().await {
166 error!("Failed to flush after connect: {}", e);
167 } else {
168 info!("{} {}", patterns::TELNET_CLIENT_SENT_TEXT, text);
169 }
170 }
171 }
172 "disconnect" => {
173 info!("LLM requested disconnect after connect");
174 return Ok(local_addr);
175 }
176 "wait_for_more" => {
177 // Just wait for data
178 }
179 _ => {
180 trace!("Unknown action type after connect: {}", action_type);
181 }
182 }
183 }
184 }
185 }
186 Err(e) => {
187 error!("LLM error on telnet_connected event: {}", e);
188 }
189 }
190 }
191
192 // Clone for telnet negotiation handler
193 let write_half_for_negotiation = write_half_arc.clone();
194 let status_tx_for_negotiation = status_tx.clone();
195
196 // Spawn read loop
197 // Registered with AppState so stop_client can abort this task —
198 // dropping a JoinHandle only detaches it in Tokio.
199 let task_registrar = app_state.clone();
200 let task_handle = tokio::spawn(async move {
201 let mut buffer = vec![0u8; 8192];
202
203 loop {
204 let read_result = tokio::select! {
205 read = read_half.read(&mut buffer) => read,
206 Some(cmd) = command_rx.recv() => {
207 let disconnect = crate::client::command_support::handle_stream_client_command(
208 &crate::client::telnet::actions::TelnetClientProtocol,
209 &write_half_arc,
210 cmd,
211 client_id,
212 &app_state,
213 &status_tx,
214 )
215 .await;
216 if disconnect {
217 app_state
218 .update_client_status(client_id, ClientStatus::Disconnected)
219 .await;
220 let _ = status_tx.send(format!(
221 "[CLIENT] Telnet client {} disconnected (injected action)",
222 client_id
223 ));
224 let _ = status_tx.send("__UPDATE_UI__".to_string());
225 break;
226 }
227 continue;
228 }
229 };
230 match read_result {
231 Ok(0) => {
232 info!(
233 "Telnet client {} {}",
234 client_id,
235 patterns::TELNET_CLIENT_DISCONNECTED
236 );
237 app_state
238 .update_client_status(client_id, ClientStatus::Disconnected)
239 .await;
240 let _ = status_tx
241 .send(format!("[CLIENT] Telnet client {} disconnected", client_id));
242 let _ = status_tx.send("__UPDATE_UI__".to_string());
243 break;
244 }
245 Ok(n) => {
246 let raw_data = buffer[..n].to_vec();
247 trace!("Telnet client {} received {} bytes", client_id, n);
248
249 // Parse Telnet protocol and extract data
250 let (data, telnet_commands) = Self::parse_telnet_data(&raw_data);
251
252 // Handle Telnet option negotiations
253 for cmd in &telnet_commands {
254 if let Some(response) = Self::handle_telnet_command(
255 cmd,
256 client_id,
257 &status_tx_for_negotiation,
258 ) {
259 match write_half_for_negotiation
260 .lock()
261 .await
262 .write_all(&response)
263 .await
264 {
265 Ok(()) => trace!(
266 "Telnet client {} sent negotiation response: {:?}",
267 client_id,
268 response
269 ),
270 Err(e) => error!(
271 "Telnet client {} could not answer negotiation: {}",
272 client_id, e
273 ),
274 }
275 }
276 }
277
278 // Only process data if there's meaningful content
279 if data.is_empty() && telnet_commands.is_empty() {
280 continue;
281 }
282
283 // Handle the data. Strictly sequential: the LLM call happens inline,
284 // so nothing else reads this socket meanwhile and there is no state
285 // to keep between turns beyond the model's memory.
286 if let Some(instruction) =
287 app_state.get_instruction_for_client(client_id).await
288 {
289 let protocol = Arc::new(
290 crate::client::telnet::actions::TelnetClientProtocol::new(),
291 );
292
293 // Lossy: after IAC stripping what is left is the server's text,
294 // and a stray byte must not cost the model the whole line.
295 let data_str = String::from_utf8_lossy(&data).to_string();
296
297 // `data` only. This event used to carry `raw_hex` as well - the
298 // whole read hex-encoded, up to 16 KB of hex per turn - which the
299 // repo's action/event rules forbid outright ("never put raw bytes
300 // or base64 in action parameters or event data"): models cannot
301 // reliably parse it, the negotiation it exposed is handled here
302 // rather than by the model, and it doubled the prompt for
303 // nothing.
304 let event = Event::new(
305 &TELNET_CLIENT_DATA_RECEIVED_EVENT,
306 serde_json::json!({ "data": data_str }),
307 );
308
309 let memory = client_data.lock().await.memory.clone();
310 match call_llm_for_client(
311 &llm_client,
312 &app_state,
313 client_id.to_string(),
314 &instruction,
315 &memory,
316 Some(&event),
317 protocol.as_ref(),
318 &status_tx,
319 )
320 .await
321 {
322 Ok(ClientLlmResult {
323 actions,
324 memory_updates,
325 }) => {
326 if let Some(mem) = memory_updates {
327 client_data.lock().await.memory = mem;
328 }
329
330 for action in actions {
331 use crate::llm::actions::client_trait::Client;
332 use crate::llm::actions::client_trait::ClientActionResult;
333 match protocol.as_ref().execute_action(action) {
334 Ok(ClientActionResult::SendData(bytes)) => {
335 // A failed write was silently swallowed here,
336 // so the model's command vanished and the
337 // client went on waiting for a reply to
338 // something that never left the host.
339 let mut write_guard = write_half_arc.lock().await;
340 if let Err(e) = write_guard.write_all(&bytes).await
341 {
342 error!(
343 "Telnet client {} failed to send {} \
344 bytes: {}",
345 client_id,
346 bytes.len(),
347 e
348 );
349 } else if let Err(e) = write_guard.flush().await {
350 error!(
351 "Telnet client {} failed to flush: {}",
352 client_id, e
353 );
354 } else {
355 trace!(
356 "Telnet client {} sent {} bytes",
357 client_id,
358 bytes.len()
359 );
360 }
361 }
362 Ok(ClientActionResult::Disconnect) => {
363 info!("Telnet client {} disconnecting", client_id);
364 break;
365 }
366 _ => {}
367 }
368 }
369 }
370 Err(e) => {
371 error!("LLM error for Telnet client {}: {}", client_id, e);
372 }
373 }
374 }
375 }
376 Err(e) => {
377 error!("Telnet client {} read error: {}", client_id, e);
378 app_state
379 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
380 .await;
381 let _ = status_tx.send("__UPDATE_UI__".to_string());
382 break;
383 }
384 }
385 }
386 // The loop owns the only receiver; dropping the registered handle
387 // makes later send_to_client calls fail fast instead of timing out.
388 app_state.remove_client_handle(client_id).await;
389 });
390 task_registrar
391 .register_client_task(client_id, task_handle)
392 .await;
393
394 Ok(local_addr)
395 }
396
397 /// Parse Telnet data, separating actual data from Telnet commands
398 /// Returns (data, telnet_commands)
399 fn parse_telnet_data(raw: &[u8]) -> (Vec<u8>, Vec<TelnetCommand>) {
400 let mut data = Vec::new();
401 let mut commands = Vec::new();
402 let mut i = 0;
403
404 while i < raw.len() {
405 if raw[i] == IAC {
406 // Telnet command
407 if i + 1 >= raw.len() {
408 break;
409 }
410
411 let cmd = raw[i + 1];
412
413 match cmd {
414 IAC => {
415 // Escaped IAC (255 255 means literal 255)
416 data.push(IAC);
417 i += 2;
418 }
419 WILL | WONT | DO | DONT => {
420 // Option negotiation
421 if i + 2 < raw.len() {
422 let option = raw[i + 2];
423 commands.push(TelnetCommand::Negotiation {
424 command: cmd,
425 option,
426 });
427 i += 3;
428 } else {
429 i += 2;
430 }
431 }
432 SB => {
433 // Subnegotiation - find SE
434 let mut sb_end = i + 2;
435 while sb_end < raw.len() {
436 if raw[sb_end] == IAC && sb_end + 1 < raw.len() && raw[sb_end + 1] == SE
437 {
438 break;
439 }
440 sb_end += 1;
441 }
442 commands.push(TelnetCommand::Subnegotiation);
443 i = sb_end + 2;
444 }
445 _ => {
446 // Other command
447 commands.push(TelnetCommand::Other(cmd));
448 i += 2;
449 }
450 }
451 } else {
452 // Regular data
453 data.push(raw[i]);
454 i += 1;
455 }
456 }
457
458 (data, commands)
459 }
460
461 /// Handle a Telnet command and return response if needed
462 fn handle_telnet_command(
463 cmd: &TelnetCommand,
464 client_id: ClientId,
465 status_tx: &mpsc::UnboundedSender<String>,
466 ) -> Option<Vec<u8>> {
467 match cmd {
468 TelnetCommand::Negotiation { command, option } => {
469 let cmd_name = match *command {
470 WILL => "WILL",
471 WONT => "WONT",
472 DO => "DO",
473 DONT => "DONT",
474 _ => "UNKNOWN",
475 };
476
477 let option_name = Self::get_option_name(*option);
478 debug!(
479 "Telnet client {} received {} {}",
480 client_id, cmd_name, option_name
481 );
482
483 let _ = status_tx.send(format!(
484 "[CLIENT] Telnet {} negotiation: {} {}",
485 client_id, cmd_name, option_name
486 ));
487
488 // Basic negotiation strategy: refuse all options
489 match *command {
490 WILL => {
491 // Server offers to do something - respond with DONT (refuse)
492 Some(vec![IAC, DONT, *option])
493 }
494 DO => {
495 // Server asks us to do something - respond with WONT (refuse)
496 Some(vec![IAC, WONT, *option])
497 }
498 _ => None,
499 }
500 }
501 TelnetCommand::Subnegotiation => {
502 debug!("Telnet client {} received subnegotiation", client_id);
503 None
504 }
505 TelnetCommand::Other(code) => {
506 debug!("Telnet client {} received command code {}", client_id, code);
507 None
508 }
509 }
510 }
511
512 /// Get human-readable name for Telnet option
513 fn get_option_name(option: u8) -> &'static str {
514 match option {
515 0 => "BINARY",
516 1 => "ECHO",
517 3 => "SUPPRESS_GO_AHEAD",
518 5 => "STATUS",
519 6 => "TIMING_MARK",
520 24 => "TERMINAL_TYPE",
521 31 => "WINDOW_SIZE",
522 32 => "TERMINAL_SPEED",
523 33 => "REMOTE_FLOW_CONTROL",
524 34 => "LINEMODE",
525 36 => "ENVIRONMENT_VARIABLES",
526 _ => "UNKNOWN",
527 }
528 }
529}
530
531/// Telnet command types
532#[derive(Debug, Clone)]
533enum TelnetCommand {
534 Negotiation { command: u8, option: u8 },
535 Subnegotiation,
536 Other(u8),
537}