Skip to main content

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}