1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{anyhow, Context, Result};
11use serde_json::json;
12use std::net::Ipv4Addr;
13use std::sync::LazyLock;
14
15use dhcproto::{v4, Encodable, Encoder};
16
17pub struct BootpProtocol {
18 request_context: std::sync::Arc<std::sync::Mutex<Option<BootpRequestContext>>>,
19}
20
21#[derive(Clone)]
22pub struct BootpRequestContext {
23 pub xid: u32, pub chaddr: Vec<u8>, pub op: v4::Opcode, pub ciaddr: Ipv4Addr, pub giaddr: Ipv4Addr, pub sname: String, pub file: String, }
31
32impl BootpProtocol {
33 pub fn new() -> Self {
34 Self {
35 request_context: std::sync::Arc::new(std::sync::Mutex::new(None)),
36 }
37 }
38
39 pub fn set_request_context(&self, context: BootpRequestContext) {
40 *self
41 .request_context
42 .lock()
43 .unwrap_or_else(|e| e.into_inner()) = Some(context);
44 }
45}
46
47impl Protocol for BootpProtocol {
49 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
50 Vec::new()
51 }
52 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
53 vec![
54 send_bootp_reply_action(),
55 send_bootp_response_action(),
56 ignore_request_action(),
57 ]
58 }
59 fn protocol_name(&self) -> &'static str {
60 "BOOTP"
61 }
62 fn get_event_types(&self) -> Vec<EventType> {
63 get_bootp_event_types()
64 }
65 fn stack_name(&self) -> &'static str {
66 "ETH>IP>UDP>BOOTP"
67 }
68 fn keywords(&self) -> Vec<&'static str> {
69 vec!["bootp", "bootstrap"]
70 }
71 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
72 use crate::protocol::metadata::{
73 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
74 };
75
76 ProtocolMetadataV2::builder()
77 .connectionless()
78 .state(DevelopmentState::Experimental)
79 .deliberately_silent()
82 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(67))
83 .well_known_udp_port(67)
84 .implementation("dhcproto v0.12 for parsing (BOOTP format)")
85 .llm_control("BOOTREQUEST→BOOTREPLY flow + boot file location")
86 .e2e_testing("Manual BOOTP packet construction - 3 LLM calls")
87 .notes("Bootstrap Protocol (RFC 951) - DHCP predecessor")
88 .build()
89 }
90 fn description(&self) -> &'static str {
91 "BOOTP server for diskless workstation boot configuration"
92 }
93 fn example_prompt(&self) -> &'static str {
94 "Start a BOOTP server on port 67"
95 }
96 fn group_name(&self) -> &'static str {
97 "Core"
98 }
99 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
100 use crate::llm::actions::StartupExamples;
101 StartupExamples::new(
102 json!({
104 "type": "open_server",
105 "port": 67,
106 "base_stack": "bootp",
107 "instruction": "BOOTP server for PXE boot, assign IPs from 192.168.1.100, boot file 'boot/pxeboot.n12'"
108 }),
109 json!({
111 "type": "open_server",
112 "port": 67,
113 "base_stack": "bootp",
114 "event_handlers": [{
115 "event_pattern": "bootp_request",
116 "handler": {
117 "type": "script",
118 "language": "python",
119 "code": "# Handle BOOTP request\nif event.get('op_code') == 'BootRequest':\n respond([{'type': 'send_bootp_reply', 'assigned_ip': '192.168.1.100', 'server_ip': '192.168.1.1', 'boot_file': 'boot/pxeboot.n12', 'server_hostname': 'bootserver.local'}])"
120 }
121 }]
122 }),
123 json!({
125 "type": "open_server",
126 "port": 67,
127 "base_stack": "bootp",
128 "event_handlers": [{
129 "event_pattern": "bootp_request",
130 "handler": {
131 "type": "static",
132 "actions": [{
133 "type": "send_bootp_reply",
134 "assigned_ip": "192.168.1.100",
135 "server_ip": "192.168.1.1",
136 "boot_file": "boot/pxeboot.n12"
137 }]
138 }
139 }]
140 }),
141 )
142 }
143}
144
145impl Server for BootpProtocol {
147 fn spawn(
148 &self,
149 ctx: crate::protocol::SpawnContext,
150 ) -> std::pin::Pin<
151 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
152 > {
153 Box::pin(async move {
154 use crate::server::bootp::BootpServer;
155 BootpServer::spawn_with_llm_actions(
156 ctx.legacy_listen_addr(),
157 ctx.llm_client,
158 ctx.state,
159 ctx.status_tx,
160 ctx.server_id,
161 )
162 .await
163 })
164 }
165 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
166 let action_type = action
167 .get("type")
168 .and_then(|v| v.as_str())
169 .context("Missing 'type' field in action")?;
170
171 match action_type {
172 "send_bootp_reply" => self.execute_send_bootp_reply(action),
173 "send_bootp_response" => self.execute_send_bootp_response(action),
174 "ignore_request" => Ok(ActionResult::NoAction),
175 _ => Err(anyhow::anyhow!("Unknown BOOTP action: {}", action_type)),
176 }
177 }
178}
179
180impl BootpProtocol {
181 fn execute_send_bootp_reply(&self, action: serde_json::Value) -> Result<ActionResult> {
182 let context = self
183 .request_context
184 .lock()
185 .map_err(|_| anyhow!("BOOTP request context lock was poisoned"))?
186 .clone();
187
188 let xid = match action.get("xid") {
192 Some(v) => parse_xid(v)?,
193 None => context.as_ref().map(|c| c.xid).ok_or_else(|| {
194 anyhow!(
195 "No BOOTP request context available and no 'xid' given: cannot build a reply \
196 the client will accept. Pass the 'xid' from the bootp_request event."
197 )
198 })?,
199 };
200
201 let chaddr = match action.get("client_mac").and_then(|v| v.as_str()) {
203 Some(mac) => parse_mac(mac)?,
204 None => context.as_ref().map(|c| c.chaddr.clone()).ok_or_else(|| {
205 anyhow!(
206 "No BOOTP request context available and no 'client_mac' given. Pass the \
207 'client_mac' from the bootp_request event."
208 )
209 })?,
210 };
211
212 let assigned_ip = parse_ipv4(&action, "assigned_ip")?
214 .ok_or_else(|| anyhow!("Missing 'assigned_ip' parameter"))?;
215
216 let server_ip = parse_ipv4(&action, "server_ip")?.unwrap_or(Ipv4Addr::UNSPECIFIED);
217
218 let boot_file = action
219 .get("boot_file")
220 .and_then(|v| v.as_str())
221 .unwrap_or("");
222
223 let server_hostname = action
224 .get("server_hostname")
225 .and_then(|v| v.as_str())
226 .unwrap_or("");
227
228 if boot_file.len() > 128 {
230 return Err(anyhow!(
231 "'boot_file' is {} bytes; the BOOTP file field holds at most 128",
232 boot_file.len()
233 ));
234 }
235 if server_hostname.len() > 64 {
236 return Err(anyhow!(
237 "'server_hostname' is {} bytes; the BOOTP sname field holds at most 64",
238 server_hostname.len()
239 ));
240 }
241
242 let gateway_ip = parse_ipv4(&action, "gateway_ip")?.unwrap_or_else(|| {
245 context
246 .as_ref()
247 .map(|c| c.giaddr)
248 .unwrap_or(Ipv4Addr::UNSPECIFIED)
249 });
250
251 let mut msg = v4::Message::default();
253 msg.set_opcode(v4::Opcode::BootReply)
254 .set_xid(xid)
255 .set_flags(v4::Flags::default())
256 .set_yiaddr(assigned_ip)
257 .set_siaddr(server_ip)
258 .set_giaddr(gateway_ip)
259 .set_chaddr(&chaddr);
260
261 if !boot_file.is_empty() {
263 msg.set_fname_str(boot_file);
264 }
265
266 if !server_hostname.is_empty() {
268 msg.set_sname_str(server_hostname);
269 }
270
271 let mut buf = Vec::new();
273 let mut encoder = Encoder::new(&mut buf);
274 msg.encode(&mut encoder)?;
275
276 Ok(ActionResult::Output(buf))
277 }
278
279 fn execute_send_bootp_response(&self, action: serde_json::Value) -> Result<ActionResult> {
280 let data = action
281 .get("data")
282 .and_then(|v| v.as_str())
283 .context("Missing 'data' parameter")?;
284
285 let bytes = decode_hex_packet(data)?;
286
287 if bytes.len() < 236 {
288 return Err(anyhow!(
289 "'data' decodes to {} bytes; a BOOTP packet is at least 236 bytes (300 with the \
290 options field). Prefer send_bootp_reply, which builds a well-formed packet for you.",
291 bytes.len()
292 ));
293 }
294
295 Ok(ActionResult::Output(bytes))
296 }
297}
298
299fn decode_hex_packet(data: &str) -> Result<Vec<u8>> {
306 let cleaned: String = data
307 .chars()
308 .filter(|c| !c.is_ascii_whitespace() && *c != ':')
309 .collect();
310 let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
311
312 if cleaned.len() % 2 != 0 {
313 return Err(anyhow!(
314 "Invalid hex in 'data': got {} hex digits, which is odd - every byte is exactly two \
315 digits. Value was {data:?}",
316 cleaned.len()
317 ));
318 }
319
320 hex::decode(cleaned).map_err(|e| {
321 anyhow!(
322 "Invalid hex in 'data' ({data:?}): {e}. Use only the digits 0-9 and a-f, two per byte. \
323 This field is the raw packet - there is no text mode."
324 )
325 })
326}
327
328fn parse_ipv4(action: &serde_json::Value, field: &str) -> Result<Option<Ipv4Addr>> {
330 match action.get(field).and_then(|v| v.as_str()) {
331 Some(s) => s.parse::<Ipv4Addr>().map(Some).map_err(|e| {
332 anyhow!(
333 "Invalid '{field}' {s:?}: {e}. Expected dotted-quad IPv4, e.g. \"192.168.1.100\""
334 )
335 }),
336 None => Ok(None),
337 }
338}
339
340fn parse_xid(value: &serde_json::Value) -> Result<u32> {
342 if let Some(n) = value.as_u64() {
343 return u32::try_from(n).map_err(|_| {
344 anyhow!("'xid' {n} does not fit in the 32-bit BOOTP transaction id field")
345 });
346 }
347 if let Some(s) = value.as_str() {
348 let trimmed = s.trim().trim_start_matches("0x").trim_start_matches("0X");
349 return u32::from_str_radix(trimmed, 16).map_err(|e| {
350 anyhow!("Invalid 'xid' {s:?}: {e}. Expected a number, or hex like \"0x6395a3e3\"")
351 });
352 }
353 Err(anyhow!(
354 "Invalid 'xid': expected the number from the bootp_request event, got {value}"
355 ))
356}
357
358fn parse_mac(mac: &str) -> Result<Vec<u8>> {
360 let cleaned: String = mac
361 .chars()
362 .filter(|c| !c.is_ascii_whitespace() && *c != ':' && *c != '-' && *c != '.')
363 .collect();
364
365 if cleaned.is_empty() || cleaned.len() % 2 != 0 || cleaned.len() > 32 {
366 return Err(anyhow!(
367 "Invalid 'client_mac' {mac:?}: expected up to 16 bytes of hex, e.g. \
368 \"00:11:22:33:44:55\""
369 ));
370 }
371
372 hex::decode(&cleaned).map_err(|e| {
373 anyhow!("Invalid 'client_mac' {mac:?}: {e}. Expected hex, e.g. \"00:11:22:33:44:55\"")
374 })
375}
376
377fn send_bootp_reply_action() -> ActionDefinition {
378 ActionDefinition {
379 name: "send_bootp_reply".to_string(),
380 description:
381 "Send a BOOTREPLY answering the client's BOOTREQUEST: assigns an IP address and points the client at its boot file. The server fills in the transaction id and the client's hardware address from the request it is answering, so you normally only supply the addresses and file names below."
382 .to_string(),
383 parameters: vec![
384 Parameter {
385 name: "assigned_ip".to_string(),
386 type_hint: "string".to_string(),
387 description: "IPv4 address to give the client, dotted-quad, e.g. '192.168.1.100'. Goes in the yiaddr field"
388 .to_string(),
389 required: true,
390 },
391 Parameter {
392 name: "server_ip".to_string(),
393 type_hint: "string".to_string(),
394 description: "IPv4 address of the boot/TFTP server holding 'boot_file' (siaddr field). Default '0.0.0.0'".to_string(),
395 required: false,
396 },
397 Parameter {
398 name: "boot_file".to_string(),
399 type_hint: "string".to_string(),
400 description: "Path of the boot image on the TFTP server, e.g. 'boot/pxeboot.n12'. At most 128 characters".to_string(),
401 required: false,
402 },
403 Parameter {
404 name: "server_hostname".to_string(),
405 type_hint: "string".to_string(),
406 description: "Name of the boot server, e.g. 'bootserver.local'. At most 64 characters".to_string(),
407 required: false,
408 },
409 Parameter {
410 name: "gateway_ip".to_string(),
411 type_hint: "string".to_string(),
412 description: "Relay agent address (giaddr). Defaults to the giaddr the request arrived with, which is what a relayed client needs - only set this if you are deliberately redirecting the reply".to_string(),
413 required: false,
414 },
415 Parameter {
416 name: "xid".to_string(),
417 type_hint: "number".to_string(),
418 description: "Transaction id to echo. Omit it: the server reuses the xid of the request being answered. Only set it (to the 'xid' from the bootp_request event) if you are constructing a reply out of band - a client discards any reply whose xid does not match its request".to_string(),
419 required: false,
420 },
421 Parameter {
422 name: "client_mac".to_string(),
423 type_hint: "string".to_string(),
424 description: "Hardware address to address the reply to, written either as '001122334455' or '00:11:22:33:44:55'. Omit it: the server reuses the request's chaddr".to_string(),
425 required: false,
426 },
427 ],
428 example: json!({
429 "type": "send_bootp_reply",
430 "assigned_ip": "192.168.1.100",
431 "server_ip": "192.168.1.1",
432 "boot_file": "boot/pxeboot.n12",
433 "server_hostname": "bootserver.local"
434 }),
435 log_template: Some(
436 LogTemplate::new()
437 .with_info("-> BOOTP reply IP={assigned_ip}")
438 .with_debug("BOOTP send_bootp_reply: ip={assigned_ip} server={server_ip} file={boot_file}"),
439 ),
440 }
441}
442
443fn send_bootp_response_action() -> ActionDefinition {
444 ActionDefinition {
445 name: "send_bootp_response".to_string(),
446 description: "Escape hatch: send a complete BOOTP packet you assembled yourself, byte for byte. Prefer send_bootp_reply, which fills in the transaction id, hardware address and header fields correctly; use this only for malformed or non-standard packets that send_bootp_reply cannot express. Nothing is echoed for you here - if the xid at bytes 4-7 does not match the request, the client ignores the packet.".to_string(),
447 parameters: vec![Parameter {
448 name: "data".to_string(),
449 type_hint: "string".to_string(),
450 description: "The whole packet as hex, two digits per byte (spaces and ':' are allowed and ignored). Decoded strictly as hex - it is never sent as text - and must be at least 236 bytes".to_string(),
451 required: true,
452 }],
453 example: json!({
459 "type": "send_bootp_response",
460 "data": "020106003903f3260000000000000000c0a80164c0a80101000000000011223344550000000000\
461 000000000000000000000000000000000000000000000000000000000000000000000000000000\
462 000000000000000000000000000000000000000000000000000000000000000000000000000000\
463 000000000000000000000000000000000000000000000000000000000000000000000000000000\
464 000000000000000000000000000000000000000000000000000000000000000000000000000000\
465 000000000000000000000000000000000000000000000000000000000000000000000000000000\
466 0000"
467 }),
468 log_template: Some(
469 LogTemplate::new()
470 .with_info("-> BOOTP raw response ({data_len}B)")
471 .with_debug("BOOTP send_bootp_response: data_len={data_len}"),
472 ),
473 }
474}
475
476fn ignore_request_action() -> ActionDefinition {
477 ActionDefinition {
478 name: "ignore_request".to_string(),
479 description: "Ignore this BOOTP request without sending a response".to_string(),
480 parameters: vec![],
481 example: json!({
482 "type": "ignore_request"
483 }),
484 log_template: Some(
485 LogTemplate::new()
486 .with_info("-> BOOTP ignored")
487 .with_debug("BOOTP ignore_request"),
488 ),
489 }
490}
491
492pub static BOOTP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
497 EventType::new(
498 "bootp_request",
499 "BOOTP client sent a BOOTREQUEST (requesting IP and boot configuration)",
500 json!({
501 "type": "send_bootp_reply",
502 "assigned_ip": "192.168.1.100",
503 "server_ip": "192.168.1.1",
504 "boot_file": "boot/pxeboot.n12"
505 })
506 )
507 .with_parameters(vec![
508 Parameter {
509 name: "op_code".to_string(),
510 type_hint: "string".to_string(),
511 description: "Operation code of the packet received: 'BootRequest' for a client asking to boot, 'BootReply' if some other server's reply reached this port".to_string(),
512 required: true,
513 },
514 Parameter {
515 name: "client_mac".to_string(),
516 type_hint: "string".to_string(),
517 description: "Client hardware address as lower-case hex with no separators, e.g. '001122334455' for the MAC written 00:11:22:33:44:55. Match on this exact form to give a known machine a fixed address".to_string(),
518 required: true,
519 },
520 Parameter {
521 name: "client_ip".to_string(),
522 type_hint: "string".to_string(),
523 description: "Address the client already holds (ciaddr), '0.0.0.0' when it has none yet".to_string(),
524 required: false,
525 },
526 Parameter {
527 name: "xid".to_string(),
528 type_hint: "number".to_string(),
529 description: "Transaction id the client picked at random. send_bootp_reply echoes it automatically; you only need it if you build the reply with send_bootp_response".to_string(),
530 required: false,
531 },
532 Parameter {
533 name: "gateway_ip".to_string(),
534 type_hint: "string".to_string(),
535 description: "Relay agent address the request passed through (giaddr), '0.0.0.0' if the client is on this link. Echoed automatically in the reply".to_string(),
536 required: false,
537 },
538 ])
539 .with_log_template(
540 LogTemplate::new()
541 .with_info("BOOTP {op_code} from {client_mac}")
542 .with_debug("BOOTP {op_code} from MAC {client_mac}, IP {client_ip}")
543 .with_trace("BOOTP: {json_pretty(.)}"),
544 )
545 .with_actions(vec![
546 send_bootp_reply_action(),
547 send_bootp_response_action(),
548 ignore_request_action(),
549 ])
550});
551
552pub fn get_bootp_event_types() -> Vec<EventType> {
553 vec![BOOTP_REQUEST_EVENT.clone()]
554}