mini_dhcp/
lib.rs

1use anyhow::Context;
2use dhcproto::v4::{
3    Decodable, Decoder, DhcpOption, Encodable, Encoder, Message, MessageType, Opcode, OptionCode,
4};
5use jiff::{ToSpan, Unit, Zoned};
6use rand::Rng;
7use serde::Serialize;
8use std::net::Ipv4Addr;
9use std::path::PathBuf;
10use tokio::{net::UdpSocket, sync::mpsc};
11use tracing::{error, info, warn};
12pub mod db;
13pub mod info;
14pub mod migration;
15
16#[derive(Debug, Serialize)]
17pub struct Client {
18    pub ip: Ipv4Addr,
19    pub client_id: String,
20}
21
22fn check_ip_in_range(addr: Ipv4Addr) -> bool {
23    let [a, b, c, d] = addr.octets();
24    a == 192 && b == 168 && c == 1 && (100..200).contains(&d)
25}
26
27fn get_ip_in_range() -> Ipv4Addr {
28    let octet = rand::thread_rng().gen_range(100..200);
29    Ipv4Addr::new(192, 168, 1, octet)
30}
31
32async fn insert_lease(
33    store: &db::LeaseStore,
34    ip: Ipv4Addr,
35    client_id: &Vec<u8>,
36) -> anyhow::Result<()> {
37    let expire_at = Zoned::now()
38        .round(Unit::Second)?
39        .checked_add(1.hour())
40        .with_context(|| "Failed to calculate lease expiry time".to_string())?
41        .timestamp()
42        .as_second();
43
44    let lease = db::Lease {
45        ip,
46        client_id: client_id.clone(),
47        leased: true,
48        expires_at: expire_at,
49        network: 0,
50        probation: false,
51    };
52
53    store.insert_lease(lease).await?;
54    Ok(())
55}
56
57async fn build_dhcp_offer_packet(
58    leases: &db::LeaseStore,
59    discover_message: &Message,
60) -> anyhow::Result<Message> {
61    let xid = discover_message.xid();
62    let client_id = discover_message.chaddr().to_vec();
63
64    // The client's current address as recorded in the client's current
65    // binding
66    // The client's current address as recorded in the client's current
67    // binding, ELSE
68    //
69    // The client's previous address as recorded in the client's (now
70    // expired or released) binding, if that address is in the server's
71    // pool of available addresses and not already allocated, ELSE
72    let mut suggested_address = match leases.get_ip_from_client_id(&client_id).await {
73        Ok(address) => {
74            // Check if this lease has expired
75            match leases.get_lease_by_ip(&address).await {
76                Ok(lease) => {
77                    let current_time = Zoned::now().round(Unit::Second)?.timestamp().as_second();
78                    if lease.expires_at < current_time {
79                        // Lease has expired, renew it
80                        let new_expiry = Zoned::now()
81                            .round(Unit::Second)?
82                            .checked_add(1.hour())
83                            .with_context(|| "Failed to calculate lease expiry time".to_string())?
84                            .timestamp()
85                            .as_second();
86                        leases.update_lease_expiry(address, new_expiry).await?;
87                        info!(
88                            "[{xid:X}] [OFFER] Client {:?} has expired lease for {:?}, renewed",
89                            client_id, address
90                        );
91                    } else {
92                        info!(
93                            "[{xid:X}] [OFFER] Client {:?} already has IP assigned: {:?}",
94                            client_id, address
95                        );
96                    }
97                    Some(address)
98                }
99                Err(_) => {
100                    warn!(
101                        "[{xid:X}] [OFFER] Could not fetch lease details for {:?}",
102                        address
103                    );
104                    Some(address)
105                }
106            }
107        }
108        _ => {
109            warn!(
110                "[{xid:X}] [OFFER] Client {:?} has no IP assigned in the database",
111                client_id
112            );
113            None
114        }
115    };
116
117    // The address requested in the 'Requested IP Address' option, if that
118    // address is valid and not already allocated, ELSE
119    if suggested_address.is_none() {
120        let requested_ip_address = discover_message.opts().get(OptionCode::RequestedIpAddress);
121        info!(
122            "[{xid:X}] [OFFER] Client requested IP address: {:?}",
123            requested_ip_address
124        );
125        match requested_ip_address {
126            Some(DhcpOption::RequestedIpAddress(ip)) => {
127                if !leases.is_ip_assigned(*ip).await? && check_ip_in_range(*ip) {
128                    insert_lease(leases, *ip, &client_id).await?;
129                    suggested_address = Some(*ip);
130                }
131            }
132            _ => {
133                warn!("[{xid:X}] [OFFER] No requested IP address")
134            }
135        };
136    }
137
138    // A new address allocated from the server's pool of available addresses
139    if suggested_address.is_none() {
140        for _ in 0..10 {
141            let random_address = get_ip_in_range();
142            if !leases.is_ip_assigned(random_address).await? {
143                insert_lease(leases, random_address, &client_id).await?;
144                suggested_address = Some(random_address);
145                break;
146            }
147        }
148    }
149
150    let suggested_address = match suggested_address {
151        Some(address) => address,
152        None => return Err(anyhow::anyhow!("Could not assign IP address")),
153    };
154
155    info!(
156        "[{xid:X}] [OFFER] Creating offer with IP {}",
157        suggested_address
158    );
159
160    let mut offer = Message::default();
161
162    let reply_opcode = Opcode::BootReply;
163    offer.set_opcode(reply_opcode);
164    offer.set_xid(discover_message.xid());
165    offer.set_yiaddr(suggested_address);
166    offer.set_siaddr(Ipv4Addr::new(192, 168, 1, 69));
167    offer.set_flags(discover_message.flags());
168    offer.set_giaddr(discover_message.giaddr());
169    offer.set_chaddr(discover_message.chaddr());
170
171    offer
172        .opts_mut()
173        .insert(DhcpOption::MessageType(MessageType::Offer));
174    offer.opts_mut().insert(DhcpOption::AddressLeaseTime(3600));
175    offer
176        .opts_mut()
177        .insert(DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 69)));
178    offer
179        .opts_mut()
180        .insert(DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)));
181    offer
182        .opts_mut()
183        .insert(DhcpOption::BroadcastAddr(Ipv4Addr::new(255, 255, 255, 255)));
184    offer
185        .opts_mut()
186        .insert(DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 1, 69)]));
187
188    Ok(offer)
189}
190
191/// Response type for DHCP REQUEST handling
192#[derive(Debug)]
193enum DhcpResponse {
194    Ack(Message),
195    Nak(Message),
196}
197
198/// Build a DHCP NAK packet according to RFC 2131 Table 3
199fn build_dhcp_nack_packet(request_message: &Message, reason: &str) -> Message {
200    let xid = request_message.xid();
201    info!("[{xid:X}] [NAK] Sending: {}", reason);
202
203    let mut nak = Message::default();
204    nak.set_opcode(Opcode::BootReply);
205    nak.set_xid(request_message.xid());
206    nak.set_flags(request_message.flags());
207    nak.set_chaddr(request_message.chaddr());
208
209    // RFC 2131 Table 3: yiaddr and siaddr must be 0 for NACK
210    nak.set_yiaddr(Ipv4Addr::new(0, 0, 0, 0));
211    nak.set_siaddr(Ipv4Addr::new(0, 0, 0, 0));
212
213    // Only include MessageType and ServerIdentifier options
214    nak.opts_mut()
215        .insert(DhcpOption::MessageType(MessageType::Nak));
216    nak.opts_mut()
217        .insert(DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 69)));
218
219    nak
220}
221
222async fn build_dhcp_ack_packet(
223    leases: &db::LeaseStore,
224    request_message: &Message,
225) -> anyhow::Result<DhcpResponse> {
226    let xid = request_message.xid();
227    let server_identifier_option = request_message.opts().get(OptionCode::ServerIdentifier);
228
229    let requested_ip_option = request_message.opts().get(OptionCode::RequestedIpAddress);
230
231    // `ciaddr` is the client’s current IP address (used in RENEWING/REBINDING)
232    let ciaddr = request_message.ciaddr();
233    let chaddr = request_message.chaddr().to_owned();
234
235    let (is_selecting, is_init_reboot, is_renewing_rebinding) = {
236        let have_server_id = server_identifier_option.is_some();
237        let have_requested_ip = requested_ip_option.is_some();
238        let ciaddr_is_zero = ciaddr == Ipv4Addr::new(0, 0, 0, 0);
239
240        // SELECTING state
241        let selecting = have_server_id && have_requested_ip && ciaddr_is_zero;
242
243        // INIT-REBOOT
244        let init_reboot = !have_server_id && have_requested_ip && ciaddr_is_zero;
245
246        // RENEWING/REBINDING: ciaddr != 0, no 'requested IP address'
247        let renewing_rebinding = ciaddr != Ipv4Addr::new(0, 0, 0, 0) && !have_requested_ip;
248
249        (selecting, init_reboot, renewing_rebinding)
250    };
251
252    let ip_to_validate = if is_selecting || is_init_reboot {
253        match requested_ip_option {
254            Some(DhcpOption::RequestedIpAddress(ip)) => {
255                info!("[{xid:X}] [ACK] Client requested IP address: {:?}", ip);
256                ip
257            }
258            _ => {
259                return Ok(DhcpResponse::Nak(build_dhcp_nack_packet(
260                    request_message,
261                    "Client didn't provide requested IP address",
262                )));
263            }
264        }
265    } else if is_renewing_rebinding {
266        info!("[{xid:X}] [ACK] Using ciaddr {:?}", ciaddr);
267        &ciaddr
268    } else {
269        return Ok(DhcpResponse::Nak(build_dhcp_nack_packet(
270            request_message,
271            "DHCPREQUEST does not match any known valid state",
272        )));
273    };
274
275    // 4) Validate that the IP is on the correct subnet (RFC says to NAK if it's on the wrong net).
276    //    Also check if you have a valid lease for this client in your DB, etc.
277
278    // First check if IP is in valid range
279    if !check_ip_in_range(*ip_to_validate) {
280        return Ok(DhcpResponse::Nak(build_dhcp_nack_packet(
281            request_message,
282            "Requested IP address is outside valid range",
283        )));
284    }
285
286    let lease = match leases.get_lease_by_ip(ip_to_validate).await {
287        Ok(lease) => Some(lease),
288        Err(db::LeaseError::NotFound) => {
289            // No lease exists - check if IP is already assigned to someone else
290            if leases.is_ip_assigned(*ip_to_validate).await? {
291                warn!(
292                    "[{xid:X}] [ACK] IP {:?} is assigned to another client",
293                    ip_to_validate
294                );
295                return Ok(DhcpResponse::Nak(build_dhcp_nack_packet(
296                    request_message,
297                    "IP address is assigned to another client",
298                )));
299            }
300
301            // Lenient mode: accept the client's claimed IP if it's in range and available.
302            // This deviates from RFC 2131 which says to remain silent when we have no record,
303            // but provides better UX for single-server setups (e.g., after server restart).
304            info!(
305                "[{xid:X}] [ACK] No lease record found, creating lease for {:?}",
306                ip_to_validate
307            );
308            insert_lease(leases, *ip_to_validate, &chaddr).await?;
309            None
310        }
311        Err(e) => {
312            warn!("[{xid:X}] [ACK] Database error: {:?}", e);
313            return Ok(DhcpResponse::Nak(build_dhcp_nack_packet(
314                request_message,
315                "Database error",
316            )));
317        }
318    };
319
320    // Validate the lease if it exists (skip if we just created it)
321    if let Some(lease) = &lease {
322        if !lease.leased {
323            return Ok(DhcpResponse::Nak(build_dhcp_nack_packet(
324                request_message,
325                "IP address is not currently leased",
326            )));
327        }
328
329        // Check if lease has expired
330        let current_time = Zoned::now().round(Unit::Second)?.timestamp().as_second();
331        if lease.expires_at < current_time {
332            warn!(
333                "[{xid:X}] [ACK] Lease has expired: expires_at={}, current={}",
334                lease.expires_at, current_time
335            );
336            return Ok(DhcpResponse::Nak(build_dhcp_nack_packet(
337                request_message,
338                "Lease has expired",
339            )));
340        }
341
342        // Validate that the client_id matches the lease
343        if lease.client_id != chaddr {
344            warn!(
345                "[{xid:X}] [ACK] Client ID mismatch: lease has {:?}, request has {:?}",
346                lease.client_id, chaddr
347            );
348            return Ok(DhcpResponse::Nak(build_dhcp_nack_packet(
349                request_message,
350                "IP address is leased to a different client",
351            )));
352        }
353    }
354
355    let mut ack = Message::default();
356    ack.set_opcode(Opcode::BootReply);
357    ack.set_xid(request_message.xid());
358    ack.set_flags(request_message.flags());
359    ack.set_giaddr(request_message.giaddr());
360    ack.set_chaddr(&chaddr);
361
362    ack.set_yiaddr(*ip_to_validate);
363
364    ack.opts_mut()
365        .insert(DhcpOption::MessageType(MessageType::Ack));
366    ack.opts_mut()
367        .insert(DhcpOption::ServerIdentifier(Ipv4Addr::new(192, 168, 1, 69)));
368
369    ack.opts_mut().insert(DhcpOption::AddressLeaseTime(3600));
370    ack.opts_mut()
371        .insert(DhcpOption::SubnetMask(Ipv4Addr::new(255, 255, 255, 0)));
372    ack.opts_mut()
373        .insert(DhcpOption::BroadcastAddr(Ipv4Addr::new(255, 255, 255, 255)));
374    ack.opts_mut()
375        .insert(DhcpOption::Router(vec![Ipv4Addr::new(192, 168, 1, 69)]));
376
377    // Update lease expiry time in database
378    let new_expiry = Zoned::now()
379        .round(Unit::Second)?
380        .checked_add(1.hour())
381        .with_context(|| "Failed to calculate new lease expiry time".to_string())?
382        .timestamp()
383        .as_second();
384
385    leases
386        .update_lease_expiry(*ip_to_validate, new_expiry)
387        .await?;
388
389    Ok(DhcpResponse::Ack(ack))
390}
391
392#[derive(Clone)]
393pub struct MiniDHCPConfiguration {
394    interface: String,
395    event_queue: Vec<mpsc::Sender<String>>,
396    pub leases: db::LeaseStore,
397}
398
399pub struct MiniDHCPConfigurationBuilder {
400    interface: Option<String>,
401    event_queue: Vec<mpsc::Sender<String>>,
402}
403
404impl MiniDHCPConfigurationBuilder {
405    pub fn new() -> MiniDHCPConfigurationBuilder {
406        Self {
407            interface: None,
408            event_queue: Vec::new(),
409        }
410    }
411
412    pub fn set_listening_interface(mut self, interface: &str) -> Self {
413        self.interface = Some(interface.into());
414        self
415    }
416
417    pub fn set_event_queue(mut self, event_queue: mpsc::Sender<String>) -> Self {
418        self.event_queue.push(event_queue);
419        self
420    }
421
422    pub async fn build(self) -> anyhow::Result<MiniDHCPConfiguration> {
423        let interface = self
424            .interface
425            .ok_or_else(|| anyhow::anyhow!("Interface not set"))?;
426
427        let leases = db::LeaseStore::new(PathBuf::from("leases.csv")).await?;
428
429        let event_queue = self.event_queue;
430
431        Ok(MiniDHCPConfiguration {
432            interface,
433            leases,
434            event_queue,
435        })
436    }
437}
438
439async fn handle_discover(
440    config: &MiniDHCPConfiguration,
441    decoded_message: &Message,
442) -> anyhow::Result<Vec<u8>> {
443    let transaction_id = decoded_message.xid();
444    let client_address = decoded_message.chaddr();
445    info!("[{:X}] DISCOVER {:?}", transaction_id, client_address);
446    let offer = build_dhcp_offer_packet(&config.leases, decoded_message);
447
448    match offer.await {
449        Ok(offer) => {
450            let offered_ip = offer.yiaddr();
451            info!(
452                "[{:X}] [OFFER]: client {:?} ip {:?}",
453                transaction_id, client_address, offered_ip
454            );
455
456            let mut buf = Vec::new();
457            let mut e = Encoder::new(&mut buf);
458            offer.encode(&mut e)?;
459            Ok(buf)
460        }
461        Err(e) => {
462            anyhow::bail!("OFFER Error: {:?}", e)
463        }
464    }
465}
466
467async fn handle_request(
468    config: &MiniDHCPConfiguration,
469    decoded_message: &Message,
470) -> anyhow::Result<Vec<u8>> {
471    let options = decoded_message.opts();
472    let transaction_id = decoded_message.xid();
473    let client_address = decoded_message.chaddr();
474    let server_identifier = options.get(OptionCode::ServerIdentifier);
475    info!(
476        "[{:X}] REQUEST from {:?} to {:?}",
477        transaction_id, client_address, server_identifier
478    );
479
480    let response = build_dhcp_ack_packet(&config.leases, decoded_message).await?;
481
482    match response {
483        DhcpResponse::Ack(ack) => {
484            let offered_ip = ack.yiaddr();
485            info!(
486                "[{:X}] [ACK]: {:?} {:?}",
487                transaction_id, client_address, offered_ip
488            );
489
490            // Send notification to event queue
491            for sender in &config.event_queue {
492                let msg = format!("NEW_LEASE: {} -> {:?}", offered_ip, client_address);
493                let _ = sender.try_send(msg);
494            }
495
496            let mut buf = Vec::new();
497            let mut e = Encoder::new(&mut buf);
498            ack.encode(&mut e)?;
499            Ok(buf)
500        }
501        DhcpResponse::Nak(nak) => {
502            info!("[{:X}] [NAK]: {:?}", transaction_id, client_address);
503
504            let mut buf = Vec::new();
505            let mut e = Encoder::new(&mut buf);
506            nak.encode(&mut e)?;
507            Ok(buf)
508        }
509    }
510}
511
512pub async fn start(config: MiniDHCPConfiguration) -> anyhow::Result<()> {
513    let address = "0.0.0.0:67";
514    info!("Starting DHCP listener [{}] {}", config.interface, address);
515    let socket = UdpSocket::bind(address).await?;
516    socket.set_broadcast(true)?;
517    socket.bind_device(Some(config.interface.as_bytes()))?;
518
519    loop {
520        // Receive a packet
521        let mut read_buffer = vec![0u8; 1024];
522        let (_len, addr) = socket.recv_from(&mut read_buffer).await?;
523        info!("== Received packet from {:?} ==", addr);
524
525        let decoded_message = Message::decode(&mut Decoder::new(&read_buffer))?;
526        // https://datatracker.ietf.org/doc/html/rfc2131#page-13
527        // The 'op' field of each DHCP message sent from a client to a server contains BOOTREQUEST.
528        if decoded_message.opcode() != Opcode::BootRequest {
529            error!("[ERROR] opcode is not BootRequest, ignoring message");
530            continue;
531        }
532
533        let options = decoded_message.opts();
534
535        if options.has_msg_type(MessageType::Discover) {
536            let transaction_id = decoded_message.xid();
537            let response = handle_discover(&config, &decoded_message).await;
538            if let Ok(response) = response {
539                info!("[{:X}] [OFFER] Sending...", transaction_id);
540                if let Err(e) = socket.send_to(&response, "255.255.255.255:68").await {
541                    error!(
542                        "[{:X}] [OFFER] Failed to send in socket: {:?}",
543                        transaction_id, e
544                    );
545                }
546            } else {
547                error!("[ERROR] handling DISCOVER {:?}", response);
548            }
549            continue;
550        }
551
552        if options.has_msg_type(MessageType::Request) {
553            let transaction_id = decoded_message.xid();
554            let response = handle_request(&config, &decoded_message).await;
555            if let Ok(response) = response {
556                info!("[{:X}] [ACK/NAK] Sending...", transaction_id);
557                if let Err(e) = socket.send_to(&response, "255.255.255.255:68").await {
558                    error!(
559                        "[{:X}] [ACK/NAK] Failed to send in socket: {:?}",
560                        transaction_id, e
561                    );
562                }
563            } else {
564                error!("[ERROR] handling REQUEST {:?}", response);
565            }
566            continue;
567        }
568
569        if options.has_msg_type(MessageType::Decline) {
570            let transaction_id = decoded_message.xid();
571            let requested_ip = decoded_message.opts().get(OptionCode::RequestedIpAddress);
572
573            if let Some(DhcpOption::RequestedIpAddress(ip)) = requested_ip {
574                info!(
575                    "[{:X}] [DECLINE] Client declined IP {:?} (address conflict detected)",
576                    transaction_id, ip
577                );
578                if let Err(e) = config.leases.mark_ip_declined(*ip).await {
579                    error!(
580                        "[{:X}] [DECLINE] Failed to mark IP as declined: {:?}",
581                        transaction_id, e
582                    );
583                } else {
584                    info!(
585                        "[{:X}] [DECLINE] IP {:?} marked as unavailable",
586                        transaction_id, ip
587                    );
588                }
589            } else {
590                warn!(
591                    "[{:X}] [DECLINE] No requested IP in DECLINE message",
592                    transaction_id
593                );
594            }
595            continue;
596        }
597
598        if options.has_msg_type(MessageType::Release) {
599            let transaction_id = decoded_message.xid();
600            let client_id = decoded_message.chaddr().to_vec();
601            let ciaddr = decoded_message.ciaddr();
602
603            info!(
604                "[{:X}] [RELEASE] Client releasing IP {:?}",
605                transaction_id, ciaddr
606            );
607            if let Err(e) = config.leases.release_lease(ciaddr, &client_id).await {
608                error!(
609                    "[{:X}] [RELEASE] Failed to release lease: {:?}",
610                    transaction_id, e
611                );
612            } else {
613                info!(
614                    "[{:X}] [RELEASE] Lease for {:?} released",
615                    transaction_id, ciaddr
616                );
617            }
618            continue;
619        }
620        if options.has_msg_type(MessageType::Inform) {
621            let transaction_id = decoded_message.xid();
622            info!("[{:X}] [INFORM]", transaction_id);
623            continue;
624        }
625    }
626}