Skip to main content

netget/client/bootp/
mod.rs

1//! BOOTP client implementation
2pub mod actions;
3
4pub use actions::BootpClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::{IpAddr, Ipv4Addr, SocketAddr};
8use std::sync::Arc;
9use tokio::net::UdpSocket;
10use tokio::sync::{mpsc, Mutex};
11use tracing::{error, info, trace, warn};
12
13use crate::client::bootp::actions::{BOOTP_CLIENT_CONNECTED_EVENT, BOOTP_REPLY_RECEIVED_EVENT};
14use crate::client::llm_budget::call_llm_for_client;
15use crate::llm::actions::client_trait::{Client, ClientActionResult};
16use crate::llm::ollama_client::OllamaClient;
17use crate::llm::ClientLlmResult;
18use crate::protocol::Event;
19use crate::state::app_state::AppState;
20use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
21use crate::state::{AccessLogOwner, ClientId, ClientStatus};
22
23use dhcproto::v4;
24
25/// Connection state for LLM processing
26#[derive(Debug, Clone, PartialEq)]
27enum ConnectionState {
28    Idle,
29    Processing,
30    Accumulating,
31}
32
33/// Per-client data for LLM handling
34struct ClientData {
35    state: ConnectionState,
36    queued_replies: Vec<BootpReply>,
37    memory: String,
38}
39
40/// Parsed BOOTP reply data
41#[derive(Debug, Clone)]
42struct BootpReply {
43    assigned_ip: Ipv4Addr,
44    server_ip: Ipv4Addr,
45    gateway_ip: Ipv4Addr,
46    boot_filename: String,
47}
48
49/// What applying one executed action did. Shared by the LLM paths and the
50/// command channel so an injected action reports the same truth the LLM path
51/// puts on the wire.
52enum Applied {
53    /// A datagram reached the wire, with its real byte count.
54    Sent(usize),
55    /// The action ran but wrote nothing.
56    Executed(String),
57    /// The session should end.
58    Disconnect,
59}
60
61/// BOOTP client that sends requests to a BOOTP/DHCP server
62pub struct BootpClient;
63
64impl BootpClient {
65    /// Connect to a BOOTP server with integrated LLM actions
66    pub async fn connect_with_llm_actions(
67        remote_addr: String,
68        llm_client: OllamaClient,
69        app_state: Arc<AppState>,
70        status_tx: mpsc::UnboundedSender<String>,
71        client_id: ClientId,
72    ) -> Result<SocketAddr> {
73        // Parse server address
74        let server_addr: SocketAddr = remote_addr
75            .parse()
76            .context("Invalid BOOTP server address")?;
77
78        // Bind UDP socket to BOOTP client port (68)
79        // Note: Binding to port 68 may require elevated privileges
80        let socket = match UdpSocket::bind("0.0.0.0:68").await {
81            Ok(s) => s,
82            Err(e) => {
83                warn!(
84                    "Failed to bind to port 68 (requires privileges): {}. Binding to random port.",
85                    e
86                );
87                UdpSocket::bind("0.0.0.0:0")
88                    .await
89                    .context("Failed to bind UDP socket")?
90            }
91        };
92
93        // Enable broadcast if needed
94        socket
95            .set_broadcast(true)
96            .context("Failed to enable broadcast")?;
97
98        let local_addr = socket.local_addr()?;
99
100        info!(
101            "BOOTP client {} bound to {} (server: {})",
102            client_id, local_addr, server_addr
103        );
104
105        // Update client state
106        app_state
107            .update_client_status(client_id, ClientStatus::Connected)
108            .await;
109        let _ = status_tx.send(format!("[CLIENT] BOOTP client {} connected", client_id));
110        let _ = status_tx.send("__UPDATE_UI__".to_string());
111
112        let socket_arc = Arc::new(socket);
113
114        // Initialize client data
115        let client_data = Arc::new(Mutex::new(ClientData {
116            state: ConnectionState::Idle,
117            queued_replies: Vec::new(),
118            memory: String::new(),
119        }));
120
121        // Clone for spawned task
122        let socket_clone = Arc::clone(&socket_arc);
123        let app_state_clone = Arc::clone(&app_state);
124        let status_tx_clone = status_tx.clone();
125
126        // Command channel: lets the dashboard (and any programmatic caller) inject
127        // actions into this client via AppState::send_to_client.
128        //
129        // Registered BEFORE the connected event is handled: a `manual` routing rule can
130        // park that event at the dashboard for minutes, and until registration the UI
131        // reports "no command channel" - reading as a protocol limitation when it is
132        // only a queue.
133        let mut command_rx =
134            crate::client::command_support::register_command_channel(&app_state, client_id).await;
135
136        // Send initial connected event to LLM
137        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
138            let protocol = Arc::new(crate::client::bootp::actions::BootpClientProtocol::new());
139            let event = Event::new(
140                &BOOTP_CLIENT_CONNECTED_EVENT,
141                serde_json::json!({
142                    "server_addr": server_addr.to_string(),
143                }),
144            );
145
146            match call_llm_for_client(
147                &llm_client,
148                &app_state,
149                client_id.to_string(),
150                &instruction,
151                &client_data.lock().await.memory,
152                Some(&event),
153                protocol.as_ref(),
154                &status_tx,
155            )
156            .await
157            {
158                Ok(ClientLlmResult {
159                    actions,
160                    memory_updates,
161                }) => {
162                    // Update memory
163                    if let Some(mem) = memory_updates {
164                        client_data.lock().await.memory = mem;
165                    }
166
167                    // Execute initial actions
168                    for action in actions {
169                        if let Err(e) = Self::execute_bootp_action(
170                            action,
171                            &socket_arc,
172                            server_addr,
173                            &protocol,
174                            &status_tx,
175                            client_id,
176                        )
177                        .await
178                        {
179                            error!("Failed to execute initial BOOTP action: {}", e);
180                        }
181                    }
182                }
183                Err(e) => {
184                    error!(
185                        "Initial LLM call failed for BOOTP client {}: {}",
186                        client_id, e
187                    );
188                }
189            }
190        }
191
192        // Spawn receive loop
193        // Registered with AppState so stop_client can abort this task —
194        // dropping a JoinHandle only detaches it in Tokio.
195        let task_registrar = app_state.clone();
196        let task_handle = tokio::spawn(async move {
197            let mut buffer = vec![0u8; 1500]; // BOOTP packets are typically ~300 bytes
198            let cmd_protocol = Arc::new(crate::client::bootp::actions::BootpClientProtocol::new());
199
200            loop {
201                // `UdpSocket::recv_from` is cancellation-safe, so the command arm can
202                // share this select! with the read - losing the race never drops a
203                // datagram.
204                let recv_result = tokio::select! {
205                    result = socket_clone.recv_from(&mut buffer) => result,
206                    Some(cmd) = command_rx.recv() => {
207                        if Self::handle_injected_command(
208                            cmd,
209                            &socket_clone,
210                            server_addr,
211                            &cmd_protocol,
212                            &app_state_clone,
213                            &status_tx_clone,
214                            client_id,
215                        )
216                        .await
217                        {
218                            app_state_clone
219                                .update_client_status(client_id, ClientStatus::Disconnected)
220                                .await;
221                            let _ = status_tx_clone.send(format!(
222                                "[CLIENT] BOOTP client {} disconnected (injected action)",
223                                client_id
224                            ));
225                            let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
226                            break;
227                        }
228                        continue;
229                    }
230                };
231
232                match recv_result {
233                    Ok((n, peer)) => {
234                        trace!(
235                            "BOOTP client {} received {} bytes from {}",
236                            client_id,
237                            n,
238                            peer
239                        );
240
241                        // Parse BOOTP reply
242                        match Self::parse_bootp_reply(&buffer[..n]) {
243                            Ok(reply) => {
244                                info!(
245                                    "BOOTP client {} received reply: IP={}, Server={}, Boot={}",
246                                    client_id,
247                                    reply.assigned_ip,
248                                    reply.server_ip,
249                                    reply.boot_filename
250                                );
251
252                                // Handle reply with LLM
253                                let mut client_data_lock = client_data.lock().await;
254
255                                match client_data_lock.state {
256                                    ConnectionState::Idle => {
257                                        // Process immediately
258                                        client_data_lock.state = ConnectionState::Processing;
259                                        drop(client_data_lock);
260
261                                        // Call LLM
262                                        if let Some(instruction) = app_state_clone
263                                            .get_instruction_for_client(client_id)
264                                            .await
265                                        {
266                                            let protocol = Arc::new(crate::client::bootp::actions::BootpClientProtocol::new());
267                                            let event = Event::new(
268                                                &BOOTP_REPLY_RECEIVED_EVENT,
269                                                serde_json::json!({
270                                                    "assigned_ip": reply.assigned_ip.to_string(),
271                                                    "server_ip": reply.server_ip.to_string(),
272                                                    "boot_filename": reply.boot_filename,
273                                                    "gateway_ip": reply.gateway_ip.to_string(),
274                                                }),
275                                            );
276
277                                            match call_llm_for_client(
278                                                &llm_client,
279                                                &app_state_clone,
280                                                client_id.to_string(),
281                                                &instruction,
282                                                &client_data.lock().await.memory,
283                                                Some(&event),
284                                                protocol.as_ref(),
285                                                &status_tx_clone,
286                                            )
287                                            .await
288                                            {
289                                                Ok(ClientLlmResult {
290                                                    actions,
291                                                    memory_updates,
292                                                }) => {
293                                                    // Update memory
294                                                    if let Some(mem) = memory_updates {
295                                                        client_data.lock().await.memory = mem;
296                                                    }
297
298                                                    // Execute actions
299                                                    for action in actions {
300                                                        if let Err(e) = Self::execute_bootp_action(
301                                                            action,
302                                                            &socket_clone,
303                                                            server_addr,
304                                                            &protocol,
305                                                            &status_tx_clone,
306                                                            client_id,
307                                                        )
308                                                        .await
309                                                        {
310                                                            error!("Failed to execute BOOTP action: {}", e);
311                                                        }
312                                                    }
313                                                }
314                                                Err(e) => {
315                                                    error!(
316                                                        "LLM error for BOOTP client {}: {}",
317                                                        client_id, e
318                                                    );
319                                                }
320                                            }
321                                        }
322
323                                        // Process queued replies if any
324                                        let mut client_data_lock = client_data.lock().await;
325                                        if !client_data_lock.queued_replies.is_empty() {
326                                            client_data_lock.queued_replies.clear();
327                                        }
328                                        client_data_lock.state = ConnectionState::Idle;
329                                    }
330                                    ConnectionState::Processing => {
331                                        // Queue reply
332                                        client_data_lock.queued_replies.push(reply);
333                                        client_data_lock.state = ConnectionState::Accumulating;
334                                    }
335                                    ConnectionState::Accumulating => {
336                                        // Continue queuing
337                                        client_data_lock.queued_replies.push(reply);
338                                    }
339                                }
340                            }
341                            Err(e) => {
342                                warn!("Failed to parse BOOTP reply: {}", e);
343                            }
344                        }
345                    }
346                    Err(e) => {
347                        error!("BOOTP client {} recv error: {}", client_id, e);
348                        app_state_clone
349                            .update_client_status(client_id, ClientStatus::Error(e.to_string()))
350                            .await;
351                        let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
352                        break;
353                    }
354                }
355            }
356
357            // Every exit path lands here: drop the command handle so the dashboard
358            // stops offering [ send ] on a dead client (a late send then fails fast).
359            app_state_clone.remove_client_handle(client_id).await;
360            let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
361        });
362        task_registrar
363            .register_client_task(client_id, task_handle)
364            .await;
365
366        Ok(local_addr)
367    }
368
369    /// Execute a BOOTP client action (LLM path: rejection and wire failure are
370    /// both just logged by the caller).
371    async fn execute_bootp_action(
372        action: serde_json::Value,
373        socket: &Arc<UdpSocket>,
374        server_addr: SocketAddr,
375        protocol: &Arc<BootpClientProtocol>,
376        status_tx: &mpsc::UnboundedSender<String>,
377        client_id: ClientId,
378    ) -> Result<Applied> {
379        let result = protocol.as_ref().execute_action(action)?;
380        Self::apply_action_result(result, socket, server_addr, status_tx, client_id).await
381    }
382
383    /// Put one executed action on the wire. Shared by the connected-event path, the
384    /// read loop and injected commands, so the BOOTP encoding and the choice of
385    /// destination exist exactly once.
386    async fn apply_action_result(
387        result: ClientActionResult,
388        socket: &Arc<UdpSocket>,
389        server_addr: SocketAddr,
390        status_tx: &mpsc::UnboundedSender<String>,
391        client_id: ClientId,
392    ) -> Result<Applied> {
393        match result {
394            ClientActionResult::Custom { name, data } if name == "send_bootp_request" => {
395                let client_mac = data["client_mac"].as_str().context("Missing client_mac")?;
396                let broadcast = data["broadcast"].as_bool().unwrap_or(true);
397
398                // Parse MAC address
399                let mac_bytes = Self::parse_mac(client_mac)?;
400
401                // Build BOOTP request
402                let bootp_request = Self::build_bootp_request(&mac_bytes)?;
403
404                // Send to server or broadcast
405                let target = if broadcast {
406                    SocketAddr::new(IpAddr::V4(Ipv4Addr::BROADCAST), server_addr.port())
407                } else {
408                    server_addr
409                };
410
411                // The byte count reported to an injected caller is the one the kernel
412                // accepted, not the length we hoped to send.
413                let sent = socket.send_to(&bootp_request, target).await?;
414                trace!("BOOTP client {} sent request to {}", client_id, target);
415                let _ = status_tx.send(format!("[CLIENT] BOOTP request sent to {}", target));
416                Ok(Applied::Sent(sent))
417            }
418            ClientActionResult::Custom { name, .. } => Ok(Applied::Executed(format!(
419                "custom result '{name}' is not a BOOTP wire verb"
420            ))),
421            ClientActionResult::Disconnect => {
422                info!("BOOTP client {} disconnecting", client_id);
423                // UDP is connectionless: there is no socket to shut down, so the only
424                // effect is ending the receive loop.
425                Ok(Applied::Disconnect)
426            }
427            ClientActionResult::WaitForMore => Ok(Applied::Executed("wait_for_more".to_string())),
428            ClientActionResult::NoAction => Ok(Applied::Executed("no_action".to_string())),
429            other => Ok(Applied::Executed(format!("no wire effect: {other:?}"))),
430        }
431    }
432
433    /// Apply one injected action, record it in the access log exactly as
434    /// `command_support::handle_stream_client_command` does for stream clients, and
435    /// reply on the command's oneshot. Returns `true` when the receive loop should
436    /// stop.
437    ///
438    /// This is bespoke rather than the generic helper because every BOOTP verb
439    /// yields `ClientActionResult::Custom` and the destination (unicast vs the
440    /// limited broadcast address) is a property of the action, not of a write half.
441    async fn handle_injected_command(
442        command: ClientCommand,
443        socket: &Arc<UdpSocket>,
444        server_addr: SocketAddr,
445        protocol: &Arc<BootpClientProtocol>,
446        app_state: &Arc<AppState>,
447        status_tx: &mpsc::UnboundedSender<String>,
448        client_id: ClientId,
449    ) -> bool {
450        use crate::llm::actions::protocol_trait::Protocol;
451
452        let action = command.action.clone();
453        let outcome = match protocol.as_ref().execute_action(action.clone()) {
454            Err(e) => Ok(ClientSendOutcome::Rejected {
455                error: e.to_string(),
456            }),
457            Ok(result) => {
458                Self::apply_action_result(result, socket, server_addr, status_tx, client_id)
459                    .await
460                    .map(|applied| match applied {
461                        Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
462                        Applied::Executed(detail) => ClientSendOutcome::Executed { detail },
463                        Applied::Disconnect => ClientSendOutcome::Disconnected,
464                    })
465            }
466        };
467
468        let outcome_json = match &outcome {
469            Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
470            Err(e) => serde_json::json!({"error": e.to_string()}),
471        };
472        app_state
473            .record_access_log(
474                AccessLogOwner::Client(client_id.as_u32()),
475                protocol.protocol_name(),
476                None,
477                "injected_action",
478                action,
479                vec![outcome_json],
480            )
481            .await;
482
483        let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
484        if let Err(e) = &outcome {
485            error!("BOOTP client {} injected action failed: {}", client_id, e);
486            let _ = status_tx.send(format!(
487                "[WARN] Client {} injected action failed: {}",
488                client_id, e
489            ));
490        }
491        let _ = status_tx.send("__UPDATE_UI__".to_string());
492        crate::client::command_support::reply(command, outcome);
493        disconnect
494    }
495
496    /// Parse MAC address from string format (00:11:22:33:44:55)
497    fn parse_mac(mac_str: &str) -> Result<[u8; 6]> {
498        let parts: Vec<&str> = mac_str.split(':').collect();
499        if parts.len() != 6 {
500            return Err(anyhow::anyhow!("Invalid MAC address format"));
501        }
502
503        let mut mac = [0u8; 6];
504        for (i, part) in parts.iter().enumerate() {
505            mac[i] = u8::from_str_radix(part, 16).context("Invalid hex digit in MAC address")?;
506        }
507
508        Ok(mac)
509    }
510
511    /// Build a BOOTP request message
512    fn build_bootp_request(client_mac: &[u8; 6]) -> Result<Vec<u8>> {
513        {
514            let mut msg = v4::Message::new(
515                Ipv4Addr::UNSPECIFIED, // ciaddr (client IP)
516                Ipv4Addr::UNSPECIFIED, // yiaddr (your IP)
517                Ipv4Addr::UNSPECIFIED, // siaddr (server IP)
518                Ipv4Addr::UNSPECIFIED, // giaddr (gateway IP)
519                client_mac,
520            );
521            msg.set_opcode(v4::Opcode::BootRequest);
522            msg.set_flags(v4::Flags::default().set_broadcast()); // Request broadcast reply
523
524            // Generate random transaction ID
525            msg.set_xid(rand::random::<u32>());
526
527            use dhcproto::Encodable;
528            msg.to_vec().context("Failed to encode BOOTP request")
529        }
530    }
531
532    /// Parse a BOOTP reply message
533    fn parse_bootp_reply(data: &[u8]) -> Result<BootpReply> {
534        {
535            use dhcproto::decoder::Decoder;
536            use dhcproto::Decodable;
537
538            let mut decoder = Decoder::new(data);
539            let msg = v4::Message::decode(&mut decoder).context("Failed to decode BOOTP reply")?;
540
541            // Verify it's a reply
542            if msg.opcode() != v4::Opcode::BootReply {
543                return Err(anyhow::anyhow!("Not a BOOTP reply"));
544            }
545
546            let boot_filename = msg
547                .fname()
548                .and_then(|bytes| std::str::from_utf8(bytes).ok())
549                .unwrap_or("")
550                .trim_end_matches('\0')
551                .to_string();
552
553            Ok(BootpReply {
554                assigned_ip: msg.yiaddr(),
555                server_ip: msg.siaddr(),
556                gateway_ip: msg.giaddr(),
557                boot_filename,
558            })
559        }
560    }
561}