Skip to main content

netget/client/tftp/
mod.rs

1//! TFTP (RFC 1350) client implementation
2//!
3//! The LLM drives the transfer: it chooses the operation (`tftp_read_file` /
4//! `tftp_write_file`), acknowledges each inbound DATA block (`send_ack`), and supplies each
5//! outbound DATA block (`send_data_block`). NetGet owns only the UDP socket and the packet
6//! framing.
7
8pub mod actions;
9pub use actions::TftpClientProtocol;
10
11use crate::client::llm_budget::call_llm_for_client;
12use crate::llm::actions::client_trait::{Client, ClientActionResult};
13use crate::llm::ollama_client::OllamaClient;
14use crate::llm::ClientLlmResult;
15use crate::protocol::Event;
16use crate::state::app_state::AppState;
17use crate::state::{ClientId, ClientStatus};
18use anyhow::{Context, Result};
19use std::net::SocketAddr;
20use std::sync::Arc;
21use tokio::net::UdpSocket;
22use tokio::sync::{mpsc, Mutex};
23use tracing::{debug, error, info, trace, warn};
24
25use actions::{
26    TFTP_CLIENT_ACK_RECEIVED_EVENT, TFTP_CLIENT_CONNECTED_EVENT, TFTP_CLIENT_DATA_RECEIVED_EVENT,
27    TFTP_CLIENT_ERROR_EVENT, TFTP_CLIENT_TRANSFER_COMPLETE_EVENT,
28};
29
30/// TFTP opcodes (RFC 1350 §5)
31pub const OP_RRQ: u16 = 1;
32pub const OP_WRQ: u16 = 2;
33pub const OP_DATA: u16 = 3;
34pub const OP_ACK: u16 = 4;
35pub const OP_ERROR: u16 = 5;
36
37/// Maximum payload of a TFTP DATA block; a shorter block terminates the transfer.
38pub const TFTP_BLOCK_SIZE: usize = 512;
39
40/// Build an RRQ (opcode 1) or WRQ (opcode 2) packet.
41///
42/// `opcode | filename | 0 | mode | 0`
43pub fn build_request_packet(opcode: u16, filename: &str, mode: &str) -> Vec<u8> {
44    let mut packet = Vec::with_capacity(4 + filename.len() + mode.len());
45    packet.extend_from_slice(&opcode.to_be_bytes());
46    packet.extend_from_slice(filename.as_bytes());
47    packet.push(0);
48    packet.extend_from_slice(mode.as_bytes());
49    packet.push(0);
50    packet
51}
52
53/// A decoded inbound TFTP packet.
54#[derive(Debug, Clone, PartialEq)]
55pub enum TftpPacket {
56    Data { block: u16, payload: Vec<u8> },
57    Ack { block: u16 },
58    Error { code: u16, message: String },
59}
60
61impl TftpPacket {
62    /// Decode a packet received from the server. Returns `None` for opcodes a client
63    /// never receives (RRQ/WRQ) or for a truncated datagram.
64    pub fn decode(data: &[u8]) -> Option<Self> {
65        if data.len() < 4 {
66            return None;
67        }
68        let opcode = u16::from_be_bytes([data[0], data[1]]);
69        let block = u16::from_be_bytes([data[2], data[3]]);
70        match opcode {
71            OP_DATA => Some(TftpPacket::Data {
72                block,
73                payload: data[4..].to_vec(),
74            }),
75            OP_ACK => Some(TftpPacket::Ack { block }),
76            OP_ERROR => {
77                // ErrorCode(2) then a NUL-terminated netascii message.
78                let msg_bytes = &data[4..];
79                let end = msg_bytes
80                    .iter()
81                    .position(|b| *b == 0)
82                    .unwrap_or(msg_bytes.len());
83                Some(TftpPacket::Error {
84                    code: block,
85                    message: String::from_utf8_lossy(&msg_bytes[..end]).to_string(),
86                })
87            }
88            _ => None,
89        }
90    }
91}
92
93/// Which direction the current transfer runs in.
94#[derive(Debug, Clone, Copy, PartialEq)]
95enum Direction {
96    Read,
97    Write,
98}
99
100/// Per-client LLM state (mirrors the per-connection state machine servers use).
101struct ClientData {
102    memory: String,
103}
104
105pub struct TftpClient;
106
107impl TftpClient {
108    pub async fn connect_with_llm_actions(
109        remote_addr: String,
110        llm_client: OllamaClient,
111        app_state: Arc<AppState>,
112        status_tx: mpsc::UnboundedSender<String>,
113        client_id: ClientId,
114    ) -> Result<SocketAddr> {
115        let server_addr: SocketAddr = remote_addr
116            .parse()
117            .with_context(|| format!("Invalid TFTP server address: {}", remote_addr))?;
118
119        let socket = Arc::new(
120            UdpSocket::bind("0.0.0.0:0")
121                .await
122                .context("Failed to bind TFTP client UDP socket")?,
123        );
124        let local_addr = socket.local_addr()?;
125
126        info!(
127            "TFTP client {} bound to {} (server {})",
128            client_id, local_addr, server_addr
129        );
130        app_state
131            .update_client_status(client_id, ClientStatus::Connected)
132            .await;
133        let _ = status_tx.send(format!(
134            "[CLIENT] TFTP client {} bound to {}",
135            client_id, local_addr
136        ));
137        let _ = status_tx.send("__UPDATE_UI__".to_string());
138
139        let client_data = Arc::new(Mutex::new(ClientData {
140            memory: String::new(),
141        }));
142        let protocol = Arc::new(TftpClientProtocol::new());
143
144        // Where the next client packet goes. Starts at the well-known port and is rewritten
145        // by the transfer loop the moment the server answers from its freshly allocated TID
146        // (RFC 1350 §4). Shared, because an injected `send_ack` issued from the command loop
147        // must go to the same place the transfer loop would send it - writing to port 69
148        // after the first reply is the failure this address exists to prevent.
149        let transfer_addr = Arc::new(Mutex::new(server_addr));
150
151        // Command channel for injected actions (the dashboard's [ send ]).
152        //
153        // Registered BEFORE the connected-event LLM call below. That call runs inline here,
154        // and a dashboard-created client defaults to a `*` -> manual routing rule, so it can
155        // park for minutes waiting for a human; [ send ] must work for the whole park.
156        let command_rx =
157            crate::client::command_support::register_command_channel(&app_state, client_id).await;
158
159        let cmd_task = tokio::spawn(Self::command_loop(
160            command_rx,
161            socket.clone(),
162            server_addr,
163            transfer_addr.clone(),
164            llm_client.clone(),
165            app_state.clone(),
166            status_tx.clone(),
167            client_id,
168            client_data.clone(),
169            protocol.clone(),
170        ));
171        app_state.register_client_task(client_id, cmd_task).await;
172
173        // Ask the model what to do with this server.
174        let event = Event::new(
175            &TFTP_CLIENT_CONNECTED_EVENT,
176            serde_json::json!({
177                "server_addr": server_addr.to_string(),
178                "local_addr": local_addr.to_string(),
179            }),
180        );
181
182        let instruction = app_state
183            .get_instruction_for_client(client_id)
184            .await
185            .unwrap_or_default();
186
187        let memory_snapshot = client_data.lock().await.memory.clone();
188        let llm_result = call_llm_for_client(
189            &llm_client,
190            &app_state,
191            client_id.to_string(),
192            &instruction,
193            &memory_snapshot,
194            Some(&event),
195            protocol.as_ref() as &dyn Client,
196            &status_tx,
197        )
198        .await;
199
200        let actions = match llm_result {
201            Ok(ClientLlmResult {
202                actions,
203                memory_updates,
204            }) => {
205                if let Some(mem) = memory_updates {
206                    client_data.lock().await.memory = mem;
207                }
208                actions
209            }
210            Err(e) => {
211                error!("TFTP client {} LLM error on connect: {}", client_id, e);
212                let _ = status_tx.send(format!("[CLIENT] TFTP LLM error on connect: {}", e));
213                Vec::new()
214            }
215        };
216
217        // Only a read or write request starts a transfer. Anything else leaves the socket
218        // bound and idle — the client never invents a request the model did not ask for.
219        let mut started = false;
220        for action in actions {
221            match protocol.execute_action(action) {
222                Ok(ClientActionResult::Custom { name, data }) => {
223                    let filename = data
224                        .get("filename")
225                        .and_then(|v| v.as_str())
226                        .unwrap_or_default()
227                        .to_string();
228                    let mode = data
229                        .get("mode")
230                        .and_then(|v| v.as_str())
231                        .unwrap_or("octet")
232                        .to_string();
233                    if filename.is_empty() {
234                        warn!("TFTP client {} got {} with empty filename", client_id, name);
235                        continue;
236                    }
237                    let direction = match name.as_str() {
238                        "tftp_read_file" => Direction::Read,
239                        "tftp_write_file" => Direction::Write,
240                        other => {
241                            warn!(
242                                "TFTP client {} unhandled custom action {}",
243                                client_id, other
244                            );
245                            continue;
246                        }
247                    };
248
249                    match Self::start_transfer(
250                        direction,
251                        socket.clone(),
252                        server_addr,
253                        transfer_addr.clone(),
254                        filename,
255                        mode,
256                        llm_client.clone(),
257                        app_state.clone(),
258                        status_tx.clone(),
259                        client_id,
260                        instruction.clone(),
261                        client_data.clone(),
262                        protocol.clone(),
263                    )
264                    .await
265                    {
266                        Ok(_) => started = true,
267                        Err(e) => {
268                            error!("TFTP client {} failed to send request: {}", client_id, e);
269                            app_state
270                                .update_client_status(client_id, ClientStatus::Error(e.to_string()))
271                                .await;
272                        }
273                    }
274                }
275                Ok(ClientActionResult::Disconnect) => {
276                    info!("TFTP client {} disconnecting on model request", client_id);
277                    app_state
278                        .update_client_status(client_id, ClientStatus::Disconnected)
279                        .await;
280                    // Early return: drop the command handle too, or the dashboard would
281                    // keep offering [ send ] into a client that never opened a transfer.
282                    app_state.remove_client_handle(client_id).await;
283                    return Ok(local_addr);
284                }
285                Ok(_) => {}
286                Err(e) => {
287                    warn!("TFTP client {} rejected action: {}", client_id, e);
288                }
289            }
290        }
291
292        if !started {
293            debug!(
294                "TFTP client {} idle: model requested no transfer",
295                client_id
296            );
297        }
298
299        Ok(local_addr)
300    }
301
302    /// Put the RRQ or WRQ on the wire and spawn the loop that drives the transfer.
303    ///
304    /// Returns the number of request bytes that actually left the socket, so an injected
305    /// `tftp_read_file` can be reported as `Sent` with a real count rather than as a
306    /// fire-and-forget dispatch. Called by both the connected-event path and the command loop.
307    #[allow(clippy::too_many_arguments)]
308    async fn start_transfer(
309        direction: Direction,
310        socket: Arc<UdpSocket>,
311        server_addr: SocketAddr,
312        transfer_addr: Arc<Mutex<SocketAddr>>,
313        filename: String,
314        mode: String,
315        llm_client: OllamaClient,
316        app_state: Arc<AppState>,
317        status_tx: mpsc::UnboundedSender<String>,
318        client_id: ClientId,
319        instruction: String,
320        client_data: Arc<Mutex<ClientData>>,
321        protocol: Arc<TftpClientProtocol>,
322    ) -> Result<usize> {
323        let opcode = match direction {
324            Direction::Read => OP_RRQ,
325            Direction::Write => OP_WRQ,
326        };
327        let request = build_request_packet(opcode, &filename, &mode);
328
329        // A new request means a new server TID; until the first reply arrives, packets go
330        // back to the well-known port.
331        *transfer_addr.lock().await = server_addr;
332
333        let sent = socket.send_to(&request, server_addr).await?;
334        debug!(
335            "TFTP client {} sent {} for '{}' ({})",
336            client_id,
337            if opcode == OP_RRQ { "RRQ" } else { "WRQ" },
338            filename,
339            mode
340        );
341        let _ = status_tx.send(format!(
342            "[CLIENT] TFTP {} '{}'",
343            if opcode == OP_RRQ { "RRQ" } else { "WRQ" },
344            filename
345        ));
346
347        let handle = tokio::spawn(Self::run_transfer(
348            socket,
349            transfer_addr,
350            llm_client,
351            app_state.clone(),
352            status_tx,
353            client_id,
354            instruction,
355            client_data,
356            protocol,
357        ));
358        app_state.register_client_task(client_id, handle).await;
359
360        Ok(sent)
361    }
362
363    /// Drive one RRQ or WRQ transfer to completion. The request itself was already sent by
364    /// [`Self::start_transfer`].
365    #[allow(clippy::too_many_arguments)]
366    async fn run_transfer(
367        socket: Arc<UdpSocket>,
368        transfer_addr: Arc<Mutex<SocketAddr>>,
369        llm_client: OllamaClient,
370        app_state: Arc<AppState>,
371        status_tx: mpsc::UnboundedSender<String>,
372        client_id: ClientId,
373        instruction: String,
374        client_data: Arc<Mutex<ClientData>>,
375        protocol: Arc<TftpClientProtocol>,
376    ) {
377        // The server answers from a freshly allocated TID, so the peer address of the first
378        // reply — not the well-known port — is where subsequent packets go (RFC 1350 §4).
379        let mut learned_tid = false;
380
381        let mut buffer = vec![0u8; 4 + TFTP_BLOCK_SIZE];
382        let mut total_bytes: u64 = 0;
383        let mut total_blocks: u16 = 0;
384
385        loop {
386            let received = tokio::time::timeout(
387                std::time::Duration::from_secs(5),
388                socket.recv_from(&mut buffer),
389            )
390            .await;
391
392            let (n, peer) = match received {
393                Ok(Ok(pair)) => pair,
394                Ok(Err(e)) => {
395                    error!("TFTP client {} socket error: {}", client_id, e);
396                    app_state
397                        .update_client_status(client_id, ClientStatus::Error(e.to_string()))
398                        .await;
399                    return;
400                }
401                Err(_) => {
402                    warn!("TFTP client {} timed out waiting for a reply", client_id);
403                    let _ = status_tx.send("[CLIENT] TFTP timeout".to_string());
404                    app_state
405                        .update_client_status(client_id, ClientStatus::Disconnected)
406                        .await;
407                    return;
408                }
409            };
410
411            if !learned_tid {
412                *transfer_addr.lock().await = peer;
413                learned_tid = true;
414                trace!("TFTP client {} learned server TID {}", client_id, peer);
415            }
416
417            let packet = match TftpPacket::decode(&buffer[..n]) {
418                Some(p) => p,
419                None => {
420                    debug!("TFTP client {} ignoring undecodable datagram", client_id);
421                    continue;
422                }
423            };
424
425            let (event, terminal) = match &packet {
426                TftpPacket::Data { block, payload } => {
427                    let is_final = payload.len() < TFTP_BLOCK_SIZE;
428                    total_bytes += payload.len() as u64;
429                    total_blocks = *block;
430                    (
431                        Event::new(
432                            &TFTP_CLIENT_DATA_RECEIVED_EVENT,
433                            serde_json::json!({
434                                "block_number": block,
435                                "data_hex": hex::encode(payload),
436                                "data_length": payload.len(),
437                                "is_final": is_final,
438                                "total_bytes": total_bytes,
439                            }),
440                        ),
441                        is_final,
442                    )
443                }
444                TftpPacket::Ack { block } => {
445                    total_blocks = *block;
446                    (
447                        Event::new(
448                            &TFTP_CLIENT_ACK_RECEIVED_EVENT,
449                            serde_json::json!({ "block_number": block }),
450                        ),
451                        false,
452                    )
453                }
454                TftpPacket::Error { code, message } => {
455                    error!(
456                        "TFTP client {} received ERROR {}: {}",
457                        client_id, code, message
458                    );
459                    let _ = status_tx.send(format!("[CLIENT] TFTP error {}: {}", code, message));
460                    (
461                        Event::new(
462                            &TFTP_CLIENT_ERROR_EVENT,
463                            serde_json::json!({
464                                "error_code": code,
465                                "error_message": message,
466                            }),
467                        ),
468                        true,
469                    )
470                }
471            };
472
473            let memory_snapshot = client_data.lock().await.memory.clone();
474            let llm_result = call_llm_for_client(
475                &llm_client,
476                &app_state,
477                client_id.to_string(),
478                &instruction,
479                &memory_snapshot,
480                Some(&event),
481                protocol.as_ref() as &dyn Client,
482                &status_tx,
483            )
484            .await;
485
486            let mut disconnect = false;
487            let mut wrote_short_block = false;
488            match llm_result {
489                Ok(ClientLlmResult {
490                    actions,
491                    memory_updates,
492                }) => {
493                    if let Some(mem) = memory_updates {
494                        client_data.lock().await.memory = mem;
495                    }
496                    for action in actions {
497                        match protocol.execute_action(action) {
498                            Ok(ClientActionResult::SendData(bytes)) => {
499                                // A DATA block shorter than 512 bytes ends a write transfer.
500                                if bytes.len() >= 4
501                                    && u16::from_be_bytes([bytes[0], bytes[1]]) == OP_DATA
502                                {
503                                    let payload_len = bytes.len() - 4;
504                                    total_bytes += payload_len as u64;
505                                    if payload_len < TFTP_BLOCK_SIZE {
506                                        wrote_short_block = true;
507                                    }
508                                }
509                                let dest = *transfer_addr.lock().await;
510                                if let Err(e) = socket.send_to(&bytes, dest).await {
511                                    error!("TFTP client {} send failed: {}", client_id, e);
512                                }
513                            }
514                            Ok(ClientActionResult::Disconnect) => disconnect = true,
515                            Ok(_) => {}
516                            Err(e) => {
517                                warn!("TFTP client {} rejected action: {}", client_id, e);
518                            }
519                        }
520                    }
521                }
522                Err(e) => {
523                    error!("TFTP client {} LLM error: {}", client_id, e);
524                }
525            }
526
527            if terminal || disconnect || wrote_short_block {
528                if !matches!(packet, TftpPacket::Error { .. }) {
529                    let complete = Event::new(
530                        &TFTP_CLIENT_TRANSFER_COMPLETE_EVENT,
531                        serde_json::json!({
532                            "total_bytes": total_bytes,
533                            "total_blocks": total_blocks,
534                        }),
535                    );
536                    let memory_snapshot = client_data.lock().await.memory.clone();
537                    // The answer is carried out. It used to be an `if let Err(..)` with no
538                    // success arm, which capped a TFTP client at exactly one transfer: this is
539                    // the only moment the model is asked "that finished — now what?", and
540                    // `send_read_request` / `send_write_request` in reply are how a second
541                    // transfer would ever start.
542                    //
543                    // No depth bound is needed. Nothing here calls back into this function;
544                    // an action puts a packet on the socket and the reply arrives through the
545                    // read loop, one datagram at a time, the same shape as `datalink`.
546                    match call_llm_for_client(
547                        &llm_client,
548                        &app_state,
549                        client_id.to_string(),
550                        &instruction,
551                        &memory_snapshot,
552                        Some(&complete),
553                        protocol.as_ref() as &dyn Client,
554                        &status_tx,
555                    )
556                    .await
557                    {
558                        Ok(ClientLlmResult {
559                            actions,
560                            memory_updates,
561                        }) => {
562                            if let Some(mem) = memory_updates {
563                                client_data.lock().await.memory = mem;
564                            }
565                            for action in actions {
566                                match protocol.execute_action(action) {
567                                    Ok(ClientActionResult::SendData(bytes)) => {
568                                        let dest = *transfer_addr.lock().await;
569                                        if let Err(e) = socket.send_to(&bytes, dest).await {
570                                            error!(
571                                                "TFTP client {} send failed after completion: {}",
572                                                client_id, e
573                                            );
574                                        }
575                                    }
576                                    Ok(ClientActionResult::Disconnect) => disconnect = true,
577                                    Ok(_) => {}
578                                    Err(e) => warn!(
579                                        "TFTP client {} rejected completion action: {}",
580                                        client_id, e
581                                    ),
582                                }
583                            }
584                        }
585                        Err(e) => {
586                            error!("TFTP client {} LLM error on completion: {}", client_id, e);
587                        }
588                    }
589                    info!(
590                        "TFTP client {} transfer complete ({} bytes, {} blocks)",
591                        client_id, total_bytes, total_blocks
592                    );
593                    let _ = status_tx.send(format!(
594                        "[CLIENT] TFTP transfer complete: {} bytes",
595                        total_bytes
596                    ));
597                }
598                app_state
599                    .update_client_status(client_id, ClientStatus::Disconnected)
600                    .await;
601                let _ = status_tx.send("__UPDATE_UI__".to_string());
602                return;
603            }
604        }
605    }
606
607    /// Drain injected commands until the channel closes (the client was removed) or an
608    /// injected `disconnect` ends the session.
609    ///
610    /// Every verb here reaches the wire, so every successful outcome is a real byte count:
611    /// `send_ack` / `send_data_block` become one `send_to` of the packet the protocol's own
612    /// executor built, and `tftp_read_file` / `tftp_write_file` go through
613    /// [`Self::start_transfer`], which awaits the RRQ/WRQ send before spawning the loop that
614    /// drives the rest.
615    ///
616    /// Note the destination: packets go to the **current** transfer address, which the
617    /// receive loop rewrites to the server's TID after the first reply. An injected ACK sent
618    /// to the well-known port would be ignored by a conforming server.
619    #[allow(clippy::too_many_arguments)]
620    async fn command_loop(
621        mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
622        socket: Arc<UdpSocket>,
623        server_addr: SocketAddr,
624        transfer_addr: Arc<Mutex<SocketAddr>>,
625        llm_client: OllamaClient,
626        app_state: Arc<AppState>,
627        status_tx: mpsc::UnboundedSender<String>,
628        client_id: ClientId,
629        client_data: Arc<Mutex<ClientData>>,
630        protocol: Arc<TftpClientProtocol>,
631    ) {
632        use crate::llm::actions::protocol_trait::Protocol;
633        use crate::state::client_handles::ClientSendOutcome;
634        use crate::state::AccessLogOwner;
635
636        while let Some(command) = command_rx.recv().await {
637            let action = command.action.clone();
638            let outcome = match protocol.execute_action(action.clone()) {
639                Err(e) => Ok(ClientSendOutcome::Rejected {
640                    error: e.to_string(),
641                }),
642                Ok(ClientActionResult::SendData(bytes)) => {
643                    let dest = *transfer_addr.lock().await;
644                    match socket.send_to(&bytes, dest).await {
645                        Ok(bytes_sent) => Ok(ClientSendOutcome::Sent { bytes_sent }),
646                        Err(e) => Err(anyhow::anyhow!("send to {dest} failed: {e}")),
647                    }
648                }
649                Ok(ClientActionResult::Custom { name, data }) => {
650                    let direction = match name.as_str() {
651                        "tftp_read_file" => Some(Direction::Read),
652                        "tftp_write_file" => Some(Direction::Write),
653                        _ => None,
654                    };
655                    let filename = data
656                        .get("filename")
657                        .and_then(|v| v.as_str())
658                        .unwrap_or_default()
659                        .to_string();
660                    let mode = data
661                        .get("mode")
662                        .and_then(|v| v.as_str())
663                        .unwrap_or("octet")
664                        .to_string();
665
666                    match direction {
667                        None => Ok(ClientSendOutcome::Executed {
668                            detail: format!("custom result '{name}' is not a TFTP client verb"),
669                        }),
670                        Some(_) if filename.is_empty() => Ok(ClientSendOutcome::Rejected {
671                            error: format!("{name} needs a non-empty 'filename'"),
672                        }),
673                        Some(direction) => {
674                            let instruction = app_state
675                                .get_instruction_for_client(client_id)
676                                .await
677                                .unwrap_or_default();
678                            match Self::start_transfer(
679                                direction,
680                                socket.clone(),
681                                server_addr,
682                                transfer_addr.clone(),
683                                filename,
684                                mode,
685                                llm_client.clone(),
686                                app_state.clone(),
687                                status_tx.clone(),
688                                client_id,
689                                instruction,
690                                client_data.clone(),
691                                protocol.clone(),
692                            )
693                            .await
694                            {
695                                Ok(bytes_sent) => Ok(ClientSendOutcome::Sent { bytes_sent }),
696                                Err(e) => Err(e),
697                            }
698                        }
699                    }
700                }
701                Ok(ClientActionResult::Disconnect) => Ok(ClientSendOutcome::Disconnected),
702                Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
703                    detail: "wait_for_more: nothing written to the wire".to_string(),
704                }),
705                Ok(_) => Ok(ClientSendOutcome::Executed {
706                    detail: "action produced no TFTP packet".to_string(),
707                }),
708            };
709
710            let outcome_json = match &outcome {
711                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
712                Err(e) => serde_json::json!({"error": e.to_string()}),
713            };
714            app_state
715                .record_access_log(
716                    AccessLogOwner::Client(client_id.as_u32()),
717                    protocol.protocol_name(),
718                    None,
719                    "injected_action",
720                    action,
721                    vec![outcome_json],
722                )
723                .await;
724
725            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
726            if let Err(e) = &outcome {
727                error!("TFTP client {} injected action failed: {}", client_id, e);
728                let _ = status_tx.send(format!(
729                    "[WARN] Client {} injected action failed: {}",
730                    client_id, e
731                ));
732            }
733            let _ = status_tx.send("__UPDATE_UI__".to_string());
734            crate::client::command_support::reply(command, outcome);
735
736            if disconnect {
737                info!("TFTP client {} disconnecting on injected action", client_id);
738                app_state
739                    .update_client_status(client_id, ClientStatus::Disconnected)
740                    .await;
741                break;
742            }
743        }
744
745        app_state.remove_client_handle(client_id).await;
746        let _ = status_tx.send("__UPDATE_UI__".to_string());
747    }
748}