Skip to main content

netget/server/bootp/
actions.rs

1//! BOOTP protocol actions implementation
2
3use 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,         // Transaction ID
24    pub chaddr: Vec<u8>,  // Client MAC address
25    pub op: v4::Opcode,   // Operation code (BootRequest/BootReply)
26    pub ciaddr: Ipv4Addr, // Client IP address
27    pub giaddr: Ipv4Addr, // Gateway IP address (for relay)
28    pub sname: String,    // Server host name
29    pub file: String,     // Boot file name
30}
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
47// Implement Protocol trait (common functionality)
48impl 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: BOOTP has no error reply: a BOOTREPLY is an offer of an
80            // address and a claim to be the boot server.
81            .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            // LLM-driven example
103            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            // Script-based example
110            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            // Static handler example
124            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
145// Implement Server trait (server-specific functionality)
146impl 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        // Transaction ID: an explicit 'xid' in the action wins, otherwise echo the one
189        // parsed from this request. A BOOTP client silently discards a reply whose xid
190        // does not match the request it sent, so getting this wrong looks like a timeout.
191        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        // Client hardware address, same rule: explicit wins, else echo the request's.
202        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        // Extract parameters from action
213        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        // dhcproto asserts on over-long values, which would panic the request task.
229        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        // Relay address: default to the giaddr the request arrived with, so a reply routed
243        // through a relay agent still finds its way back.
244        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        // Build BOOTP REPLY message
252        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        // Set boot file name if provided
262        if !boot_file.is_empty() {
263            msg.set_fname_str(boot_file);
264        }
265
266        // Set server hostname if provided
267        if !server_hostname.is_empty() {
268            msg.set_sname_str(server_hostname);
269        }
270
271        // Encode to bytes
272        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
299/// Decode the `data` field of a raw-packet action.
300///
301/// The parameter is documented as hex, so it is decoded strictly as hex: an unparseable
302/// value is an error rather than being silently put on the wire as ASCII, which would
303/// produce a packet no BOOTP client can read. Whitespace and `:` separators are tolerated
304/// because models emit them, and a leading `0x` is stripped.
305fn 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
328/// Parse an optional dotted-quad IPv4 field, reporting the field name on failure.
329fn 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
340/// Parse a transaction id given either as a JSON number or as a hex string ("0x6395a3e3").
341fn 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
358/// Parse a hardware address written as `00:11:22:33:44:55`, `00-11-22-...` or bare hex.
359fn 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        // A complete, decodable 236-byte BOOTREPLY: op=2 htype=1 hlen=6 hops=0,
454        // xid=0x3903f326, yiaddr=192.168.1.100, siaddr=192.168.1.1,
455        // chaddr=00:11:22:33:44:55, empty sname and file. Written out in full on
456        // purpose - the shortest packet this action accepts is 236 bytes, so an
457        // abbreviated example is one the executor rejects.
458        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
492// ============================================================================
493// BOOTP Event Type Constants
494// ============================================================================
495
496pub 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}