1use 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
15pub 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 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
63impl 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 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 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 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 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 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
229impl 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 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
293fn 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
400pub 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 .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}