1use 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
37pub 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
122impl crate::llm::actions::protocol_trait::Protocol for TftpClientProtocol {
124 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
272impl 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 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 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
373fn 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}