Skip to main content

netget/server/dhcp/
mod.rs

1//! DHCP server implementation
2pub mod actions;
3
4use crate::server::connection::ConnectionId;
5use anyhow::Result;
6use std::net::SocketAddr;
7use std::sync::Arc;
8use tokio::net::UdpSocket;
9use tokio::sync::mpsc;
10
11use crate::llm::action_helper::call_llm;
12use crate::llm::ollama_client::OllamaClient;
13use crate::logging::emit::Log;
14use crate::protocol::Event;
15use crate::server::DhcpProtocol;
16use crate::state::app_state::AppState;
17use actions::DHCP_REQUEST_EVENT;
18
19use actions::DhcpRequestContext;
20use dhcproto::{v4, Decodable, Decoder};
21
22/// Render a hardware address the way the `dhcp_request` event reports it: lower-case hex
23/// with no separators, e.g. `001122334455`.
24///
25/// This matches the format the BOOTP sibling and the E2E suites expect. The reply actions
26/// accept either this or the colon-separated form when `client_mac` is passed explicitly.
27pub fn format_mac(bytes: &[u8]) -> String {
28    hex::encode(bytes)
29}
30
31/// DHCP server that forwards requests to LLM
32pub struct DhcpServer;
33
34impl DhcpServer {
35    /// Spawn DHCP server with integrated LLM actions
36    pub async fn spawn_with_llm_actions(
37        listen_addr: SocketAddr,
38        llm_client: OllamaClient,
39        app_state: Arc<AppState>,
40        status_tx: mpsc::UnboundedSender<String>,
41        server_id: crate::state::ServerId,
42    ) -> Result<SocketAddr> {
43        let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
44        let local_addr = socket.local_addr()?;
45        Log::new(Some(&status_tx)).info(format!("DHCP server listening on {}", local_addr));
46
47        let task_registrar = app_state.clone();
48        let accept_handle = tokio::spawn(async move {
49            let mut buffer = vec![0u8; 1500];
50
51            loop {
52                match socket.recv_from(&mut buffer).await {
53                    Ok((n, peer_addr)) => {
54                        let data = buffer[..n].to_vec();
55                        let connection_id =
56                            ConnectionId::new(app_state.get_next_unified_id().await);
57
58                        // DEBUG: Log summary (FileOnly, hot per-packet path)
59                        Log::new(Some(&status_tx))
60                            .debug(format!("DHCP received {} bytes from {}", n, peer_addr));
61
62                        // TRACE: Log full payload (always hex for DHCP, FileOnly)
63                        let hex_str = hex::encode(&data);
64                        Log::new(Some(&status_tx)).trace(format!("DHCP data (hex): {}", hex_str));
65
66                        // A datagram that does not decode - or decodes without a DHCP message
67                        // type option - is not a DHCP request. Raising `dhcp_request` for it
68                        // spent an LLM round trip and handed the model an event reading
69                        // "unknown" in every field, out of which no reply could be built:
70                        // `base_reply` has no transaction id to echo and errors. Drop it.
71                        let Some((_, Some(request_ctx))) = Self::parse_dhcp_message(&data) else {
72                            Log::new(Some(&status_tx)).warn(format!(
73                                "Dropping non-DHCP datagram ({} bytes) from {}",
74                                n, peer_addr
75                            ));
76                            continue;
77                        };
78
79                        // Add connection to ServerInstance
80                        use crate::state::server::{
81                            ConnectionState as ServerConnectionState, ConnectionStatus,
82                            ProtocolConnectionInfo,
83                        };
84                        let now = crate::utils::clock::Instant::now();
85
86                        let conn_state = ServerConnectionState {
87                            id: connection_id,
88                            remote_addr: peer_addr,
89                            local_addr,
90                            bytes_sent: 0,
91                            bytes_received: n as u64,
92                            packets_sent: 0,
93                            packets_received: 1,
94                            last_activity: now,
95                            status: ConnectionStatus::Active,
96                            status_changed_at: now,
97                            protocol_info: ProtocolConnectionInfo::empty(),
98                        };
99                        app_state
100                            .add_connection_to_server(server_id, conn_state)
101                            .await;
102                        let _ = status_tx.send("__UPDATE_UI__".to_string());
103
104                        let llm_clone = llm_client.clone();
105                        let state_clone = app_state.clone();
106                        let status_clone = status_tx.clone();
107                        let socket_clone = socket.clone();
108
109                        // Tracked, not detached: stop_server must abort this task too.
110                        let task_owner = app_state.clone();
111                        task_owner.spawn_server_task(server_id, async move {
112                            // One protocol instance per request. The instance carries the
113                            // request context (xid, chaddr, giaddr, broadcast flag) used to
114                            // build the reply, so two clients whose LLM calls overlap can
115                            // never echo each other's transaction ID.
116                            let protocol = DhcpProtocol::new();
117                            protocol.set_request_context(request_ctx.clone());
118
119                            let message_type = format!("{:?}", request_ctx.message_type);
120                            let message_type_for_log = message_type.clone();
121                            let client_mac = crate::server::dhcp::format_mac(&request_ctx.chaddr);
122                            let requested_ip = request_ctx.requested_ip.map(|ip| ip.to_string());
123                            let xid = Some(request_ctx.xid);
124                            let client_ip = request_ctx.ciaddr.to_string();
125                            let gateway_ip = request_ctx.giaddr.to_string();
126
127                            let mut event_data = serde_json::json!({
128                                "message_type": message_type,
129                                "client_mac": client_mac,
130                                "xid": xid,
131                                "client_ip": client_ip,
132                                "gateway_ip": gateway_ip
133                            });
134                            if let Some(ip) = requested_ip {
135                                event_data["requested_ip"] = serde_json::json!(ip);
136                            }
137
138                            let event = Event::new(&DHCP_REQUEST_EVENT, event_data);
139
140                            Log::new(Some(&status_clone))
141                                .debug(format!("DHCP calling LLM for request from {}", peer_addr));
142
143                            // ── Why a failed request is answered with NOTHING ──────────────
144                            //
145                            // DHCP has no "I cannot answer right now" message. The only reply
146                            // that could be synthesised here without the model is a DHCPNAK,
147                            // and a NAK is not a failure code: RFC 2131 4.3.2 defines it as
148                            // "your notion of the network address is incorrect". Sending it
149                            // because *netget's* backend is down tells the client something
150                            // false about its own lease, and a client in RENEWING/REBINDING
151                            // that receives one MUST drop the address and restart from INIT
152                            // (4.4.5) — a valid lease destroyed by an internal error. The same
153                            // section requires a server with no knowledge of a binding to
154                            // "remain silent", which is exactly netget's position when the
155                            // oracle that would have decided is unavailable.
156                            //
157                            // Silence is also the retry path the protocol already specifies:
158                            // clients retransmit DISCOVER/REQUEST with backoff (4.4.1), so an
159                            // Overloaded backend recovers on the client's own retry, which is
160                            // what a 503 buys in HTTP. Answering would be the worse behaviour
161                            // here, not the better one.
162                            //
163                            // So the three no-reply cases are separated in the LOG instead,
164                            // with a stable `decision=` token (the radius convention): an
165                            // operator greps `decision=fail_closed_` and finds every request
166                            // netget dropped on the floor, distinct from the ones the model
167                            // deliberately chose not to answer. The error itself goes to the
168                            // log and the status stream only — never to the wire, which for
169                            // this protocol carries no bytes at all on these paths.
170                            let log = Log::new(Some(&status_clone));
171
172                            let decision = match call_llm(
173                                &llm_clone,
174                                &state_clone,
175                                server_id,
176                                None,
177                                &event,
178                                &protocol,
179                            )
180                            .await
181                            {
182                                Ok(execution_result) => {
183                                    for message in &execution_result.messages {
184                                        log.info(format!("{}", message));
185                                    }
186
187                                    log.debug(format!(
188                                        "DHCP got {} protocol results",
189                                        execution_result.protocol_results.len()
190                                    ));
191
192                                    let had_actions = !execution_result.raw_actions.is_empty();
193                                    let mut sent = 0usize;
194
195                                    for protocol_result in execution_result.protocol_results {
196                                        if let Some(output_data) =
197                                            protocol_result.get_all_output().first()
198                                        {
199                                            let _ =
200                                                socket_clone.send_to(output_data, peer_addr).await;
201                                            sent += 1;
202
203                                            // DEBUG: Log summary (FileOnly, hot per-packet path)
204                                            log.debug(format!(
205                                                "DHCP sent {} bytes to {}",
206                                                output_data.len(),
207                                                peer_addr
208                                            ));
209
210                                            // TRACE: Log full payload (FileOnly)
211                                            let hex_str = hex::encode(output_data);
212                                            log.trace(format!("DHCP sent (hex): {}", hex_str));
213
214                                            log.info(format!(
215                                                "DHCP response to {} ({} bytes)",
216                                                peer_addr,
217                                                output_data.len()
218                                            ));
219                                        } else {
220                                            log.debug("DHCP protocol result has no output data");
221                                        }
222                                    }
223
224                                    if sent > 0 {
225                                        "model_reply"
226                                    } else if had_actions {
227                                        // The model answered, and its answer produced no
228                                        // packet — `ignore_request`, or actions that write
229                                        // nothing. Deliberate silence, not a failure.
230                                        "model_no_reply"
231                                    } else {
232                                        // The model produced no action at all. Nothing on the
233                                        // wire, same as an error, but a different cause.
234                                        "fail_closed_no_action"
235                                    }
236                                }
237                                Err(e) => {
238                                    // DHCP cannot express "retry later" on the wire, so an
239                                    // overloaded backend and a dead one look identical to the
240                                    // client. Keep the distinction where it is actionable —
241                                    // next to the decision token, in the log.
242                                    let category =
243                                        match crate::utils::wire_failure::WireFailure::classify(&e)
244                                        {
245                                            crate::utils::wire_failure::WireFailure::Overloaded => {
246                                                "overloaded"
247                                            }
248                                            crate::utils::wire_failure::WireFailure::Unavailable => {
249                                                "unavailable"
250                                            }
251                                        };
252                                    log.error(format!(
253                                        "DHCP LLM call failed for {} (category={}): {}",
254                                        peer_addr, category, e
255                                    ));
256                                    "fail_closed_llm_error"
257                                }
258                            };
259
260                            log.info(format!(
261                                "DHCP {} from {} decision={}",
262                                message_type_for_log, peer_addr, decision
263                            ));
264
265                            if decision.starts_with("fail_closed_") {
266                                // RFC 2131 4.3.2: a server that cannot decide remains silent
267                                // and "MAY output a warning to the network administrator".
268                                // This is that warning — the client will retransmit.
269                                log.warn(format!(
270                                    "DHCP sent no reply to {} (no usable decision was produced; \
271                                     the client will retransmit)",
272                                    peer_addr
273                                ));
274                            }
275                        }).await;
276                    }
277                    Err(e) => {
278                        Log::new(Some(&status_tx)).error(format!("DHCP receive error: {}", e));
279                        break;
280                    }
281                }
282            }
283        });
284
285        // Register the recv loop so stop_server can abort it and release the port.
286        task_registrar
287            .register_server_task(server_id, accept_handle)
288            .await;
289
290        Ok(local_addr)
291    }
292
293    /// Decode a datagram into `(description, request context)`.
294    ///
295    /// `None` means the datagram is not usable as a DHCP request: it did not decode, its
296    /// `hlen` is out of range, or it carries no message-type option. The caller drops those.
297    fn parse_dhcp_message(data: &[u8]) -> Option<(String, Option<DhcpRequestContext>)> {
298        use std::net::Ipv4Addr;
299
300        match v4::Message::decode(&mut Decoder::new(data)) {
301            Ok(msg) => {
302                // `hlen` is read straight off the wire without validation, and
303                // `Message::chaddr()` slices a fixed [u8; 16] with it - a datagram
304                // declaring hlen > 16 panics inside dhcproto. This runs in the socket
305                // task, so that panic would take the whole server down. Reject instead.
306                if msg.hlen() as usize > 16 {
307                    tracing::warn!(
308                        "Dropping DHCP datagram with invalid hlen {} (max 16)",
309                        msg.hlen()
310                    );
311                    return None;
312                }
313
314                // Extract message type from options
315                let message_type = msg.opts().get(v4::OptionCode::MessageType).and_then(|opt| {
316                    if let v4::DhcpOption::MessageType(mt) = opt {
317                        Some(*mt)
318                    } else {
319                        None
320                    }
321                });
322
323                let message_type_str = message_type
324                    .as_ref()
325                    .map(|mt| format!("{:?}", mt))
326                    .unwrap_or_else(|| "Unknown".to_string());
327
328                // Extract requested IP from options if present
329                let requested_ip =
330                    msg.opts()
331                        .get(v4::OptionCode::RequestedIpAddress)
332                        .and_then(|opt| {
333                            if let v4::DhcpOption::RequestedIpAddress(ip) = opt {
334                                Some(*ip)
335                            } else {
336                                None
337                            }
338                        });
339
340                // Build human-readable description
341                let mac_str = hex::encode(msg.chaddr());
342                let mut description = format!(
343                    "DHCP {} from client MAC {} (transaction ID: 0x{:08x})",
344                    message_type_str,
345                    mac_str,
346                    msg.xid()
347                );
348
349                if msg.ciaddr() != Ipv4Addr::UNSPECIFIED {
350                    description.push_str(&format!(", client IP: {}", msg.ciaddr()));
351                }
352
353                if let Some(req_ip) = requested_ip {
354                    description.push_str(&format!(", requested IP: {}", req_ip));
355                }
356
357                // Create context for action execution
358                let context = message_type.map(|mt| DhcpRequestContext {
359                    xid: msg.xid(),
360                    chaddr: msg.chaddr().to_vec(),
361                    message_type: mt,
362                    ciaddr: msg.ciaddr(),
363                    giaddr: msg.giaddr(),
364                    broadcast: msg.flags().broadcast(),
365                    requested_ip,
366                });
367
368                Some((description, context))
369            }
370            Err(e) => {
371                tracing::warn!("Failed to parse DHCP message: {}", e);
372                None
373            }
374        }
375    }
376}