Skip to main content

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}