Skip to main content

dmx_interface/net/
dhcp.rs

1//! Minimal `DHCPv4` server for access-point clients.
2//!
3//! Implements the RFC 2131 subset needed to hand out addresses from a
4//! fixed pool: `DISCOVER` → `OFFER`, `REQUEST` → `ACK`/`NAK`, plus
5//! `DECLINE`, `RELEASE` and `INFORM`. Clients are identified by their
6//! hardware address (`chaddr`); every lease slot owns a stable pool
7//! address, so a reconnecting device keeps its previous address.
8//!
9//! Replies are broadcast while the client has no address of its own
10//! (sender `0.0.0.0` or the RFC 2131 broadcast flag) and unicast for
11//! renewals. All packet access goes through the bounds-checked
12//! [`Reader`] and [`Writer`] below — client input is never indexed
13//! directly.
14
15use embassy_net::udp::{PacketMetadata, UdpSocket};
16use embassy_net::{IpAddress, Ipv4Address, Stack};
17use embassy_time::{Duration, Instant};
18
19use super::AP_IP;
20
21/// DHCP server port — clients broadcast their requests here.
22const SERVER_PORT: u16 = 67;
23/// DHCP client port — every reply is sent there.
24const CLIENT_PORT: u16 = 68;
25
26/// First pool address: `<AP_IP network>.<POOL_START>` — assumes the `/24`
27/// declared by [`super::AP_PREFIX`].
28const POOL_START: u8 = 10;
29/// Number of lease slots; slot *i* owns `….<POOL_START + i>` (all slots
30/// must fit into that `/24`).
31const POOL_SIZE: usize = 8;
32/// Lease time handed out to clients, in seconds.
33const LEASE_SECS: u64 = 3600;
34/// Lease duration (the `embassy-time` spelling of [`LEASE_SECS`]).
35const LEASE: Duration = Duration::from_secs(LEASE_SECS);
36/// Subnet mask sent with every lease — `/24`, matching
37/// [`super::AP_PREFIX`]; the pool layout above only supports a `/24`.
38const NETMASK: [u8; 4] = [255, 255, 255, 0];
39
40/// BOOTP `op`: request sent by a client (RFC 951).
41const BOOTREQUEST: u8 = 1;
42/// BOOTP `op`: reply sent by the server (RFC 951).
43const BOOTREPLY: u8 = 2;
44/// `htype`: Ethernet hardware type (RFC 826).
45const HTYPE_ETHERNET: u8 = 1;
46/// `hlen`: six-byte MAC address length.
47const HLEN_ETHERNET: u8 = 6;
48/// Magic cookie `0x63825363` preceding the options area (RFC 2131 §4.2).
49const MAGIC_COOKIE: u32 = 0x6382_5363;
50/// RFC 2131 `flags` broadcast bit.
51const FLAG_BROADCAST: u16 = 0x8000;
52
53/// Option 0: padding byte (skip).
54const OPT_PAD: u8 = 0;
55/// Option 1: subnet mask.
56const OPT_SUBNET_MASK: u8 = 1;
57/// Option 3: default router.
58const OPT_ROUTER: u8 = 3;
59/// Option 6: DNS server.
60const OPT_DNS: u8 = 6;
61/// Option 50: requested IP address.
62const OPT_REQUESTED_IP: u8 = 50;
63/// Option 51: IP address lease time.
64const OPT_LEASE_TIME: u8 = 51;
65/// Option 53: DHCP message type (see the `MSG_*` constants).
66const OPT_MESSAGE_TYPE: u8 = 53;
67/// Option 54: server identifier.
68const OPT_SERVER_ID: u8 = 54;
69/// Option 255: end of the options list.
70const OPT_END: u8 = 255;
71
72/// Message type 1: client `DISCOVER`.
73const MSG_DISCOVER: u8 = 1;
74/// Message type 2: server `OFFER`.
75const MSG_OFFER: u8 = 2;
76/// Message type 3: client `REQUEST`.
77const MSG_REQUEST: u8 = 3;
78/// Message type 4: client `DECLINE`.
79const MSG_DECLINE: u8 = 4;
80/// Message type 5: server `ACK`.
81const MSG_ACK: u8 = 5;
82/// Message type 6: server `NAK`.
83const MSG_NAK: u8 = 6;
84/// Message type 7: client `RELEASE`.
85const MSG_RELEASE: u8 = 7;
86/// Message type 8: client `INFORM`.
87const MSG_INFORM: u8 = 8;
88
89/// Bounds-checked cursor over an untrusted packet.
90struct Reader<'a> {
91    buf: &'a [u8],
92}
93
94impl<'a> Reader<'a> {
95    /// Wrap a packet slice at position 0.
96    fn new(buf: &'a [u8]) -> Self {
97        Self { buf }
98    }
99
100    /// Consume the next `len` bytes.
101    fn take(&mut self, len: usize) -> Option<&'a [u8]> {
102        let (head, tail) = self.buf.split_at_checked(len)?;
103        self.buf = tail;
104        Some(head)
105    }
106
107    /// Next byte, `None` once the buffer ends.
108    fn u8(&mut self) -> Option<u8> {
109        self.take(1)?.first().copied()
110    }
111
112    /// Next big-endian `u16`.
113    fn u16(&mut self) -> Option<u16> {
114        let bytes: [u8; 2] = self.take(2)?.try_into().ok()?;
115        Some(u16::from_be_bytes(bytes))
116    }
117
118    /// Next big-endian `u32`.
119    fn u32(&mut self) -> Option<u32> {
120        let bytes: [u8; 4] = self.take(4)?.try_into().ok()?;
121        Some(u32::from_be_bytes(bytes))
122    }
123
124    /// Next four bytes as an IPv4 address.
125    fn ipv4(&mut self) -> Option<Ipv4Address> {
126        let [a, b, c, d]: [u8; 4] = self.take(4)?.try_into().ok()?;
127        Some(Ipv4Address::new(a, b, c, d))
128    }
129}
130
131/// Bounds-checked cursor that appends to a reply buffer.
132struct Writer<'a> {
133    buf: &'a mut [u8],
134    pos: usize,
135}
136
137impl<'a> Writer<'a> {
138    /// Wrap a reply buffer at position 0.
139    fn new(buf: &'a mut [u8]) -> Self {
140        Self { buf, pos: 0 }
141    }
142
143    /// Append raw bytes.
144    fn put(&mut self, bytes: &[u8]) -> Option<()> {
145        let end = self.pos.checked_add(bytes.len())?;
146        self.buf.get_mut(self.pos..end)?.copy_from_slice(bytes);
147        self.pos = end;
148        Some(())
149    }
150
151    /// Append `len` zero bytes.
152    fn zeros(&mut self, len: usize) -> Option<()> {
153        let end = self.pos.checked_add(len)?;
154        self.buf.get_mut(self.pos..end)?.fill(0);
155        self.pos = end;
156        Some(())
157    }
158
159    /// Append one byte.
160    fn u8(&mut self, value: u8) -> Option<()> {
161        self.put(&[value])
162    }
163
164    /// Append a big-endian `u16`.
165    fn u16(&mut self, value: u16) -> Option<()> {
166        self.put(&value.to_be_bytes())
167    }
168
169    /// Append a big-endian `u32`.
170    fn u32(&mut self, value: u32) -> Option<()> {
171        self.put(&value.to_be_bytes())
172    }
173
174    /// Append an IPv4 address.
175    fn ipv4(&mut self, value: Ipv4Address) -> Option<()> {
176        self.put(&value.octets())
177    }
178
179    /// Append a length-prefixed DHCP option.
180    fn opt(&mut self, kind: u8, data: &[u8]) -> Option<()> {
181        self.u8(kind)?;
182        self.u8(u8::try_from(data.len()).ok()?)?;
183        self.put(data)
184    }
185}
186
187/// A validated client message.
188struct ClientMsg {
189    xid: u32,
190    flags: u16,
191    mac: [u8; 6],
192    ciaddr: Ipv4Address,
193    giaddr: Ipv4Address,
194    msg_type: u8,
195    requested_ip: Option<Ipv4Address>,
196    server_id: Option<Ipv4Address>,
197}
198
199/// Parse and validate a DHCP request; `None` for anything that is not a
200/// well-formed ethernet `BOOTREQUEST` from a client.
201fn parse_client(buf: &[u8]) -> Option<ClientMsg> {
202    let mut r = Reader::new(buf);
203    let op = r.u8()?;
204    let htype = r.u8()?;
205    let hlen = r.u8()?;
206    let _hops = r.u8()?;
207    let xid = r.u32()?;
208    let _secs = r.u16()?;
209    let flags = r.u16()?;
210    let ciaddr = r.ipv4()?;
211    let _yiaddr = r.ipv4()?;
212    let _siaddr = r.ipv4()?;
213    let giaddr = r.ipv4()?;
214    let hw_addr = r.take(16)?;
215    let _sname = r.take(64)?;
216    let _file = r.take(128)?;
217    let cookie = r.u32()?;
218    if op != BOOTREQUEST
219        || htype != HTYPE_ETHERNET
220        || hlen != HLEN_ETHERNET
221        || cookie != MAGIC_COOKIE
222    {
223        return None;
224    }
225    let (mac, _padding) = hw_addr.split_at(usize::from(HLEN_ETHERNET));
226    let mac = mac.try_into().ok()?;
227
228    let mut msg_type = 0u8;
229    let mut requested_ip = None;
230    let mut server_id = None;
231    while let Some(kind) = r.u8() {
232        if kind == OPT_END {
233            break;
234        }
235        if kind == OPT_PAD {
236            continue;
237        }
238        // DHCP options are TLV: type, length, value.
239        let len = usize::from(r.u8()?);
240        let data = r.take(len)?;
241        match kind {
242            OPT_MESSAGE_TYPE => {
243                if let Some(value) = data.first() {
244                    msg_type = *value;
245                }
246            }
247            OPT_REQUESTED_IP => {
248                if let Ok([a, b, c, d]) = <[u8; 4]>::try_from(data) {
249                    requested_ip = Some(Ipv4Address::new(a, b, c, d));
250                }
251            }
252            OPT_SERVER_ID => {
253                if let Ok([a, b, c, d]) = <[u8; 4]>::try_from(data) {
254                    server_id = Some(Ipv4Address::new(a, b, c, d));
255                }
256            }
257            _ => {}
258        }
259    }
260    if msg_type == 0 {
261        return None;
262    }
263
264    Some(ClientMsg {
265        xid,
266        flags,
267        mac,
268        ciaddr,
269        giaddr,
270        msg_type,
271        requested_ip,
272        server_id,
273    })
274}
275
276/// One lease slot: a pool address bound to a client hardware address.
277#[derive(Clone, Copy)]
278struct Lease {
279    mac: [u8; 6],
280    expires: Instant,
281    occupied: bool,
282}
283
284/// The server's fixed lease pool.
285struct Server {
286    leases: [Lease; POOL_SIZE],
287}
288
289/// The reply the server decided on for one client message.
290struct Action {
291    kind: u8,
292    yiaddr: Ipv4Address,
293    ciaddr: Ipv4Address,
294    with_config: bool,
295}
296
297impl Server {
298    /// Empty pool — every slot free, nothing expired yet.
299    const fn new() -> Self {
300        Self {
301            leases: [Lease {
302                mac: [0; 6],
303                expires: Instant::from_nanos(0),
304                occupied: false,
305            }; POOL_SIZE],
306        }
307    }
308
309    /// Address owned by a pool slot.
310    fn pool_addr(slot: usize) -> Option<Ipv4Address> {
311        if slot >= POOL_SIZE {
312            return None;
313        }
314        let offset = u8::try_from(slot).ok()?;
315        let octet = POOL_START.checked_add(offset)?;
316        let [a, b, c, _] = AP_IP.octets();
317        Some(Ipv4Address::new(a, b, c, octet))
318    }
319
320    /// Slot that owns `ip`, when the address is inside the pool.
321    fn slot_of_addr(ip: Ipv4Address) -> Option<usize> {
322        let [a, b, c, d] = ip.octets();
323        let [ap_a, ap_b, ap_c, _] = AP_IP.octets();
324        if [a, b, c] != [ap_a, ap_b, ap_c] {
325            return None;
326        }
327        let slot = d.checked_sub(POOL_START)?;
328        if usize::from(slot) < POOL_SIZE {
329            Some(usize::from(slot))
330        } else {
331            None
332        }
333    }
334
335    /// Slot actively (unexpired) leased to `mac`.
336    fn active_slot(&self, mac: [u8; 6], now: Instant) -> Option<usize> {
337        self.leases
338            .iter()
339            .position(|lease| lease.occupied && lease.mac == mac && lease.expires > now)
340    }
341
342    /// Extend a lease for another full duration.
343    fn refresh(&mut self, slot: usize, now: Instant) {
344        if let Some(lease) = self.leases.get_mut(slot) {
345            lease.expires = now.saturating_add(LEASE);
346        }
347    }
348
349    /// Assign a pool address to `mac` (reusing its existing lease, else
350    /// claiming a free or expired slot).
351    fn assign(&mut self, mac: [u8; 6], now: Instant) -> Option<Ipv4Address> {
352        for (i, lease) in self.leases.iter_mut().enumerate() {
353            if lease.occupied && lease.mac == mac && lease.expires > now {
354                lease.expires = now.saturating_add(LEASE);
355                return Self::pool_addr(i);
356            }
357        }
358        for (i, lease) in self.leases.iter_mut().enumerate() {
359            if !lease.occupied || lease.expires <= now {
360                *lease = Lease {
361                    mac,
362                    expires: now.saturating_add(LEASE),
363                    occupied: true,
364                };
365                return Self::pool_addr(i);
366            }
367        }
368        None
369    }
370
371    /// Bind `mac` to a specific pool address (the `REQUEST` path).
372    fn bind_addr(&mut self, mac: [u8; 6], ip: Ipv4Address, now: Instant) -> bool {
373        let Some(slot) = Self::slot_of_addr(ip) else {
374            return false;
375        };
376        let Some(lease) = self.leases.get_mut(slot) else {
377            return false;
378        };
379        let active = lease.occupied && lease.expires > now;
380        if active && lease.mac != mac {
381            return false;
382        }
383        if active {
384            lease.expires = now.saturating_add(LEASE);
385        } else {
386            *lease = Lease {
387                mac,
388                expires: now.saturating_add(LEASE),
389                occupied: true,
390            };
391        }
392        true
393    }
394
395    /// Free a slot (`RELEASE` / `DECLINE`).
396    fn free(&mut self, slot: usize) {
397        if let Some(lease) = self.leases.get_mut(slot) {
398            lease.occupied = false;
399        }
400    }
401
402    /// Decide the reply for one client message; `None` means no answer.
403    fn handle(&mut self, msg: &ClientMsg, now: Instant) -> Option<Action> {
404        match msg.msg_type {
405            MSG_DISCOVER => {
406                let Some(ip) = self.assign(msg.mac, now) else {
407                    log::warn!(
408                        "DHCP: lease pool exhausted — ignoring DISCOVER from {:02X?}",
409                        msg.mac
410                    );
411                    return None;
412                };
413                log::info!("DHCP OFFER {ip} for {:02X?}", msg.mac);
414                Some(Action {
415                    kind: MSG_OFFER,
416                    yiaddr: ip,
417                    ciaddr: Ipv4Address::UNSPECIFIED,
418                    with_config: true,
419                })
420            }
421            MSG_REQUEST => self.handle_request(msg, now),
422            MSG_DECLINE => {
423                if let Some(ip) = msg.requested_ip
424                    && let Some(slot) = Self::slot_of_addr(ip)
425                {
426                    let owned = self
427                        .leases
428                        .get(slot)
429                        .is_some_and(|lease| lease.occupied && lease.mac == msg.mac);
430                    if owned {
431                        self.free(slot);
432                        log::warn!("DHCP DECLINE: {ip} released, client {:02X?}", msg.mac);
433                    } else {
434                        log::warn!(
435                            "DHCP DECLINE for {ip} ignored — not leased to {:02X?}",
436                            msg.mac
437                        );
438                    }
439                }
440                None
441            }
442            MSG_RELEASE => {
443                if let Some(sid) = msg.server_id
444                    && sid != AP_IP
445                {
446                    return None;
447                }
448                if let Some(slot) = self.active_slot(msg.mac, now) {
449                    self.free(slot);
450                    log::info!("DHCP RELEASE for {:02X?}", msg.mac);
451                }
452                None
453            }
454            MSG_INFORM => Some(Action {
455                kind: MSG_ACK,
456                yiaddr: Ipv4Address::UNSPECIFIED,
457                ciaddr: msg.ciaddr,
458                with_config: false,
459            }),
460            _ => None,
461        }
462    }
463
464    /// `REQUEST`: confirm a lease (`ACK`) or send the client back to
465    /// `DISCOVER` (`NAK`).
466    fn handle_request(&mut self, msg: &ClientMsg, now: Instant) -> Option<Action> {
467        // Offers from another server are none of our business.
468        if let Some(sid) = msg.server_id
469            && sid != AP_IP
470        {
471            return None;
472        }
473        // Pre-configured clients put the address in `requested_ip`,
474        // renewing ones in `ciaddr`.
475        let requested = if let Some(ip) = msg.requested_ip {
476            Some(ip)
477        } else if msg.ciaddr.is_unspecified() {
478            None
479        } else {
480            Some(msg.ciaddr)
481        };
482
483        let (kind, yiaddr) = match (self.active_slot(msg.mac, now), requested) {
484            (Some(slot), Some(req)) => {
485                if Self::pool_addr(slot) == Some(req) {
486                    self.refresh(slot, now);
487                    (MSG_ACK, req)
488                } else {
489                    (MSG_NAK, Ipv4Address::UNSPECIFIED)
490                }
491            }
492            (Some(_) | None, None) => (MSG_NAK, Ipv4Address::UNSPECIFIED),
493            (None, Some(req)) => {
494                if self.bind_addr(msg.mac, req, now) {
495                    (MSG_ACK, req)
496                } else {
497                    (MSG_NAK, Ipv4Address::UNSPECIFIED)
498                }
499            }
500        };
501        if kind == MSG_NAK {
502            log::warn!("DHCP NAK for {:02X?} (requested {requested:?})", msg.mac);
503        } else {
504            log::info!("DHCP ACK {yiaddr} for {:02X?}", msg.mac);
505        }
506        Some(Action {
507            kind,
508            yiaddr,
509            ciaddr: Ipv4Address::UNSPECIFIED,
510            with_config: kind == MSG_ACK,
511        })
512    }
513}
514
515/// Assemble a reply packet; returns the number of bytes used.
516fn build_reply(buf: &mut [u8], msg: &ClientMsg, action: &Action) -> Option<usize> {
517    buf.fill(0);
518    let mut w = Writer::new(buf);
519    w.u8(BOOTREPLY)?;
520    w.u8(HTYPE_ETHERNET)?;
521    w.u8(HLEN_ETHERNET)?;
522    w.u8(0)?;
523    w.u32(msg.xid)?;
524    w.u16(0)?;
525    w.u16(msg.flags)?;
526    w.ipv4(action.ciaddr)?;
527    w.ipv4(action.yiaddr)?;
528    w.ipv4(Ipv4Address::UNSPECIFIED)?;
529    w.ipv4(msg.giaddr)?;
530    w.put(&msg.mac)?;
531    w.zeros(10)?;
532    w.zeros(64)?;
533    w.zeros(128)?;
534    w.u32(MAGIC_COOKIE)?;
535
536    w.opt(OPT_MESSAGE_TYPE, &[action.kind])?;
537    w.opt(OPT_SERVER_ID, &AP_IP.octets())?;
538    if action.with_config {
539        let lease = u32::try_from(LEASE_SECS).ok()?;
540        w.opt(OPT_LEASE_TIME, &lease.to_be_bytes())?;
541        w.opt(OPT_SUBNET_MASK, &NETMASK)?;
542        w.opt(OPT_ROUTER, &AP_IP.octets())?;
543        w.opt(OPT_DNS, &AP_IP.octets())?;
544    }
545    w.u8(OPT_END)?;
546    Some(w.pos)
547}
548
549/// DHCP server task: answers client requests on the access-point
550/// interface until reboot. Spawned by [`super::task`] after link-up.
551#[embassy_executor::task]
552pub async fn task(stack: Stack<'static>) {
553    let mut rx_meta = [PacketMetadata::EMPTY; 4];
554    let mut tx_meta = [PacketMetadata::EMPTY; 4];
555    let mut rx_ring = [0u8; 1024];
556    let mut tx_ring = [0u8; 512];
557    let mut socket = UdpSocket::new(
558        stack,
559        &mut rx_meta,
560        &mut rx_ring,
561        &mut tx_meta,
562        &mut tx_ring,
563    );
564    if let Err(err) = socket.bind(SERVER_PORT) {
565        log::error!("DHCP server: bind({SERVER_PORT}) failed: {err:?}");
566        return;
567    }
568    let [net0, net1, net2, _] = AP_IP.octets();
569    let base = Ipv4Address::new(net0, net1, net2, POOL_START);
570    log::info!("DHCP server up: {POOL_SIZE} leases from {base}");
571
572    let mut server = Server::new();
573    let mut pkt = [0u8; 1024];
574    let mut reply = [0u8; 512];
575    loop {
576        let (len, meta) = match socket.recv_from(&mut pkt).await {
577            Ok(received) => received,
578            Err(err) => {
579                log::warn!("DHCP: receive failed: {err:?}");
580                continue;
581            }
582        };
583        // Only IPv4 is enabled, so the pattern is irrefutable.
584        let IpAddress::Ipv4(sender) = meta.endpoint.addr;
585        log::debug!("DHCP rx {len}B from {sender}:{}", meta.endpoint.port);
586        let Some(data) = pkt.get(..len) else {
587            continue;
588        };
589        let Some(msg) = parse_client(data) else {
590            log::debug!("DHCP: ignored {len}B from {sender} (parse)");
591            continue;
592        };
593        let Some(action) = server.handle(&msg, Instant::now()) else {
594            continue;
595        };
596        let Some(used) = build_reply(&mut reply, &msg, &action) else {
597            continue;
598        };
599        let Some(packet) = reply.get(..used) else {
600            continue;
601        };
602
603        // Broadcast until the client owns an address; unicast renewals.
604        let dst = if msg.flags & FLAG_BROADCAST == 0 && !sender.is_unspecified() {
605            if action.yiaddr.is_unspecified() {
606                sender
607            } else {
608                action.yiaddr
609            }
610        } else {
611            Ipv4Address::BROADCAST
612        };
613        if let Err(err) = socket.send_to(packet, (dst, CLIENT_PORT)).await {
614            log::warn!("DHCP: send to {dst} failed: {err:?}");
615        }
616    }
617}