Skip to main content

netget/server/telnet/
actions.rs

1//! Telnet protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13use tracing::debug;
14
15/// Telnet protocol action handler
16pub struct TelnetProtocol;
17
18impl TelnetProtocol {
19    pub fn new() -> Self {
20        Self
21    }
22
23    fn execute_send_telnet_message(&self, action: serde_json::Value) -> Result<ActionResult> {
24        let message = action
25            .get("message")
26            .and_then(|v| v.as_str())
27            .context("Missing 'message' parameter")?;
28
29        debug!("Telnet sending message: {}", message.trim());
30        Ok(ActionResult::Output(message.as_bytes().to_vec()))
31    }
32
33    fn execute_send_telnet_line(&self, action: serde_json::Value) -> Result<ActionResult> {
34        let line = action
35            .get("line")
36            .and_then(|v| v.as_str())
37            .context("Missing 'line' parameter")?;
38
39        // Ensure the line ends with exactly one CRLF.
40        let formatted = if line.ends_with("\r\n") {
41            line.to_string()
42        } else if let Some(stripped) = line.strip_suffix('\n') {
43            format!("{}\r\n", stripped)
44        } else {
45            format!("{}\r\n", line)
46        };
47
48        debug!("Telnet sending line: {}", formatted.trim());
49        Ok(ActionResult::Output(formatted.as_bytes().to_vec()))
50    }
51
52    fn execute_send_telnet_prompt(&self, action: serde_json::Value) -> Result<ActionResult> {
53        let prompt = action
54            .get("prompt")
55            .and_then(|v| v.as_str())
56            .unwrap_or("> ");
57
58        debug!("Telnet sending prompt: {:?}", prompt);
59        Ok(ActionResult::Output(prompt.as_bytes().to_vec()))
60    }
61}
62
63// Implement Protocol trait (common functionality)
64impl Protocol for TelnetProtocol {
65    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
66        vec![
67                crate::llm::actions::ParameterDefinition {
68                    name: "send_first".to_string(),
69                    type_hint: "boolean".to_string(),
70                    description: "The peer is owed a greeting: a telnet_connection_opened answer with nothing in it is logged as a missing banner, and a backend failure prints a notice line. telnet_connection_opened is raised for every connection either way, and nothing the client types is read until it has been answered".to_string(),
71                    required: false,
72                    example: serde_json::json!(true),
73                    default: None,
74                },
75                crate::llm::actions::ParameterDefinition {
76                    name: "first_byte_timeout_secs".to_string(),
77                    type_hint: "number".to_string(),
78                    description: "Seconds a connected peer may send no line at all before the \
79                                  server closes it. Default 300, matching the window a \
80                                  `manual` rule gives a human to answer one event - which is \
81                                  the relevant number, because the other end of a telnet \
82                                  session is usually a person, and NetGet's own telnet client \
83                                  writes nothing until an action or [ send message ] says to. \
84                                  Lower it (120 was the old value) for a listener exposed to \
85                                  strangers."
86                        .to_string(),
87                    required: false,
88                    example: serde_json::json!(300),
89                    default: Some(serde_json::json!(super::FIRST_LINE_READ_TIMEOUT.as_secs())),
90                },
91                crate::llm::actions::ParameterDefinition {
92                    name: "idle_timeout_secs".to_string(),
93                    type_hint: "number".to_string(),
94                    description: "Seconds an established session may go without a further line \
95                                  before the server closes it. Default 600, which is Cisco \
96                                  IOS's `exec-timeout 10 0` for vty lines - a person thinks, \
97                                  reads output and types again, so this is on a human \
98                                  timescale rather than a machine one."
99                        .to_string(),
100                    required: false,
101                    example: serde_json::json!(600),
102                    default: Some(serde_json::json!(super::IDLE_BETWEEN_LINES_TIMEOUT.as_secs())),
103                },
104            ]
105    }
106    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
107        // Telnet doesn't need async actions for now
108        Vec::new()
109    }
110    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
111        vec![
112            send_telnet_message_action(),
113            send_telnet_line_action(),
114            send_telnet_prompt_action(),
115            wait_for_more_action(),
116            close_connection_action(),
117        ]
118    }
119    fn protocol_name(&self) -> &'static str {
120        "Telnet"
121    }
122    fn get_event_types(&self) -> Vec<EventType> {
123        get_telnet_event_types()
124    }
125    fn stack_name(&self) -> &'static str {
126        "ETH>IP>TCP>Telnet"
127    }
128    fn keywords(&self) -> Vec<&'static str> {
129        vec!["telnet"]
130    }
131    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
132        use crate::protocol::metadata::{
133            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
134        };
135
136        ProtocolMetadataV2::builder()
137            .state(DevelopmentState::Experimental)
138            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(23))
139            .well_known_port(23)
140            .implementation(
141                "Line-based text over TCP. IAC sequences are stripped from the stream but not \
142                 answered; lines are capped at 8 KiB.",
143            )
144            .llm_control("Every byte sent to the terminal")
145            .e2e_testing(
146                "tests/server/telnet/: test.rs (echo, prompt, multiple lines, concurrent \
147                 connections) and llm_failure_test.rs over raw TCP, plus line_framing_test.rs, \
148                 which sends a real telnet(1) negotiation preamble and asserts the handler \
149                 received the typed line alone, and asserts an 8 KiB run with no newline is \
150                 refused rather than buffered.",
151            )
152            .notes(
153                "Telnet-lite: IAC/WILL/WONT/DO/DONT and subnegotiations are recognised only \
154                 well enough to be removed from the data stream, never answered, so a real \
155                 telnet client stays in its default line mode and gets no reply to its offers. \
156                 Absent: character-at-a-time mode, TTYPE/NAWS, terminal emulation, TLS.",
157            )
158            .max_inbound_bytes(crate::server::telnet::MAX_LINE_BYTES)
159            .build()
160    }
161    fn description(&self) -> &'static str {
162        "Telnet terminal server"
163    }
164    fn example_prompt(&self) -> &'static str {
165        "Start a telnet server on port 23 that echoes commands"
166    }
167    fn group_name(&self) -> &'static str {
168        "Application"
169    }
170
171    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
172        use crate::llm::actions::StartupExamples;
173        use serde_json::json;
174
175        // Deterministic: echo each line back to the client, no LLM call.
176        let script = r#"import json, sys
177data = json.load(sys.stdin)
178event = data["event"]
179if data["event_type_id"] == "telnet_message_received":
180    actions = [{"type": "send_telnet_line",
181                "line": "you said: " + str(event.get("message", ""))}]
182else:
183    actions = []
184print(json.dumps({"actions": actions}))"#;
185
186        StartupExamples::new(
187            // LLM mode: LLM handles all Telnet responses intelligently
188            json!({
189                "type": "open_server",
190                "port": 23,
191                "base_stack": "telnet",
192                "send_first": true,
193                "instruction": "Telnet terminal server: greet with a login banner on connect, then handle user commands"
194            }),
195            // Script mode: Code-based deterministic responses
196            json!({
197                "type": "open_server",
198                "port": 23,
199                "base_stack": "telnet",
200                "event_handlers": [{
201                    "event_pattern": "telnet_message_received",
202                    "handler": {
203                        "type": "script",
204                        "language": "python",
205                        "code": script
206                    }
207                }]
208            }),
209            // Static mode: Fixed responses
210            json!({
211                "type": "open_server",
212                "port": 23,
213                "base_stack": "telnet",
214                "event_handlers": [{
215                    "event_pattern": "telnet_message_received",
216                    "handler": {
217                        "type": "static",
218                        "actions": [{
219                            "type": "send_telnet_line",
220                            "line": "Command received"
221                        }]
222                    }
223                }]
224            }),
225        )
226    }
227}
228
229// Implement Server trait (server-specific functionality)
230impl Server for TelnetProtocol {
231    fn spawn(
232        &self,
233        ctx: crate::protocol::SpawnContext,
234    ) -> std::pin::Pin<
235        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
236    > {
237        Box::pin(async move {
238            use crate::server::telnet::TelnetServer;
239            let send_first = ctx
240                .startup_params
241                .as_ref()
242                .map(|p| p.get_optional_bool("send_first"))
243                .transpose()?
244                .flatten()
245                .unwrap_or(false);
246
247            #[allow(deprecated)]
248            let listen_addr = ctx.socket_addr().unwrap_or(ctx.legacy_listen_addr());
249
250            // Both read deadlines are the operator's to choose: who is on the other end is
251            // the only thing that decides them, and only the operator knows that.
252            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
253                Ok(ctx
254                    .startup_params
255                    .as_ref()
256                    .map(|p| p.get_optional_u64(name))
257                    .transpose()?
258                    .flatten())
259            };
260            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
261            let idle_timeout_secs = secs("idle_timeout_secs")?;
262
263            TelnetServer::spawn_with_llm_actions(
264                listen_addr,
265                ctx.llm_client,
266                ctx.state,
267                ctx.status_tx,
268                ctx.server_id,
269                send_first,
270                first_byte_timeout_secs,
271                idle_timeout_secs,
272            )
273            .await
274        })
275    }
276    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
277        let action_type = action
278            .get("type")
279            .and_then(|v| v.as_str())
280            .context("Missing 'type' field in action")?;
281
282        match action_type {
283            "send_telnet_message" => self.execute_send_telnet_message(action),
284            "send_telnet_line" => self.execute_send_telnet_line(action),
285            "send_telnet_prompt" => self.execute_send_telnet_prompt(action),
286            "wait_for_more" => Ok(ActionResult::WaitForMore),
287            "close_connection" => Ok(ActionResult::CloseConnection),
288            _ => Err(anyhow::anyhow!("Unknown Telnet action: {}", action_type)),
289        }
290    }
291}
292
293// Action definitions
294
295fn send_telnet_message_action() -> ActionDefinition {
296    ActionDefinition {
297        name: "send_telnet_message".to_string(),
298        description: "Send a raw Telnet message (exact bytes, no modification)".to_string(),
299        parameters: vec![Parameter {
300            name: "message".to_string(),
301            type_hint: "string".to_string(),
302            description: "Message to send (sent as-is, no newline added)".to_string(),
303            required: true,
304        }],
305        example: json!({
306            "type": "send_telnet_message",
307            "message": "Hello\r\n"
308        }),
309        log_template: Some(
310            LogTemplate::new()
311                .with_info("-> Telnet: {preview(message,60)}")
312                .with_debug("Telnet send_telnet_message: {message_len} bytes"),
313        ),
314    }
315}
316
317fn send_telnet_line_action() -> ActionDefinition {
318    ActionDefinition {
319        name: "send_telnet_line".to_string(),
320        description: "Send a line of text (automatically adds \\r\\n if not present)".to_string(),
321        parameters: vec![Parameter {
322            name: "line".to_string(),
323            type_hint: "string".to_string(),
324            description: "Line of text to send".to_string(),
325            required: true,
326        }],
327        example: json!({
328            "type": "send_telnet_line",
329            "line": "Welcome to the Telnet server!"
330        }),
331        log_template: Some(
332            LogTemplate::new()
333                .with_info("-> Telnet: {preview(line,60)}")
334                .with_debug("Telnet send_telnet_line: {line}"),
335        ),
336    }
337}
338
339fn send_telnet_prompt_action() -> ActionDefinition {
340    ActionDefinition {
341        name: "send_telnet_prompt".to_string(),
342        description: "Send only a prompt such as '> ' or '$ ': no newline and no other text. \
343            A prompt says nothing by itself; to answer the line, use send_telnet_line (and a \
344            prompt after it if you want one)."
345            .to_string(),
346        parameters: vec![Parameter {
347            name: "prompt".to_string(),
348            type_hint: "string".to_string(),
349            description: "Prompt text (default: '> ')".to_string(),
350            required: false,
351        }],
352        example: json!({
353            "type": "send_telnet_prompt",
354            "prompt": "$ "
355        }),
356        log_template: Some(
357            LogTemplate::new()
358                .with_info("-> Telnet prompt: {prompt}")
359                .with_debug("Telnet send_telnet_prompt: prompt={prompt}"),
360        ),
361    }
362}
363
364fn wait_for_more_action() -> ActionDefinition {
365    ActionDefinition {
366        name: "wait_for_more".to_string(),
367        description: "Send nothing and wait for the client's next line. Use it when the line you \
368            just received is only part of a command (e.g. you asked for a username and want the \
369            password before replying). The server reads lines strictly in order, so this simply \
370            means 'no response for this line'."
371            .to_string(),
372        parameters: vec![],
373        example: json!({
374            "type": "wait_for_more"
375        }),
376        log_template: Some(
377            LogTemplate::new()
378                .with_info("Telnet waiting for more data")
379                .with_debug("Telnet wait_for_more"),
380        ),
381    }
382}
383
384fn close_connection_action() -> ActionDefinition {
385    ActionDefinition {
386        name: "close_connection".to_string(),
387        description: "Close the Telnet connection".to_string(),
388        parameters: vec![],
389        example: json!({
390            "type": "close_connection"
391        }),
392        log_template: Some(
393            LogTemplate::new()
394                .with_info("Telnet connection closed")
395                .with_debug("Telnet close_connection"),
396        ),
397    }
398}
399
400// ============================================================================
401// Telnet Event Type Constants
402// ============================================================================
403
404/// Raised for every connection, before the first line is read.
405pub static TELNET_CONNECTION_OPENED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
406    EventType::new(
407        "telnet_connection_opened",
408        "A client opened a Telnet connection and nothing has been sent yet. Raised for every \
409         connection. If the instruction asks to greet connecting clients, show a banner or ask \
410         for a login, send it now. Otherwise answer with no actions at all \
411         ({\"actions\": []}) and wait for the client to type - do not invent a greeting \
412         nobody asked for. The client has typed nothing, so there is no message to react to.",
413        json!({
414            "type": "send_telnet_message",
415            "message": "Welcome to NetGet\r\nlogin: "
416        }),
417    )
418    // Data: `connect_event_data()` - nothing received yet, and the answer that calls for.
419    .with_actions(vec![
420        send_telnet_message_action(),
421        send_telnet_line_action(),
422        send_telnet_prompt_action(),
423        close_connection_action(),
424    ])
425    .with_parameters(crate::protocol::event_type::connect_event_parameters())
426    .raised_on_every_connection()
427    .with_log_template(
428        LogTemplate::new()
429            .with_info("Telnet connection opened from {client_ip}")
430            .with_debug("Telnet connection opened from {client_ip}:{client_port}")
431            .with_trace("Telnet connection opened: {json_pretty(.)}"),
432    )
433});
434
435pub static TELNET_MESSAGE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
436    EventType::new(
437        "telnet_message_received",
438        "A complete line of text arrived from a Telnet client. Lines are split on \\n and the \
439         trailing whitespace/CR is trimmed before you see them. A line longer than 8 KiB with \
440         no newline in it is refused by the server and never raises this event. The event \
441         carries this one line only: nothing said earlier on the connection is repeated in it, \
442         so whatever a later line depends on (a game in progress, a login step, a current \
443         directory) has to be kept in memory with set_memory.",
444        json!({
445            "type": "send_telnet_line",
446            "line": "Command received. Processing..."
447        }),
448    )
449    .with_parameters(vec![Parameter {
450        name: "message".to_string(),
451        type_hint: "string".to_string(),
452        description: "The line the client sent, trimmed of its trailing CR/LF and surrounding \
453            whitespace. Telnet option negotiation is removed before you see it, so a real \
454            telnet client's IAC (0xFF) sequences never appear here - but the server does not \
455            *answer* them either, so the client stays in its default line mode"
456            .to_string(),
457        required: true,
458    }])
459    .with_actions(vec![
460        send_telnet_message_action(),
461        send_telnet_line_action(),
462        send_telnet_prompt_action(),
463        wait_for_more_action(),
464        close_connection_action(),
465    ])
466    .with_log_template(
467        LogTemplate::new()
468            .with_info("Telnet {client_ip}: {preview(message,80)}")
469            .with_debug("Telnet message from {client_ip}:{client_port}")
470            .with_trace("Telnet: {json_pretty(.)}"),
471    )
472});
473
474pub fn get_telnet_event_types() -> Vec<EventType> {
475    vec![
476        TELNET_CONNECTION_OPENED_EVENT.clone(),
477        TELNET_MESSAGE_RECEIVED_EVENT.clone(),
478    ]
479}