netget/server/bootp/mod.rs
1//! BOOTP 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;
10use tracing::{error, info};
11
12use crate::llm::action_helper::call_llm;
13use crate::llm::ollama_client::OllamaClient;
14use crate::logging::emit::Log;
15use crate::protocol::Event;
16use crate::server::BootpProtocol;
17use crate::state::app_state::AppState;
18use actions::BOOTP_REQUEST_EVENT;
19
20use crate::{console_debug, console_trace};
21use actions::BootpRequestContext;
22use dhcproto::{v4, Decodable, Decoder};
23
24/// Render a hardware address the way the `bootp_request` event reports it: lower-case hex
25/// with no separators, e.g. `001122334455`.
26///
27/// The separator-free form is what `tests/server/bootp/e2e_test.rs` matches on, so it is
28/// the contract. `send_bootp_reply`'s `client_mac` parameter accepts either this or the
29/// colon-separated form when a handler supplies one explicitly.
30pub fn format_mac(bytes: &[u8]) -> String {
31 hex::encode(bytes)
32}
33
34/// BOOTP server that forwards requests to LLM
35pub struct BootpServer;
36
37impl BootpServer {
38 /// Spawn BOOTP server with integrated LLM actions
39 pub async fn spawn_with_llm_actions(
40 listen_addr: SocketAddr,
41 llm_client: OllamaClient,
42 app_state: Arc<AppState>,
43 status_tx: mpsc::UnboundedSender<String>,
44 server_id: crate::state::ServerId,
45 ) -> Result<SocketAddr> {
46 let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
47 let local_addr = socket.local_addr()?;
48 info!("BOOTP server (action-based) listening on {}", local_addr);
49 Log::new(Some(&status_tx)).info(format!("BOOTP server listening on {}", local_addr));
50
51 let task_registrar = app_state.clone();
52 let accept_handle = tokio::spawn(async move {
53 let mut buffer = vec![0u8; 1500];
54
55 loop {
56 match socket.recv_from(&mut buffer).await {
57 Ok((n, peer_addr)) => {
58 let data = buffer[..n].to_vec();
59 let connection_id =
60 ConnectionId::new(app_state.get_next_unified_id().await);
61
62 // DEBUG: Log summary
63 console_debug!(status_tx, "BOOTP received {} bytes from {}", n, peer_addr);
64
65 // TRACE: Log full payload (always hex for BOOTP)
66 let hex_str = hex::encode(&data);
67 console_trace!(status_tx, "BOOTP data (hex): {}", hex_str);
68
69 let parsed_info = Self::parse_bootp_message(&data);
70
71 // Add connection to ServerInstance
72 use crate::state::server::{
73 ConnectionState as ServerConnectionState, ConnectionStatus,
74 ProtocolConnectionInfo,
75 };
76 let now = crate::utils::clock::Instant::now();
77
78 let _request_type = parsed_info
79 .as_ref()
80 .map(|(desc, _)| desc.clone())
81 .unwrap_or_else(|| "unknown".to_string());
82
83 let conn_state = ServerConnectionState {
84 id: connection_id,
85 remote_addr: peer_addr,
86 local_addr,
87 bytes_sent: 0,
88 bytes_received: n as u64,
89 packets_sent: 0,
90 packets_received: 1,
91 last_activity: now,
92 status: ConnectionStatus::Active,
93 status_changed_at: now,
94 protocol_info: ProtocolConnectionInfo::empty(),
95 };
96 app_state
97 .add_connection_to_server(server_id, conn_state)
98 .await;
99 let _ = status_tx.send("__UPDATE_UI__".to_string());
100
101 let llm_clone = llm_client.clone();
102 let state_clone = app_state.clone();
103 let status_clone = status_tx.clone();
104 let socket_clone = socket.clone();
105
106 // Tracked, not detached: stop_server must abort this task too.
107 let task_owner = app_state.clone();
108 task_owner
109 .spawn_server_task(server_id, async move {
110 // One protocol instance per request. The instance carries the
111 // request context (xid, chaddr, giaddr) used to build the reply,
112 // so two clients whose LLM calls overlap can never read each
113 // other's transaction ID.
114 let protocol = BootpProtocol::new();
115
116 if let Some((_, Some(ctx))) = parsed_info.as_ref() {
117 protocol.set_request_context(ctx.clone());
118 }
119
120 // Extract event data
121 let (op_code, client_mac, client_ip, xid, gateway_ip) =
122 if let Some((_, Some(ctx))) = &parsed_info {
123 (
124 format!("{:?}", ctx.op),
125 crate::server::bootp::format_mac(&ctx.chaddr),
126 ctx.ciaddr.to_string(),
127 Some(ctx.xid),
128 ctx.giaddr.to_string(),
129 )
130 } else {
131 (
132 "unknown".to_string(),
133 "unknown".to_string(),
134 "0.0.0.0".to_string(),
135 None,
136 "0.0.0.0".to_string(),
137 )
138 };
139
140 let client_mac_for_log = client_mac.clone();
141
142 let event_data = serde_json::json!({
143 "op_code": op_code,
144 "client_mac": client_mac,
145 "client_ip": client_ip,
146 "xid": xid,
147 "gateway_ip": gateway_ip
148 });
149
150 let event = Event::new(&BOOTP_REQUEST_EVENT, event_data);
151
152 Log::new(Some(&status_clone)).debug(format!(
153 "BOOTP calling LLM for request from {}",
154 peer_addr
155 ));
156
157 match call_llm(
158 &llm_clone,
159 &state_clone,
160 server_id,
161 None,
162 &event,
163 &protocol,
164 )
165 .await
166 {
167 Ok(execution_result) => {
168 let log = Log::new(Some(&status_clone));
169 for message in &execution_result.messages {
170 log.info(format!("{}", message));
171 }
172
173 log.debug(format!(
174 "BOOTP got {} protocol results",
175 execution_result.protocol_results.len()
176 ));
177
178 let mut declined = false;
179 let mut replied = false;
180
181 for protocol_result in execution_result.protocol_results {
182 if let Some(output_data) =
183 protocol_result.get_all_output().first()
184 {
185 replied = true;
186 let _ = socket_clone
187 .send_to(output_data, peer_addr)
188 .await;
189
190 // DEBUG: Log summary
191 log.debug(format!(
192 "BOOTP sent {} bytes to {}",
193 output_data.len(),
194 peer_addr
195 ));
196
197 // TRACE: Log full payload
198 let hex_str = hex::encode(output_data);
199 log.trace(format!("BOOTP sent (hex): {}", hex_str));
200
201 let _ = status_clone.send(format!(
202 "→ BOOTP response to {} ({} bytes)",
203 peer_addr,
204 output_data.len()
205 ));
206 } else {
207 // `ignore_request` yields NoAction: the model
208 // looked at the request and chose not to serve
209 // this client. That is a real decision, not a
210 // failure, and must not be confused with either
211 // of the two below.
212 declined = true;
213 log.debug(
214 "BOOTP protocol result has no output data",
215 );
216 }
217 }
218
219 if !replied {
220 let decision = if declined {
221 "model_decline"
222 } else {
223 "no_answer"
224 };
225 Self::log_silence(
226 &status_clone,
227 peer_addr,
228 &client_mac_for_log,
229 decision,
230 );
231 }
232 }
233 Err(e) => {
234 // The backend failed. BOOTP has no way to say so on the
235 // wire (see `log_silence`), so the peer gets nothing and
236 // the error goes to the log and the operator's status
237 // stream only - never into a datagram.
238 let decision = match crate::utils::WireFailure::classify(&e)
239 {
240 crate::utils::WireFailure::Overloaded => {
241 "fail_closed_overloaded"
242 }
243 crate::utils::WireFailure::Unavailable => {
244 "fail_closed_unavailable"
245 }
246 };
247 error!(
248 "BOOTP LLM call failed for {} (mac={}) decision={}: {}",
249 peer_addr, client_mac_for_log, decision, e
250 );
251 let _ =
252 status_clone.send(format!("✗ BOOTP LLM error: {}", e));
253 Self::log_silence(
254 &status_clone,
255 peer_addr,
256 &client_mac_for_log,
257 decision,
258 );
259 }
260 }
261 })
262 .await;
263 }
264 Err(e) => {
265 error!("BOOTP receive error: {}", e);
266 let _ = status_tx.send(format!("✗ BOOTP receive error: {}", e));
267 break;
268 }
269 }
270 }
271 });
272
273 // Register the recv loop so stop_server can abort it and release the port.
274 task_registrar
275 .register_server_task(server_id, accept_handle)
276 .await;
277
278 Ok(local_addr)
279 }
280
281 /// Record, in one greppable shape, that this request goes unanswered - and why.
282 ///
283 /// **BOOTP has no failure message, and inventing one would be worse than silence.**
284 /// RFC 951 defines exactly two operations, BOOTREQUEST and BOOTREPLY; there is no NAK
285 /// (DHCPNAK belongs to DHCP's message-type option, which BOOTP does not have) and no
286 /// status field anywhere in the 300-byte frame. The only thing this server could put on
287 /// the wire on failure is a BOOTREPLY, and a BOOTREPLY is an *offer*: the client reads
288 /// `yiaddr`, `siaddr` and `file` and boots from them. A reply carrying 0.0.0.0 would
289 /// either be discarded as garbage or, on a lenient client, end the retransmit loop and
290 /// strand a machine that would otherwise have been served by the next retry or by
291 /// another BOOTP server on the segment.
292 ///
293 /// Silence is what the protocol already means by "not me": RFC 951 s7.1 has the client
294 /// retransmit with backoff precisely so a busy or absent server costs nothing. So a
295 /// transient backend failure resolves itself on the client's next attempt, which is the
296 /// same outcome the `Overloaded` category asks for in protocols that can express it.
297 ///
298 /// The three cases are therefore distinguished only here, by `decision=`:
299 /// `model_decline` (the model ran `ignore_request`), `no_answer` (the model produced
300 /// nothing), and `fail_closed_overloaded` / `fail_closed_unavailable` (the LLM call
301 /// itself errored, classified by [`crate::utils::WireFailure`]). Nothing derived from
302 /// the error reaches the socket.
303 fn log_silence(
304 status_tx: &mpsc::UnboundedSender<String>,
305 peer_addr: SocketAddr,
306 client_mac: &str,
307 decision: &'static str,
308 ) {
309 let line = format!(
310 "BOOTP no reply to {} (mac={}) decision={} (RFC 951 has no failure reply; \
311 client will retransmit)",
312 peer_addr, client_mac, decision
313 );
314 tracing::warn!("{}", line);
315 Log::new(Some(status_tx)).info(line);
316 }
317
318 fn parse_bootp_message(data: &[u8]) -> Option<(String, Option<BootpRequestContext>)> {
319 match v4::Message::decode(&mut Decoder::new(data)) {
320 Ok(msg) => {
321 // `hlen` comes straight off the wire without validation, and
322 // `Message::chaddr()` slices a fixed [u8; 16] with it - a datagram
323 // declaring hlen > 16 panics inside dhcproto. This loop runs in the
324 // socket task, so that panic would kill the server. Reject instead.
325 if msg.hlen() as usize > 16 {
326 tracing::warn!(
327 "Dropping BOOTP datagram with invalid hlen {} (max 16)",
328 msg.hlen()
329 );
330 return None;
331 }
332
333 let op = msg.opcode();
334
335 // Build human-readable description
336 let mac_str = hex::encode(msg.chaddr());
337 let description = format!(
338 "BOOTP {} from client MAC {} (transaction ID: 0x{:08x}, client IP: {})",
339 format!("{:?}", op),
340 mac_str,
341 msg.xid(),
342 msg.ciaddr()
343 );
344
345 // Create context for action execution
346 let context = BootpRequestContext {
347 xid: msg.xid(),
348 chaddr: msg.chaddr().to_vec(),
349 op: op,
350 ciaddr: msg.ciaddr(),
351 giaddr: msg.giaddr(),
352 sname: msg
353 .sname()
354 .map(|s| String::from_utf8_lossy(s).trim_matches('\0').to_string())
355 .unwrap_or_default(),
356 file: msg
357 .fname()
358 .map(|f| String::from_utf8_lossy(f).trim_matches('\0').to_string())
359 .unwrap_or_default(),
360 };
361
362 Some((description, Some(context)))
363 }
364 Err(e) => {
365 tracing::warn!("Failed to parse BOOTP message: {}", e);
366 None
367 }
368 }
369 }
370}