Skip to main content

netget/client/tftp/
actions.rs

1//! TFTP client protocol actions
2
3use crate::llm::actions::client_trait::{Client, ClientActionResult};
4use crate::llm::actions::{ActionDefinition, Parameter};
5use crate::protocol::{ConnectContext, EventType};
6use crate::state::app_state::AppState;
7use anyhow::{Context, Result};
8use serde_json::json;
9use std::future::Future;
10use std::net::SocketAddr;
11use std::pin::Pin;
12use std::sync::LazyLock;
13
14pub struct TftpClientProtocol;
15
16impl Default for TftpClientProtocol {
17    fn default() -> Self {
18        Self
19    }
20}
21
22impl TftpClientProtocol {
23    pub fn new() -> Self {
24        Self
25    }
26}
27
28fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
29    Parameter {
30        name: name.to_string(),
31        type_hint: type_hint.to_string(),
32        description: description.to_string(),
33        required,
34    }
35}
36
37// Event type constants
38pub static TFTP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
39    EventType::new(
40        "tftp_connected",
41        "TFTP client socket bound; choose a transfer to start",
42        json!({"type": "tftp_read_file", "filename": "pxelinux.0", "mode": "octet"}),
43    )
44    .with_parameters(vec![
45        param("server_addr", "string", "Server socket address", true),
46        param(
47            "local_addr",
48            "string",
49            "Local socket address (client TID)",
50            true,
51        ),
52    ])
53    .with_actions(vec![
54        tftp_read_file_action(),
55        tftp_write_file_action(),
56        disconnect_action(),
57    ])
58});
59
60pub static TFTP_CLIENT_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
61    EventType::new(
62        "tftp_data_received",
63        "Received data block from server",
64        json!({"type": "send_ack", "block_number": 1}),
65    )
66    .with_parameters(vec![
67        param("block_number", "number", "Block number received", true),
68        param("data_hex", "string", "Block data, hex-encoded", true),
69        param("data_length", "number", "Number of bytes in block", true),
70        param(
71            "is_final",
72            "boolean",
73            "True if final block (< 512 bytes)",
74            true,
75        ),
76        param("total_bytes", "number", "Total bytes received so far", true),
77    ])
78    .with_actions(vec![send_ack_action(), disconnect_action()])
79});
80
81pub static TFTP_CLIENT_ACK_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
82    EventType::new(
83        "tftp_ack_received",
84        "Server acknowledged a block; send the next one",
85        json!({"type": "send_data_block", "block_number": 1, "data_hex": "48656c6c6f"}),
86    )
87    .with_parameters(vec![param(
88        "block_number",
89        "number",
90        "Block number acknowledged",
91        true,
92    )])
93    .with_actions(vec![send_data_block_action(), disconnect_action()])
94});
95
96pub static TFTP_CLIENT_TRANSFER_COMPLETE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
97    EventType::new(
98        "tftp_transfer_complete",
99        "File transfer completed",
100        json!({"type": "disconnect"}),
101    )
102    .with_parameters(vec![
103        param("total_bytes", "number", "Total bytes transferred", true),
104        param("total_blocks", "number", "Total blocks transferred", true),
105    ])
106    .with_actions(vec![disconnect_action()])
107});
108
109pub static TFTP_CLIENT_ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
110    EventType::new(
111        "tftp_error",
112        "TFTP error received from server",
113        json!({"type": "disconnect"}),
114    )
115    .with_parameters(vec![
116        param("error_code", "number", "TFTP error code", true),
117        param("error_message", "string", "Error message from server", true),
118    ])
119    .with_actions(vec![disconnect_action()])
120});
121
122// Implement Protocol trait (common functionality)
123impl crate::llm::actions::protocol_trait::Protocol for TftpClientProtocol {
124    /// What a caller can ask for at any time: start a read, start a write, or give up.
125    ///
126    /// The mid-transfer actions (`send_ack`, `send_data_block`) are deliberately **not**
127    /// repeated here — they belong to `get_sync_actions()` and to the events that need them.
128    /// This list carried them for a while as a workaround: `call_llm_for_client` used to build
129    /// the model's tool list from `get_async_actions()` alone, never `get_sync_actions()` and
130    /// never `event.event_type.actions`, so `send_ack` was advertised nowhere and every DATA
131    /// block came back "Unknown Action" with the transfer stalled at block 1
132    /// (`tests/client/tftp/e2e_test.rs::reads_a_two_block_file` catches exactly that).
133    ///
134    /// That is fixed centrally now — `client_llm_action_set` advertises the union of async,
135    /// sync and the firing event's own actions — so the honest declaration is back, and the
136    /// TFTP e2e test passing with `send_ack` sync-only is the proof the central fix works.
137    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
138        vec![
139            tftp_read_file_action(),
140            tftp_write_file_action(),
141            disconnect_action(),
142        ]
143    }
144
145    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
146        vec![
147            send_ack_action(),
148            send_data_block_action(),
149            disconnect_action(),
150        ]
151    }
152
153    fn protocol_name(&self) -> &'static str {
154        "TFTP"
155    }
156
157    fn get_event_types(&self) -> Vec<EventType> {
158        vec![
159            TFTP_CLIENT_CONNECTED_EVENT.clone(),
160            TFTP_CLIENT_DATA_RECEIVED_EVENT.clone(),
161            TFTP_CLIENT_ACK_RECEIVED_EVENT.clone(),
162            TFTP_CLIENT_TRANSFER_COMPLETE_EVENT.clone(),
163            TFTP_CLIENT_ERROR_EVENT.clone(),
164        ]
165    }
166
167    fn stack_name(&self) -> &'static str {
168        "ETH>IP>UDP>TFTP"
169    }
170
171    fn keywords(&self) -> Vec<&'static str> {
172        vec!["tftp", "tftp client", "trivial file transfer"]
173    }
174
175    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
176        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
177
178        ProtocolMetadataV2::builder()
179            .state(DevelopmentState::Experimental)
180            .implementation(
181                "Hand-rolled RFC 1350 packet encode/decode over tokio UdpSocket. \
182                 Learns the server TID from the first reply. No RFC 2347 option \
183                 negotiation, no retransmission (5s timeout aborts).",
184            )
185            .llm_control(
186                "Model picks read vs write, acknowledges each DATA block, and supplies \
187                 every outbound DATA block. NetGet invents no request on its own.",
188            )
189            .e2e_testing(
190                "Validated against NetGet's own TFTP server (tests/client/tftp) — \
191                 a same-project peer, which is weaker evidence than an independent \
192                 implementation. Packet encode/decode is additionally checked against \
193                 RFC 1350 literal bytes.",
194            )
195            .notes(
196                "Re-enabled 2026-08; had been commented out of the registry since \
197                 2025-11 pending the call_llm_for_client signature change. Write \
198                 transfers are LLM-driven block by block, so a large upload costs one \
199                 LLM call per 512 bytes.",
200            )
201            .build()
202    }
203
204    fn description(&self) -> &'static str {
205        "TFTP client for file transfers"
206    }
207
208    fn example_prompt(&self) -> &'static str {
209        "connect to 192.168.1.1:69 via tftp. Read file pxelinux.0"
210    }
211
212    fn group_name(&self) -> &'static str {
213        "Clients"
214    }
215
216    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
217        Vec::new()
218    }
219
220    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
221        use crate::llm::actions::StartupExamples;
222
223        StartupExamples::new(
224            json!({
225                "type": "open_client",
226                "remote_addr": "192.168.1.1:69",
227                "base_stack": "tftp",
228                "instruction": "Read the file pxelinux.0 in octet mode and report its size"
229            }),
230            json!({
231                "type": "open_client",
232                "remote_addr": "192.168.1.1:69",
233                "base_stack": "tftp",
234                "event_handlers": [{
235                    "event_pattern": "tftp_data_received",
236                    "handler": {
237                        "type": "script",
238                        "language": "python",
239                        "code": "<tftp_client_handler>"
240                    }
241                }]
242            }),
243            json!({
244                "type": "open_client",
245                "remote_addr": "192.168.1.1:69",
246                "base_stack": "tftp",
247                "event_handlers": [
248                    {
249                        "event_pattern": "tftp_connected",
250                        "handler": {
251                            "type": "static",
252                            "actions": [{
253                                "type": "tftp_read_file",
254                                "filename": "pxelinux.0",
255                                "mode": "octet"
256                            }]
257                        }
258                    },
259                    {
260                        "event_pattern": "tftp_transfer_complete",
261                        "handler": {
262                            "type": "static",
263                            "actions": [{"type": "disconnect"}]
264                        }
265                    }
266                ]
267            }),
268        )
269    }
270}
271
272// Implement Client trait (client-specific functionality)
273impl Client for TftpClientProtocol {
274    fn connect(
275        &self,
276        ctx: ConnectContext,
277    ) -> Pin<Box<dyn Future<Output = Result<SocketAddr>> + Send>> {
278        Box::pin(async move {
279            crate::client::tftp::TftpClient::connect_with_llm_actions(
280                ctx.remote_addr,
281                ctx.llm_client,
282                ctx.state,
283                ctx.status_tx,
284                ctx.client_id,
285            )
286            .await
287        })
288    }
289
290    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
291        let action_type = action
292            .get("type")
293            .and_then(|v| v.as_str())
294            .context("Missing 'type' field in action")?;
295
296        match action_type {
297            "tftp_read_file" | "tftp_write_file" => {
298                let filename = action
299                    .get("filename")
300                    .and_then(|v| v.as_str())
301                    .with_context(|| format!("Missing 'filename' in {}", action_type))?;
302                let mode = action
303                    .get("mode")
304                    .and_then(|v| v.as_str())
305                    .unwrap_or("octet");
306                if mode != "octet" && mode != "netascii" {
307                    return Err(anyhow::anyhow!(
308                        "TFTP mode must be 'octet' or 'netascii', got '{}'",
309                        mode
310                    ));
311                }
312                Ok(ClientActionResult::Custom {
313                    name: action_type.to_string(),
314                    data: json!({ "filename": filename, "mode": mode }),
315                })
316            }
317            "send_ack" => {
318                let block_number = action
319                    .get("block_number")
320                    .and_then(|v| v.as_u64())
321                    .context("Missing 'block_number' in send_ack")?;
322                let block_number =
323                    u16::try_from(block_number).context("TFTP block_number must fit in 16 bits")?;
324
325                // ACK packet: opcode(2) + block_number(2)
326                let mut packet = Vec::with_capacity(4);
327                packet.extend_from_slice(&crate::client::tftp::OP_ACK.to_be_bytes());
328                packet.extend_from_slice(&block_number.to_be_bytes());
329
330                Ok(ClientActionResult::SendData(packet))
331            }
332            "send_data_block" => {
333                let block_number = action
334                    .get("block_number")
335                    .and_then(|v| v.as_u64())
336                    .context("Missing 'block_number' in send_data_block")?;
337                let block_number =
338                    u16::try_from(block_number).context("TFTP block_number must fit in 16 bits")?;
339
340                let data_hex = action
341                    .get("data_hex")
342                    .and_then(|v| v.as_str())
343                    .context("Missing 'data_hex' in send_data_block")?;
344
345                let data =
346                    hex::decode(data_hex).context("Failed to decode hex in send_data_block")?;
347
348                if data.len() > crate::client::tftp::TFTP_BLOCK_SIZE {
349                    return Err(anyhow::anyhow!(
350                        "TFTP data block cannot exceed {} bytes, got {}",
351                        crate::client::tftp::TFTP_BLOCK_SIZE,
352                        data.len()
353                    ));
354                }
355
356                // DATA packet: opcode(2) + block_number(2) + data
357                let mut packet = Vec::with_capacity(4 + data.len());
358                packet.extend_from_slice(&crate::client::tftp::OP_DATA.to_be_bytes());
359                packet.extend_from_slice(&block_number.to_be_bytes());
360                packet.extend_from_slice(&data);
361
362                Ok(ClientActionResult::SendData(packet))
363            }
364            "disconnect" => Ok(ClientActionResult::Disconnect),
365            _ => Err(anyhow::anyhow!(
366                "Unknown TFTP client action: {}",
367                action_type
368            )),
369        }
370    }
371}
372
373// Action definitions
374
375fn tftp_read_file_action() -> ActionDefinition {
376    ActionDefinition {
377        name: "tftp_read_file".to_string(),
378        description: "Request a file from the TFTP server (sends RRQ)".to_string(),
379        parameters: vec![
380            param("filename", "string", "Name of file to read", true),
381            param(
382                "mode",
383                "string",
384                "Transfer mode: 'octet' or 'netascii' (default: octet)",
385                false,
386            ),
387        ],
388        example: json!({
389            "type": "tftp_read_file",
390            "filename": "pxelinux.0",
391            "mode": "octet"
392        }),
393        log_template: None,
394    }
395}
396
397fn tftp_write_file_action() -> ActionDefinition {
398    ActionDefinition {
399        name: "tftp_write_file".to_string(),
400        description: "Offer a file to the TFTP server (sends WRQ). The file content is not \
401                      supplied here: the server answers with ACK block 0, and you send each \
402                      512-byte block with send_data_block. A block shorter than 512 bytes \
403                      ends the transfer."
404            .to_string(),
405        parameters: vec![
406            param("filename", "string", "Name of file to write", true),
407            param(
408                "mode",
409                "string",
410                "Transfer mode: 'octet' or 'netascii' (default: octet)",
411                false,
412            ),
413        ],
414        example: json!({
415            "type": "tftp_write_file",
416            "filename": "config.txt",
417            "mode": "octet"
418        }),
419        log_template: None,
420    }
421}
422
423fn send_ack_action() -> ActionDefinition {
424    ActionDefinition {
425        name: "send_ack".to_string(),
426        description: "Acknowledge a received DATA block".to_string(),
427        parameters: vec![param(
428            "block_number",
429            "number",
430            "Block number to acknowledge (echo the one from the event)",
431            true,
432        )],
433        example: json!({
434            "type": "send_ack",
435            "block_number": 5
436        }),
437        log_template: None,
438    }
439}
440
441fn send_data_block_action() -> ActionDefinition {
442    ActionDefinition {
443        name: "send_data_block".to_string(),
444        description: "Send one DATA block to the server. A block shorter than 512 bytes is \
445                      the last one and ends the transfer."
446            .to_string(),
447        parameters: vec![
448            param(
449                "block_number",
450                "number",
451                "Block number (one more than the block just acknowledged)",
452                true,
453            ),
454            param(
455                "data_hex",
456                "string",
457                "Block payload, hex-encoded, at most 512 bytes decoded",
458                true,
459            ),
460        ],
461        example: json!({
462            "type": "send_data_block",
463            "block_number": 1,
464            "data_hex": "48656c6c6f"
465        }),
466        log_template: None,
467    }
468}
469
470fn disconnect_action() -> ActionDefinition {
471    ActionDefinition {
472        name: "disconnect".to_string(),
473        description: "Abandon the transfer and close the client".to_string(),
474        parameters: vec![],
475        example: json!({
476            "type": "disconnect"
477        }),
478        log_template: None,
479    }
480}