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}