1use crate::llm::actions::{
6 protocol_trait::{ActionResult, Protocol, Server},
7 ActionDefinition, Parameter,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::EventType;
11use crate::state::app_state::AppState;
12use anyhow::{Context, Result};
13use serde_json::json;
14use std::sync::LazyLock;
15use tracing::debug;
16
17pub struct FtpProtocol;
19
20impl FtpProtocol {
21 pub fn new() -> Self {
22 Self
23 }
24
25 fn execute_send_ftp_response(&self, action: serde_json::Value) -> Result<ActionResult> {
26 let code = parse_ftp_code(&action)?;
27
28 let message = action
29 .get("message")
30 .and_then(|v| v.as_str())
31 .context("Missing 'message' parameter (the human-readable text after the code)")?;
32 reject_line_breaks("message", message)?;
33
34 let response = format!("{} {}\r\n", code, message);
35
36 debug!("FTP sending response: {} {}", code, message);
37 Ok(ActionResult::Output(response.as_bytes().to_vec()))
38 }
39
40 fn execute_send_ftp_multiline(&self, action: serde_json::Value) -> Result<ActionResult> {
41 let code = parse_ftp_code(&action)?;
42
43 let lines = action
44 .get("lines")
45 .and_then(|v| v.as_array())
46 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
47 .unwrap_or_default();
48
49 if lines.is_empty() {
50 let message = action
54 .get("message")
55 .and_then(|v| v.as_str())
56 .unwrap_or("OK");
57 reject_line_breaks("message", message)?;
58 let response = format!("{} {}\r\n", code, message);
59 return Ok(ActionResult::Output(response.as_bytes().to_vec()));
60 }
61
62 for line in &lines {
63 reject_line_breaks("lines", line)?;
64 }
65
66 let mut response = String::new();
68 for (i, line) in lines.iter().enumerate() {
69 if i == lines.len() - 1 {
70 response.push_str(&format!("{} {}\r\n", code, line));
72 } else {
73 response.push_str(&format!("{}-{}\r\n", code, line));
75 }
76 }
77
78 debug!("FTP sending multiline response: code {}", code);
79 Ok(ActionResult::Output(response.as_bytes().to_vec()))
80 }
81
82 fn execute_send_ftp_data(&self, action: serde_json::Value) -> Result<ActionResult> {
83 let data = action
84 .get("data")
85 .and_then(|v| v.as_str())
86 .context("Missing 'data' parameter")?;
87
88 let formatted = if data.ends_with("\r\n") {
90 data.to_string()
91 } else if let Some(stripped) = data.strip_suffix('\n') {
92 format!("{}\r\n", stripped)
93 } else {
94 format!("{}\r\n", data)
95 };
96
97 debug!("FTP sending data: {} bytes", formatted.len());
98 Ok(ActionResult::Output(formatted.as_bytes().to_vec()))
99 }
100
101 fn execute_send_ftp_list(&self, action: serde_json::Value) -> Result<ActionResult> {
102 let entries = action
103 .get("entries")
104 .and_then(|v| v.as_array())
105 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
106 .unwrap_or_default();
107
108 for entry in &entries {
109 reject_line_breaks("entries", entry)?;
110 }
111
112 let entry_count = entries.len();
113 let mut response = String::new();
114 for entry in entries {
115 response.push_str(entry);
116 response.push_str("\r\n");
117 }
118
119 debug!("FTP sending LIST data: {} entries", entry_count);
120 Ok(ActionResult::Output(response.as_bytes().to_vec()))
121 }
122}
123
124impl Default for FtpProtocol {
125 fn default() -> Self {
126 Self::new()
127 }
128}
129
130fn reject_line_breaks(field: &str, value: &str) -> Result<()> {
145 if let Some(pos) = value.find(['\r', '\n']) {
146 return Err(anyhow::anyhow!(
147 "FTP '{field}' must not contain CR or LF (found one at byte {pos}): a reply is \
148 terminated by CRLF, so an embedded line break forges a second reply rather than \
149 continuing this one. Use send_ftp_multiline for a reply spanning several lines."
150 ));
151 }
152 Ok(())
153}
154
155fn parse_ftp_code(action: &serde_json::Value) -> Result<u64> {
161 let code = action
162 .get("code")
163 .and_then(|v| v.as_u64())
164 .context("Missing 'code' parameter (a three-digit RFC 959 reply code, e.g. 220)")?;
165
166 if !(100..=599).contains(&code) {
167 return Err(anyhow::anyhow!(
168 "Invalid FTP reply code {code}: RFC 959 codes are three digits in the range 100-599 \
169 (e.g. 220 ready, 230 logged in, 331 need password, 550 not found)."
170 ));
171 }
172
173 Ok(code)
174}
175
176impl Protocol for FtpProtocol {
178 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
179 vec![
188 crate::llm::actions::ParameterDefinition {
189 name: "first_byte_timeout_secs".to_string(),
190 type_hint: "number".to_string(),
191 description: "Seconds a connected peer may send no command at all - not even \
192 a USER after the 220 greeting - before the server closes it. \
193 Default 300, matching the window a `manual` rule gives a human \
194 to answer one event. The peer is often NetGet's own FTP client, \
195 which reads the greeting in its read loop and writes nothing \
196 until an action or [ send message ] says to. Lower it (60 was \
197 the old value) for a listener exposed to strangers."
198 .to_string(),
199 required: false,
200 example: json!(300),
201 default: Some(serde_json::json!(
202 super::FIRST_COMMAND_READ_TIMEOUT.as_secs()
203 )),
204 },
205 crate::llm::actions::ParameterDefinition {
206 name: "idle_timeout_secs".to_string(),
207 type_hint: "number".to_string(),
208 description: "Seconds an established control connection may go without a \
209 further command before the server closes it. Default 300, which \
210 is vsftpd's `idle_session_timeout` default; the silence between \
211 two commands of an interactive session is someone typing, so \
212 this is on a human timescale rather than a machine one."
213 .to_string(),
214 required: false,
215 example: json!(300),
216 default: Some(serde_json::json!(
217 super::IDLE_BETWEEN_COMMANDS_TIMEOUT.as_secs()
218 )),
219 },
220 ]
221 }
222
223 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
224 Vec::new()
226 }
227
228 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
229 vec![
230 send_ftp_response_action(),
231 send_ftp_multiline_action(),
232 send_ftp_data_action(),
233 send_ftp_list_action(),
234 wait_for_more_action(),
235 close_connection_action(),
236 ]
237 }
238
239 fn protocol_name(&self) -> &'static str {
240 "FTP"
241 }
242
243 fn get_event_types(&self) -> Vec<EventType> {
244 get_ftp_event_types()
245 }
246
247 fn stack_name(&self) -> &'static str {
248 "ETH>IP>TCP>FTP"
249 }
250
251 fn keywords(&self) -> Vec<&'static str> {
252 vec!["ftp", "file transfer", "ftp server"]
253 }
254
255 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
256 use crate::protocol::metadata::{
257 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
258 };
259
260 ProtocolMetadataV2::builder()
261 .state(DevelopmentState::Experimental)
262 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(21))
263 .well_known_port(21)
264 .implementation("Manual line-based parsing with tokio")
265 .llm_control("All FTP replies on the control connection")
266 .e2e_testing("raw TCP client (nc); real FTP clients cannot transfer files")
267 .notes(
268 "Control connection only: PASV/PORT are not implemented, so LIST/RETR/STOR \
269 cannot complete against a real FTP client. No TLS. No E2E test.",
270 )
271 .max_inbound_bytes(crate::server::ftp::MAX_COMMAND_LINE)
272 .build()
273 }
274
275 fn description(&self) -> &'static str {
276 "FTP file transfer server"
277 }
278
279 fn example_prompt(&self) -> &'static str {
280 "Start an FTP server on port 21 that allows anonymous login and lists files"
281 }
282
283 fn group_name(&self) -> &'static str {
284 "Application"
285 }
286
287 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
288 use crate::llm::actions::StartupExamples;
289
290 let script = r#"import json, sys
293data = json.load(sys.stdin)
294event = data["event"]
295if data["event_type_id"] == "ftp_command":
296 actions = [{"type": "send_ftp_response", "code": 200, "message": "Command okay"}]
297else:
298 actions = []
299print(json.dumps({"actions": actions}))"#;
300
301 StartupExamples::new(
305 json!({
307 "type": "open_server",
308 "port": 21,
309 "base_stack": "ftp",
310 "instruction": "FTP server that allows anonymous login and responds to FTP commands"
311 }),
312 json!({
314 "type": "open_server",
315 "port": 21,
316 "base_stack": "ftp",
317 "event_handlers": [{
318 "event_pattern": "ftp_command",
319 "handler": {
320 "type": "script",
321 "language": "python",
322 "code": script
323 }
324 }]
325 }),
326 json!({
328 "type": "open_server",
329 "port": 21,
330 "base_stack": "ftp",
331 "event_handlers": [{
332 "event_pattern": "ftp_command",
333 "handler": {
334 "type": "static",
335 "actions": [{
336 "type": "send_ftp_response",
337 "code": 500,
338 "message": "Command not recognized"
339 }]
340 }
341 }]
342 }),
343 )
344 }
345}
346
347impl Server for FtpProtocol {
349 fn spawn(
350 &self,
351 ctx: crate::protocol::SpawnContext,
352 ) -> std::pin::Pin<
353 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
354 > {
355 Box::pin(async move {
356 use crate::server::ftp::FtpServer;
357 #[allow(deprecated)]
358 let listen_addr = ctx.socket_addr().unwrap_or(ctx.legacy_listen_addr());
359 let secs = |name: &str| -> anyhow::Result<Option<u64>> {
362 Ok(ctx
363 .startup_params
364 .as_ref()
365 .map(|p| p.get_optional_u64(name))
366 .transpose()?
367 .flatten())
368 };
369 let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
370 let idle_timeout_secs = secs("idle_timeout_secs")?;
371
372 FtpServer::spawn_with_llm_actions(
373 listen_addr,
374 ctx.llm_client,
375 ctx.state,
376 ctx.status_tx,
377 ctx.server_id,
378 first_byte_timeout_secs,
379 idle_timeout_secs,
380 )
381 .await
382 })
383 }
384
385 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
386 let action_type = action
387 .get("type")
388 .and_then(|v| v.as_str())
389 .context("Missing 'type' field in action")?;
390
391 match action_type {
392 "send_ftp_response" => self.execute_send_ftp_response(action),
393 "send_ftp_multiline" => self.execute_send_ftp_multiline(action),
394 "send_ftp_data" => self.execute_send_ftp_data(action),
395 "send_ftp_list" => self.execute_send_ftp_list(action),
396 "wait_for_more" => Ok(ActionResult::WaitForMore),
397 "close_connection" => Ok(ActionResult::CloseConnection),
398 _ => Err(anyhow::anyhow!("Unknown FTP action: {}", action_type)),
399 }
400 }
401}
402
403fn send_ftp_response_action() -> ActionDefinition {
406 ActionDefinition {
407 name: "send_ftp_response".to_string(),
408 description: "Send a single-line RFC 959 reply on the FTP control connection. The bytes \
409 put on the wire are exactly \"<code> <message>\\r\\n\" - do not include the code in \
410 'message' and do not add your own line ending. This is the normal way to answer every \
411 FTP command."
412 .to_string(),
413 parameters: vec![
414 Parameter {
415 name: "code".to_string(),
416 type_hint: "number".to_string(),
417 description: "Three-digit RFC 959 reply code, 100-599. 220 ready, 221 goodbye, \
418 230 logged in, 250 command ok, 257 the current directory (the answer to PWD, \
419 with the path in double quotes: code 257, message \"\\\"/pub\\\" is the \
420 current directory\"), 331 need password, 500 syntax error, 530 not logged \
421 in, 550 file unavailable. Any value outside 100-599 is rejected"
422 .to_string(),
423 required: true,
424 },
425 Parameter {
426 name: "message".to_string(),
427 type_hint: "string".to_string(),
428 description: "Human-readable text placed after the code on the same line. Must \
429 not contain CR or LF - use send_ftp_multiline for a reply spanning several \
430 lines"
431 .to_string(),
432 required: true,
433 },
434 ],
435 example: json!({
436 "type": "send_ftp_response",
437 "code": 220,
438 "message": "FTP Server Ready"
439 }),
440 log_template: Some(
441 LogTemplate::new()
442 .with_info("-> FTP {code} {message}")
443 .with_debug("FTP send_ftp_response: {code} {message}"),
444 ),
445 }
446}
447
448fn send_ftp_multiline_action() -> ActionDefinition {
449 ActionDefinition {
450 name: "send_ftp_multiline".to_string(),
451 description: "Send a multi-line RFC 959 reply, used for FEAT, HELP, STAT and similar. \
452 Every line but the last is written as \"<code>-<line>\\r\\n\" and the last as \
453 \"<code> <line>\\r\\n\", which is what tells the client the reply has ended. Supply \
454 only the text of each line; the code and separators are added for you."
455 .to_string(),
456 parameters: vec![
457 Parameter {
458 name: "code".to_string(),
459 type_hint: "number".to_string(),
460 description: "Three-digit RFC 959 reply code, 100-599, repeated on every line of \
461 the reply (e.g. 211 for FEAT, 214 for HELP)"
462 .to_string(),
463 required: true,
464 },
465 Parameter {
466 name: "lines".to_string(),
467 type_hint: "array".to_string(),
468 description: "Array of strings, one per line of the reply, in order. Include the \
469 closing line (e.g. \"End\") yourself. If this is omitted or empty the action \
470 falls back to sending a single-line reply using the 'message' field"
471 .to_string(),
472 required: true,
473 },
474 ],
475 example: json!({
476 "type": "send_ftp_multiline",
477 "code": 211,
478 "lines": ["Features:", "UTF8", "End"]
479 }),
480 log_template: Some(
481 LogTemplate::new()
482 .with_info("-> FTP {code} multiline")
483 .with_debug("FTP send_ftp_multiline: code={code}, lines={lines_len}"),
484 ),
485 }
486}
487
488fn send_ftp_data_action() -> ActionDefinition {
489 ActionDefinition {
490 name: "send_ftp_data".to_string(),
491 description: "Write raw text on the FTP CONTROL connection, terminated with CRLF. \
492 WARNING: this server implements no data connection (PASV/PORT are not supported), so \
493 this does NOT perform an FTP file transfer - the bytes appear inline in the command \
494 channel. A real FTP client will reject or misparse them. Use it only with raw clients \
495 such as `nc`, or to send a reply line that send_ftp_response cannot express."
496 .to_string(),
497 parameters: vec![Parameter {
498 name: "data".to_string(),
499 type_hint: "string".to_string(),
500 description: "Text to write on the control connection. Exactly one trailing CRLF is \
501 ensured: a bare \"\\n\" is upgraded to \"\\r\\n\" and a missing ending is added"
502 .to_string(),
503 required: true,
504 }],
505 example: json!({
506 "type": "send_ftp_data",
507 "data": "Hello from FTP"
508 }),
509 log_template: Some(
510 LogTemplate::new()
511 .with_info("-> FTP data {output_bytes}B")
512 .with_debug("FTP send_ftp_data: {output_bytes}B")
513 .with_trace("FTP data: {preview(data,200)}"),
514 ),
515 }
516}
517
518fn send_ftp_list_action() -> ActionDefinition {
519 ActionDefinition {
520 name: "send_ftp_list".to_string(),
521 description: "Write directory listing lines, one per entry, each terminated with CRLF. \
522 WARNING: a real FTP client expects a LIST/NLST listing on a separate data connection, \
523 and this server has none - the lines go out on the control connection instead, where \
524 a real client will not accept them. Useful for raw clients (`nc`) and for showing a \
525 plausible listing to an attacker; not for interoperating with `ftp`/`lftp`/`curl`."
526 .to_string(),
527 parameters: vec![Parameter {
528 name: "entries".to_string(),
529 type_hint: "array".to_string(),
530 description: "Array of strings, one per directory entry, already formatted as \
531 `ls -l` output (permissions, links, owner, group, size, date, name). Do not \
532 include line endings; CRLF is appended to each entry"
533 .to_string(),
534 required: true,
535 }],
536 example: json!({
537 "type": "send_ftp_list",
538 "entries": [
539 "-rw-r--r-- 1 ftp ftp 1024 Jan 01 00:00 file.txt",
540 "drwxr-xr-x 2 ftp ftp 4096 Jan 01 00:00 subdir"
541 ]
542 }),
543 log_template: Some(
544 LogTemplate::new()
545 .with_info("-> FTP LIST {entries_len} entries")
546 .with_debug("FTP send_ftp_list: {entries_len} entries"),
547 ),
548 }
549}
550
551fn wait_for_more_action() -> ActionDefinition {
552 ActionDefinition {
553 name: "wait_for_more".to_string(),
554 description: "Wait for more data before responding".to_string(),
555 parameters: vec![],
556 example: json!({
557 "type": "wait_for_more"
558 }),
559 log_template: Some(LogTemplate::new().with_debug("FTP waiting for more data")),
560 }
561}
562
563fn close_connection_action() -> ActionDefinition {
564 ActionDefinition {
565 name: "close_connection".to_string(),
566 description: "Close the FTP connection".to_string(),
567 parameters: vec![],
568 example: json!({
569 "type": "close_connection"
570 }),
571 log_template: Some(
572 LogTemplate::new()
573 .with_info("FTP connection closed")
574 .with_debug("FTP close_connection"),
575 ),
576 }
577}
578
579pub static SEND_FTP_RESPONSE_ACTION: LazyLock<ActionDefinition> =
584 LazyLock::new(send_ftp_response_action);
585pub static SEND_FTP_MULTILINE_ACTION: LazyLock<ActionDefinition> =
586 LazyLock::new(send_ftp_multiline_action);
587pub static SEND_FTP_DATA_ACTION: LazyLock<ActionDefinition> = LazyLock::new(send_ftp_data_action);
588pub static SEND_FTP_LIST_ACTION: LazyLock<ActionDefinition> = LazyLock::new(send_ftp_list_action);
589pub static WAIT_FOR_MORE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(wait_for_more_action);
590pub static CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> =
591 LazyLock::new(close_connection_action);
592
593pub static FTP_COMMAND_EVENT: LazyLock<EventType> =
599 LazyLock::new(|| {
600 EventType::new(
601 "ftp_command",
602 "A client connected, or sent a command line on the FTP control connection. This is the \
603 only FTP event: the connect case is signalled by the literal command \
604 'CONNECTION_ESTABLISHED', for which you must reply with a 220 greeting before the client \
605 will send anything else.",
606 json!({"type": "send_ftp_response", "code": 220, "message": "FTP Server Ready"}),
607 )
608 .with_parameters(vec![Parameter {
609 name: "command".to_string(),
610 type_hint: "string".to_string(),
611 description: "The command line as sent by the client, with the trailing CRLF stripped \
612 (e.g. 'USER anonymous', 'PASS x@y.z', 'SYST', 'PWD', 'LIST', 'RETR file.txt', \
613 'QUIT'). The verb is NOT upper-cased or split out for you. One exception: the \
614 literal value 'CONNECTION_ESTABLISHED' is not from the client - it means the TCP \
615 connection has just been accepted and the server is waiting for your 220 greeting"
616 .to_string(),
617 required: true,
618 },
619 Parameter {
620 name: "answer_with".to_string(),
621 type_hint: "string".to_string(),
622 description: "The reply RFC 959 expects for this command, derived by the server from \
623 its verb. Absent for a verb the server does not classify."
624 .to_string(),
625 required: false,
626 }])
627 .with_actions(vec![
628 SEND_FTP_RESPONSE_ACTION.clone(),
629 SEND_FTP_MULTILINE_ACTION.clone(),
630 SEND_FTP_DATA_ACTION.clone(),
631 SEND_FTP_LIST_ACTION.clone(),
632 WAIT_FOR_MORE_ACTION.clone(),
633 CLOSE_CONNECTION_ACTION.clone(),
634 ])
635 .with_log_template(
636 LogTemplate::new()
637 .with_info("FTP {client_ip}: {command}")
638 .with_debug("FTP command from {client_ip}:{client_port}: {command}")
639 .with_trace("FTP: {json_pretty(.)}"),
640 )
641 });
642
643pub fn answer_with_for_command(command: &str) -> Option<&'static str> {
651 let verb = command
652 .split_whitespace()
653 .next()
654 .unwrap_or("")
655 .to_ascii_uppercase();
656 Some(match verb.as_str() {
657 "CONNECTION_ESTABLISHED" => "one 220 greeting",
658 "USER" => {
659 "one reply: 331 to ask for a password, or 230 if this user is logged in with no \
660 password - not both"
661 }
662 "PASS" => "one reply: 230 if the login succeeds, 530 if it does not",
663 "PWD" | "XPWD" => {
664 "one 257 reply whose message starts with the directory in double quotes, e.g. \
665 \"/pub\" is the current directory"
666 }
667 "CWD" | "XCWD" | "CDUP" => {
668 "one reply: 250 if the directory changed, 550 if it does not exist"
669 }
670 "SYST" => "one 215 reply naming the system type, e.g. UNIX Type: L8",
671 "TYPE" | "MODE" | "STRU" | "NOOP" => "one 200 reply",
672 "QUIT" => "one 221 reply, then the server closes",
673 "FEAT" => "a multi-line 211 reply listing features, or 502 if there are none",
674 _ => return None,
675 })
676}
677
678pub fn get_ftp_event_types() -> Vec<EventType> {
680 vec![FTP_COMMAND_EVENT.clone()]
681}