Skip to main content

netget/client/dhcp/
mod.rs

1//! DHCP client implementation
2pub mod actions;
3
4pub use actions::DhcpClientProtocol;
5
6use crate::client::llm_budget::call_llm_for_client;
7use crate::llm::actions::client_trait::{Client, ClientActionResult};
8use crate::llm::ollama_client::OllamaClient;
9use crate::llm::ClientLlmResult;
10use crate::protocol::Event;
11use crate::state::app_state::AppState;
12use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
13use crate::state::{AccessLogOwner, ClientId, ClientStatus};
14use anyhow::{Context, Result};
15use std::net::SocketAddr;
16use std::sync::Arc;
17use tokio::net::UdpSocket;
18use tokio::sync::{mpsc, Mutex};
19use tracing::{debug, error, info, trace};
20
21use actions::{DHCP_CLIENT_CONNECTED_EVENT, DHCP_CLIENT_RESPONSE_RECEIVED_EVENT};
22
23use dhcproto::{v4, Decodable, Decoder};
24
25/// Connection state for LLM processing
26#[derive(Debug, Clone, PartialEq)]
27enum ConnectionState {
28    Idle,
29    Processing,
30}
31
32/// Per-client data for LLM handling
33struct ClientData {
34    state: ConnectionState,
35    memory: String,
36}
37
38/// What applying one executed action did. Shared by the LLM paths and the
39/// command channel so an injected action reports the same truth the LLM path
40/// puts on the wire.
41enum Applied {
42    /// A datagram reached the wire, with its real byte count.
43    Sent(usize),
44    /// The action ran but wrote nothing.
45    Executed(String),
46    /// The session should end.
47    Disconnect,
48}
49
50/// DHCP client that sends requests and processes responses via LLM
51pub struct DhcpClient;
52
53impl DhcpClient {
54    /// Connect to DHCP server with LLM-controlled actions
55    pub async fn connect_with_llm_actions(
56        remote_addr: String,
57        llm_client: OllamaClient,
58        app_state: Arc<AppState>,
59        status_tx: mpsc::UnboundedSender<String>,
60        client_id: ClientId,
61    ) -> Result<SocketAddr> {
62        // Parse remote_addr (DHCP server address)
63        let server_addr: SocketAddr = remote_addr.parse().context("Invalid DHCP server address")?;
64
65        // Bind to DHCP client port (68) when we may, an ephemeral port otherwise.
66        //
67        // Port 68 is privileged, so an unprivileged NetGet cannot have it - and a hard
68        // failure there made the DHCP client unusable (and untestable) for every
69        // non-root run, including the dashboard's own [ + new client ]. Falling back
70        // mirrors the BOOTP client. The cost is real and worth stating: a server that
71        // replies by broadcasting to port 68, as RFC 2131 allows when the BROADCAST
72        // flag is set, will not be heard on an ephemeral port; unicast replies to our
73        // source port are.
74        let socket = Arc::new(match UdpSocket::bind("0.0.0.0:68").await {
75            Ok(s) => s,
76            Err(e) => {
77                tracing::warn!(
78                    "DHCP client {} could not bind port 68 ({}); using an ephemeral port \
79                     - replies broadcast to port 68 will not be received",
80                    client_id,
81                    e
82                );
83                UdpSocket::bind("0.0.0.0:0")
84                    .await
85                    .context("Failed to bind a UDP socket for the DHCP client")?
86            }
87        });
88
89        // Enable broadcast
90        socket.set_broadcast(true)?;
91
92        let local_addr = socket.local_addr()?;
93
94        info!(
95            "DHCP client {} bound to {}, targeting server {}",
96            client_id, local_addr, server_addr
97        );
98        let _ = status_tx.send(format!(
99            "[CLIENT] DHCP client {} connected, targeting server {}",
100            client_id, server_addr
101        ));
102
103        // Update client status
104        app_state
105            .update_client_status(client_id, ClientStatus::Connected)
106            .await;
107        let _ = status_tx.send("__UPDATE_UI__".to_string());
108
109        // Initialize client data
110        let client_data = Arc::new(Mutex::new(ClientData {
111            state: ConnectionState::Idle,
112            memory: String::new(),
113        }));
114
115        // Spawn event: dhcp_connected
116        let event = Event::new(
117            &DHCP_CLIENT_CONNECTED_EVENT,
118            serde_json::json!({
119                "server_addr": server_addr.to_string(),
120                "local_addr": local_addr.to_string()
121            }),
122        );
123
124        debug!("DHCP client {} calling LLM for connected event", client_id);
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        // Call LLM for initial connection event
137        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
138            let protocol = Arc::new(DhcpClientProtocol::new());
139
140            match call_llm_for_client(
141                &llm_client,
142                &app_state,
143                client_id.to_string(),
144                &instruction,
145                &client_data.lock().await.memory,
146                Some(&event),
147                protocol.as_ref(),
148                &status_tx,
149            )
150            .await
151            {
152                Ok(ClientLlmResult {
153                    actions,
154                    memory_updates,
155                }) => {
156                    // Update memory
157                    if let Some(mem) = memory_updates {
158                        client_data.lock().await.memory = mem;
159                    }
160
161                    // Execute actions from LLM response
162                    for action in actions {
163                        match protocol.as_ref().execute_action(action) {
164                            Ok(action_result) => {
165                                match Self::apply_action_result(
166                                    action_result,
167                                    &socket,
168                                    server_addr,
169                                    client_id,
170                                )
171                                .await
172                                {
173                                    Ok(Applied::Disconnect) => {
174                                        info!("DHCP client {} disconnecting", client_id);
175                                        app_state.remove_client_handle(client_id).await;
176                                        return Ok(local_addr);
177                                    }
178                                    Ok(_) => {}
179                                    Err(e) => {
180                                        error!(
181                                            "DHCP client {} could not send after connect: {}",
182                                            client_id, e
183                                        );
184                                    }
185                                }
186                            }
187                            Err(e) => {
188                                error!("DHCP client {} action execution failed: {}", client_id, e);
189                            }
190                        }
191                    }
192                }
193                Err(e) => {
194                    error!("DHCP client {} LLM call failed: {}", client_id, e);
195                }
196            }
197        }
198
199        // Spawn receive loop
200        let socket_clone = socket.clone();
201        let llm_clone = llm_client.clone();
202        let state_clone = app_state.clone();
203        let status_clone = status_tx.clone();
204        let client_data_clone = client_data.clone();
205
206        // Registered with AppState so stop_client can abort this task —
207        // dropping a JoinHandle only detaches it in Tokio.
208        let task_registrar = app_state.clone();
209        let task_handle = tokio::spawn(async move {
210            let mut buffer = vec![0u8; 1500];
211            let cmd_protocol = Arc::new(DhcpClientProtocol::new());
212
213            loop {
214                // `UdpSocket::recv_from` is cancellation-safe, so the command arm can
215                // share this select! with the read - losing the race never drops a
216                // datagram.
217                let recv_result = tokio::select! {
218                    result = socket_clone.recv_from(&mut buffer) => result,
219                    Some(cmd) = command_rx.recv() => {
220                        if Self::handle_injected_command(
221                            cmd,
222                            &socket_clone,
223                            server_addr,
224                            &cmd_protocol,
225                            &state_clone,
226                            &status_clone,
227                            client_id,
228                        )
229                        .await
230                        {
231                            state_clone
232                                .update_client_status(client_id, ClientStatus::Disconnected)
233                                .await;
234                            let _ = status_clone.send(format!(
235                                "[CLIENT] DHCP client {} disconnected (injected action)",
236                                client_id
237                            ));
238                            let _ = status_clone.send("__UPDATE_UI__".to_string());
239                            break;
240                        }
241                        continue;
242                    }
243                };
244
245                match recv_result {
246                    Ok((n, peer_addr)) => {
247                        let data = buffer[..n].to_vec();
248
249                        debug!(
250                            "DHCP client {} received {} bytes from {}",
251                            client_id, n, peer_addr
252                        );
253                        trace!("DHCP response (hex): {}", hex::encode(&data));
254
255                        // Handle data with LLM
256                        let mut client_data_lock = client_data_clone.lock().await;
257
258                        match client_data_lock.state {
259                            ConnectionState::Idle => {
260                                // Process immediately
261                                client_data_lock.state = ConnectionState::Processing;
262                                drop(client_data_lock);
263
264                                // Parse DHCP response
265                                let parsed_info = Self::parse_dhcp_response(&data);
266
267                                let event_data = if let Some((message_type, details)) = parsed_info
268                                {
269                                    serde_json::json!({
270                                        "message_type": message_type,
271                                        "details": details
272                                    })
273                                } else {
274                                    serde_json::json!({
275                                        "message_type": "unknown",
276                                        "data_hex": hex::encode(&data),
277                                        "data_length": n
278                                    })
279                                };
280
281                                let event =
282                                    Event::new(&DHCP_CLIENT_RESPONSE_RECEIVED_EVENT, event_data);
283
284                                // Call LLM with response event
285                                if let Some(instruction) =
286                                    state_clone.get_instruction_for_client(client_id).await
287                                {
288                                    let protocol = Arc::new(DhcpClientProtocol::new());
289
290                                    match call_llm_for_client(
291                                        &llm_clone,
292                                        &state_clone,
293                                        client_id.to_string(),
294                                        &instruction,
295                                        &client_data_clone.lock().await.memory,
296                                        Some(&event),
297                                        protocol.as_ref(),
298                                        &status_clone,
299                                    )
300                                    .await
301                                    {
302                                        Ok(ClientLlmResult {
303                                            actions,
304                                            memory_updates,
305                                        }) => {
306                                            // Update memory
307                                            if let Some(mem) = memory_updates {
308                                                client_data_clone.lock().await.memory = mem;
309                                            }
310
311                                            // Execute actions from LLM
312                                            for action in actions {
313                                                match protocol.as_ref().execute_action(action) {
314                                                    Ok(action_result) => {
315                                                        match Self::apply_action_result(
316                                                            action_result,
317                                                            &socket_clone,
318                                                            peer_addr,
319                                                            client_id,
320                                                        )
321                                                        .await
322                                                        {
323                                                            Ok(Applied::Disconnect) => {
324                                                                info!(
325                                                                    "DHCP client {} disconnecting",
326                                                                    client_id
327                                                                );
328                                                                state_clone
329                                                                    .update_client_status(
330                                                                        client_id,
331                                                                        ClientStatus::Disconnected,
332                                                                    )
333                                                                    .await;
334                                                                let _ = status_clone.send(
335                                                                    "__UPDATE_UI__".to_string(),
336                                                                );
337                                                                break;
338                                                            }
339                                                            Ok(_) => {}
340                                                            Err(e) => {
341                                                                error!("DHCP client {} failed to send: {}", client_id, e);
342                                                            }
343                                                        }
344                                                    }
345                                                    Err(e) => {
346                                                        error!("DHCP client {} action execution failed: {}", client_id, e);
347                                                    }
348                                                }
349                                            }
350                                        }
351                                        Err(e) => {
352                                            error!(
353                                                "DHCP client {} LLM call failed: {}",
354                                                client_id, e
355                                            );
356                                        }
357                                    }
358                                }
359
360                                // Reset to idle state
361                                client_data_clone.lock().await.state = ConnectionState::Idle;
362                            }
363                            ConnectionState::Processing => {
364                                // Queue data for later processing
365                                debug!(
366                                    "DHCP client {} is processing, queueing response",
367                                    client_id
368                                );
369                            }
370                        }
371                    }
372                    Err(e) => {
373                        error!("DHCP client {} receive error: {}", client_id, e);
374                        state_clone
375                            .update_client_status(client_id, ClientStatus::Error(e.to_string()))
376                            .await;
377                        let _ = status_clone.send("__UPDATE_UI__".to_string());
378                        break;
379                    }
380                }
381            }
382
383            // Every exit path lands here: drop the command handle so the dashboard
384            // stops offering [ send ] on a dead client (a late send then fails fast).
385            state_clone.remove_client_handle(client_id).await;
386            let _ = status_clone.send("__UPDATE_UI__".to_string());
387        });
388        task_registrar
389            .register_client_task(client_id, task_handle)
390            .await;
391
392        Ok(local_addr)
393    }
394
395    /// Put one executed action on the wire. Shared by the connected-event path, the
396    /// receive loop and injected commands, so the packet encoding and the choice of
397    /// destination exist exactly once.
398    ///
399    /// `unicast_target` is where a non-broadcast datagram goes: the configured server
400    /// on the connect path, the peer that just answered us in the receive loop.
401    async fn apply_action_result(
402        result: ClientActionResult,
403        socket: &Arc<UdpSocket>,
404        unicast_target: SocketAddr,
405        client_id: ClientId,
406    ) -> Result<Applied> {
407        match result {
408            ClientActionResult::Custom { name, data } => {
409                {
410                    let (packet, label) = match name.as_str() {
411                        "dhcp_discover" => (Self::build_discover_packet(&data)?, "DISCOVER"),
412                        "dhcp_request" => (Self::build_request_packet(&data)?, "REQUEST"),
413                        // INFORM is sent by a client that already has an address, so it
414                        // is always unicast to the server.
415                        "dhcp_inform" => (Self::build_inform_packet(&data)?, "INFORM"),
416                        _ => {
417                            return Ok(Applied::Executed(format!(
418                                "custom result '{name}' is not a DHCP wire verb"
419                            )))
420                        }
421                    };
422
423                    let broadcast = label != "INFORM"
424                        && data
425                            .get("broadcast")
426                            .and_then(|v| v.as_bool())
427                            .unwrap_or(true);
428                    let target: SocketAddr = if broadcast {
429                        "255.255.255.255:67"
430                            .parse()
431                            .expect("literal broadcast address")
432                    } else {
433                        unicast_target
434                    };
435
436                    // The byte count reported to an injected caller is the one the
437                    // kernel accepted, not the length we hoped to send.
438                    let sent = socket.send_to(&packet, target).await?;
439                    debug!(
440                        "DHCP client {} sent {} ({} bytes) to {}",
441                        client_id, label, sent, target
442                    );
443                    trace!("DHCP {} (hex): {}", label, hex::encode(&packet));
444                    Ok(Applied::Sent(sent))
445                }
446            }
447            ClientActionResult::Disconnect => Ok(Applied::Disconnect),
448            ClientActionResult::WaitForMore => Ok(Applied::Executed("wait_for_more".to_string())),
449            ClientActionResult::NoAction => Ok(Applied::Executed("no_action".to_string())),
450            other => Ok(Applied::Executed(format!("no wire effect: {other:?}"))),
451        }
452    }
453
454    /// Apply one injected action, record it in the access log exactly as
455    /// `command_support::handle_stream_client_command` does for stream clients, and
456    /// reply on the command's oneshot. Returns `true` when the receive loop should
457    /// stop.
458    ///
459    /// Bespoke rather than the generic helper because every DHCP verb yields
460    /// `ClientActionResult::Custom` and the destination is a property of the action
461    /// (its `broadcast` flag), not of a write half.
462    async fn handle_injected_command(
463        command: ClientCommand,
464        socket: &Arc<UdpSocket>,
465        unicast_target: SocketAddr,
466        protocol: &Arc<DhcpClientProtocol>,
467        app_state: &Arc<AppState>,
468        status_tx: &mpsc::UnboundedSender<String>,
469        client_id: ClientId,
470    ) -> bool {
471        use crate::llm::actions::protocol_trait::Protocol;
472
473        let action = command.action.clone();
474        let outcome = match protocol.as_ref().execute_action(action.clone()) {
475            Err(e) => Ok(ClientSendOutcome::Rejected {
476                error: e.to_string(),
477            }),
478            Ok(result) => Self::apply_action_result(result, socket, unicast_target, client_id)
479                .await
480                .map(|applied| match applied {
481                    Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
482                    Applied::Executed(detail) => ClientSendOutcome::Executed { detail },
483                    Applied::Disconnect => ClientSendOutcome::Disconnected,
484                }),
485        };
486
487        let outcome_json = match &outcome {
488            Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
489            Err(e) => serde_json::json!({"error": e.to_string()}),
490        };
491        app_state
492            .record_access_log(
493                AccessLogOwner::Client(client_id.as_u32()),
494                protocol.protocol_name(),
495                None,
496                "injected_action",
497                action,
498                vec![outcome_json],
499            )
500            .await;
501
502        let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
503        if let Err(e) = &outcome {
504            error!("DHCP client {} injected action failed: {}", client_id, e);
505            let _ = status_tx.send(format!(
506                "[WARN] Client {} injected action failed: {}",
507                client_id, e
508            ));
509        }
510        let _ = status_tx.send("__UPDATE_UI__".to_string());
511        crate::client::command_support::reply(command, outcome);
512        disconnect
513    }
514
515    fn build_discover_packet(params: &serde_json::Value) -> Result<Vec<u8>> {
516        use dhcproto::Encodable;
517        use std::net::Ipv4Addr;
518
519        // Generate random transaction ID
520        let xid = rand::random::<u32>();
521
522        // Get MAC address from params or generate random one
523        let mac_str = params
524            .get("mac_address")
525            .and_then(|v| v.as_str())
526            .unwrap_or("00:00:00:00:00:00");
527
528        let chaddr = Self::parse_mac_address(mac_str)?;
529
530        // Build DHCP DISCOVER
531        let mut msg = v4::Message::default();
532        msg.set_opcode(v4::Opcode::BootRequest)
533            .set_xid(xid)
534            .set_flags(v4::Flags::default().set_broadcast())
535            .set_chaddr(&chaddr);
536
537        // Add DHCP options
538        msg.opts_mut()
539            .insert(v4::DhcpOption::MessageType(v4::MessageType::Discover));
540
541        // Optional: requested IP
542        if let Some(requested_ip) = params.get("requested_ip").and_then(|v| v.as_str()) {
543            if let Ok(ip) = requested_ip.parse::<Ipv4Addr>() {
544                msg.opts_mut()
545                    .insert(v4::DhcpOption::RequestedIpAddress(ip));
546            }
547        }
548
549        // Encode to bytes
550        let bytes = msg.to_vec()?;
551        Ok(bytes)
552    }
553
554    fn build_request_packet(params: &serde_json::Value) -> Result<Vec<u8>> {
555        use dhcproto::Encodable;
556        use std::net::Ipv4Addr;
557
558        // Generate random transaction ID
559        let xid = rand::random::<u32>();
560
561        // Get MAC address
562        let mac_str = params
563            .get("mac_address")
564            .and_then(|v| v.as_str())
565            .unwrap_or("00:00:00:00:00:00");
566
567        let chaddr = Self::parse_mac_address(mac_str)?;
568
569        // Get requested IP (required for REQUEST)
570        let requested_ip = params
571            .get("requested_ip")
572            .and_then(|v| v.as_str())
573            .context("Missing 'requested_ip' parameter")?
574            .parse::<Ipv4Addr>()?;
575
576        // Get server IP (optional)
577        let server_ip = params
578            .get("server_ip")
579            .and_then(|v| v.as_str())
580            .map(|s| s.parse::<Ipv4Addr>())
581            .transpose()?;
582
583        // Build DHCP REQUEST
584        let mut msg = v4::Message::default();
585        msg.set_opcode(v4::Opcode::BootRequest)
586            .set_xid(xid)
587            .set_flags(v4::Flags::default().set_broadcast())
588            .set_chaddr(&chaddr);
589
590        // Add DHCP options
591        msg.opts_mut()
592            .insert(v4::DhcpOption::MessageType(v4::MessageType::Request));
593        msg.opts_mut()
594            .insert(v4::DhcpOption::RequestedIpAddress(requested_ip));
595
596        if let Some(server) = server_ip {
597            msg.opts_mut()
598                .insert(v4::DhcpOption::ServerIdentifier(server));
599        }
600
601        // Encode to bytes
602        let bytes = msg.to_vec()?;
603        Ok(bytes)
604    }
605
606    fn build_inform_packet(params: &serde_json::Value) -> Result<Vec<u8>> {
607        use dhcproto::Encodable;
608        use std::net::Ipv4Addr;
609
610        // Generate random transaction ID
611        let xid = rand::random::<u32>();
612
613        // Get MAC address
614        let mac_str = params
615            .get("mac_address")
616            .and_then(|v| v.as_str())
617            .unwrap_or("00:00:00:00:00:00");
618
619        let chaddr = Self::parse_mac_address(mac_str)?;
620
621        // Get current IP (required for INFORM)
622        let current_ip = params
623            .get("current_ip")
624            .and_then(|v| v.as_str())
625            .context("Missing 'current_ip' parameter")?
626            .parse::<Ipv4Addr>()?;
627
628        // Build DHCP INFORM
629        let mut msg = v4::Message::default();
630        msg.set_opcode(v4::Opcode::BootRequest)
631            .set_xid(xid)
632            .set_ciaddr(current_ip) // Set client IP address
633            .set_chaddr(&chaddr);
634
635        // Add DHCP options
636        msg.opts_mut()
637            .insert(v4::DhcpOption::MessageType(v4::MessageType::Inform));
638
639        // Encode to bytes
640        let bytes = msg.to_vec()?;
641        Ok(bytes)
642    }
643
644    fn parse_mac_address(mac_str: &str) -> Result<Vec<u8>> {
645        let parts: Vec<&str> = mac_str.split(':').collect();
646        if parts.len() != 6 {
647            anyhow::bail!("Invalid MAC address format: {}", mac_str);
648        }
649
650        let mut mac = Vec::with_capacity(16); // DHCP chaddr is 16 bytes
651        for part in parts {
652            let byte = u8::from_str_radix(part, 16).context("Invalid hex in MAC address")?;
653            mac.push(byte);
654        }
655
656        // Pad to 16 bytes (DHCP chaddr field)
657        while mac.len() < 16 {
658            mac.push(0);
659        }
660
661        Ok(mac)
662    }
663
664    fn parse_dhcp_response(data: &[u8]) -> Option<(String, serde_json::Value)> {
665        use std::net::Ipv4Addr;
666
667        match v4::Message::decode(&mut Decoder::new(data)) {
668            Ok(msg) => {
669                // `hlen` is read straight off the wire without validation, and
670                // `Message::chaddr()` slices a fixed [u8; 16] with it - a datagram
671                // declaring hlen > 16 panics inside dhcproto. This is the client's
672                // receive path, so a malicious or merely broken DHCP server could
673                // take the client task down. Reject instead. Mirrors the server-side
674                // guard in src/server/dhcp/mod.rs.
675                if msg.hlen() as usize > 16 {
676                    tracing::warn!(
677                        "Dropping DHCP response with invalid hlen {} (max 16)",
678                        msg.hlen()
679                    );
680                    return None;
681                }
682
683                // Extract message type
684                let message_type = msg.opts().get(v4::OptionCode::MessageType).and_then(|opt| {
685                    if let v4::DhcpOption::MessageType(mt) = opt {
686                        Some(*mt)
687                    } else {
688                        None
689                    }
690                });
691
692                let message_type_str = message_type
693                    .as_ref()
694                    .map(|mt| format!("{:?}", mt))
695                    .unwrap_or_else(|| "Unknown".to_string());
696
697                // Extract key fields
698                let offered_ip = msg.yiaddr();
699                let server_ip = msg
700                    .opts()
701                    .get(v4::OptionCode::ServerIdentifier)
702                    .and_then(|opt| {
703                        if let v4::DhcpOption::ServerIdentifier(ip) = opt {
704                            Some(*ip)
705                        } else {
706                            None
707                        }
708                    });
709
710                let subnet_mask = msg.opts().get(v4::OptionCode::SubnetMask).and_then(|opt| {
711                    if let v4::DhcpOption::SubnetMask(mask) = opt {
712                        Some(*mask)
713                    } else {
714                        None
715                    }
716                });
717
718                let router = msg.opts().get(v4::OptionCode::Router).and_then(|opt| {
719                    if let v4::DhcpOption::Router(routers) = opt {
720                        routers.first().copied()
721                    } else {
722                        None
723                    }
724                });
725
726                let dns_servers =
727                    msg.opts()
728                        .get(v4::OptionCode::DomainNameServer)
729                        .and_then(|opt| {
730                            if let v4::DhcpOption::DomainNameServer(dns) = opt {
731                                Some(dns.clone())
732                            } else {
733                                None
734                            }
735                        });
736
737                let lease_time = msg
738                    .opts()
739                    .get(v4::OptionCode::AddressLeaseTime)
740                    .and_then(|opt| {
741                        if let v4::DhcpOption::AddressLeaseTime(time) = opt {
742                            Some(*time)
743                        } else {
744                            None
745                        }
746                    });
747
748                // Build details JSON
749                let mut details = serde_json::json!({
750                    "transaction_id": format!("0x{:08x}", msg.xid()),
751                    "client_mac": hex::encode(msg.chaddr())
752                });
753
754                if offered_ip != Ipv4Addr::UNSPECIFIED {
755                    details["offered_ip"] = serde_json::json!(offered_ip.to_string());
756                }
757
758                if let Some(server) = server_ip {
759                    details["server_ip"] = serde_json::json!(server.to_string());
760                }
761
762                if let Some(mask) = subnet_mask {
763                    details["subnet_mask"] = serde_json::json!(mask.to_string());
764                }
765
766                if let Some(gw) = router {
767                    details["router"] = serde_json::json!(gw.to_string());
768                }
769
770                if let Some(dns) = dns_servers {
771                    let dns_strs: Vec<String> = dns.iter().map(|ip| ip.to_string()).collect();
772                    details["dns_servers"] = serde_json::json!(dns_strs);
773                }
774
775                if let Some(lease) = lease_time {
776                    details["lease_time"] = serde_json::json!(lease);
777                }
778
779                Some((message_type_str, details))
780            }
781            Err(e) => {
782                tracing::warn!("Failed to parse DHCP response: {}", e);
783                None
784            }
785        }
786    }
787}