toe_beans/v4/server/
ip_pool.rs

1use super::Config;
2use crate::v4::error::Result;
3use ipnetwork::Ipv4Network;
4use log::{debug, info, warn};
5use mac_address::MacAddress;
6use serde::{Deserialize, Serialize};
7use std::fs::read_to_string;
8use std::io::Write;
9use std::os::unix::fs::OpenOptionsExt;
10use std::path::PathBuf;
11use std::time::{Duration, SystemTime};
12use std::{
13    collections::{HashMap, HashSet},
14    fs::OpenOptions,
15    net::Ipv4Addr,
16};
17
18#[derive(Deserialize, Serialize, Clone)]
19struct Lease {
20    ip: Ipv4Addr,
21    // TODO this time measurement is not monotonic.
22    when: SystemTime,
23}
24
25/// Used to manage the dynamic collection of IP addresses that the server leases.
26// TODO Use a CIDR Trie for this?
27#[derive(Deserialize, Serialize)]
28pub struct IpPool {
29    #[serde(skip)]
30    available: HashSet<Ipv4Addr>,
31    #[serde(skip)]
32    offered: HashMap<MacAddress, Lease>,
33    acked: HashMap<MacAddress, Lease>,
34    /// The server will always assign these ip adddresses to these mac addresses.
35    /// All static leases must be within the network_cidr range.
36    static_leases: HashMap<MacAddress, Ipv4Addr>,
37    /// Passed to IpPool from Config.network_cidr.
38    network: Ipv4Network,
39    /// Passed to IpPool from Config.lease_time, so IpPool check for lease expiration.
40    lease_time: u32,
41    #[serde(skip)]
42    /// Passed to IpPool from Config.path because they share a single directory.
43    file_path: PathBuf,
44}
45
46impl IpPool {
47    /// The file name that the IpPool is read from and written to.
48    pub const FILE_NAME: &'static str = "toe-beans-leases.toml";
49
50    /// Create a pool with the capacity of the configured network's size.
51    pub fn new(config: &Config) -> Self {
52        let network = config.network_cidr;
53        let size = network.size() as usize;
54        let mut available = HashSet::with_capacity(size);
55        available.extend(network.iter());
56        let offered = HashMap::with_capacity(size);
57        let acked = HashMap::with_capacity(size);
58        let static_leases = HashMap::new();
59
60        Self {
61            file_path: config.path.clone(),
62            available,
63            offered,
64            acked,
65            static_leases,
66            network,
67            lease_time: config.lease_time.as_server(),
68        }
69    }
70
71    /// Restores the state of an IpPool from a toe-beans-leases.toml
72    /// as long as the IpPool's network (range/capacity) has not changed.
73    /// Static leases are verified to be within network range.
74    ///
75    /// This does not restore dhcp offers.
76    pub fn restore(config: &Config) -> Result<Self> {
77        let network = config.network_cidr;
78        let read_result = read_to_string(config.path.join(Self::FILE_NAME));
79
80        let mut ip_pool: IpPool = match read_result {
81            Ok(toml_string) => {
82                let toml_result = toml::from_str(&toml_string);
83                match toml_result {
84                    Ok(ip_pool) => ip_pool,
85                    Err(_) => return Err("Problem parsing leases file"),
86                }
87            }
88            Err(_) => {
89                return Err("Problem reading leases file");
90            }
91        };
92
93        if network != ip_pool.network {
94            return Err(
95                "The restored and configured IP address pool's network range/capacity do not match",
96            );
97        }
98
99        ip_pool.static_leases.values().try_for_each(|static_ip| {
100            if !network.contains(*static_ip) {
101                return Err("The configured network range does not include the static ip");
102            }
103
104            Ok(())
105        })?;
106
107        ip_pool.available.extend(network.iter());
108
109        // TODO remove restored "acked" from "available"
110
111        info!("Restored IP address leases");
112        Ok(ip_pool)
113    }
114
115    /// Use the toe-beans-leases.toml to restore past leases
116    /// or return a new IpPool if that was not successful.
117    pub fn restore_or_new(config: &Config) -> Self {
118        IpPool::restore(config).unwrap_or_else(|_| IpPool::new(config))
119    }
120
121    /// Writes leases to persistent storage as toe-beans-leases.toml
122    fn commit(&self) -> Result<()> {
123        debug!("Writing {}", Self::FILE_NAME);
124
125        let file_content = match toml::to_string_pretty(&self) {
126            Ok(content) => content,
127            Err(_) => return Err("Failed to generate toml data"),
128        };
129
130        let open_result = OpenOptions::new()
131            .read(false)
132            .write(true)
133            .create(true)
134            .truncate(true)
135            .mode(0o644) // ensure that file permissions are set before creating file
136            .open(self.file_path.join(Self::FILE_NAME));
137
138        let mut file = match open_result {
139            Ok(file) => file,
140            Err(_) => return Err("Failed to open file for writing"),
141        };
142
143        match file.write_all(file_content.as_bytes()) {
144            Ok(_) => Ok(()),
145            Err(_) => Err("Failed to write to file"),
146        }
147    }
148
149    /// Takes an available IP address, marks it as offered, and returns it.
150    ///
151    /// Returns one of:
152    /// - The previously offered address, if one has been offered.
153    /// - The requested address:
154    ///   - If one was requested,
155    ///   - _and_ it is in the pool's range,
156    ///   - _and_ it is available
157    /// - Otherwise any available address (unless none are available).
158    pub fn offer(&mut self, owner: MacAddress, requested_ip: Option<Ipv4Addr>) -> Result<Ipv4Addr> {
159        if self.acked.contains_key(&owner) && !self.is_expired(&owner)? {
160            return Err("This device already has already been leased a non-expired IP address");
161        }
162
163        if let Some(offered) = self.offered.get(&owner) {
164            warn!(
165                "{} will be offered an IP address that it has already been offered",
166                owner
167            );
168            return Ok(offered.ip);
169        }
170
171        if let Some(requested_ip) = requested_ip {
172            let ip_in_pool = self.network.contains(requested_ip);
173            let not_available = !self.available.contains(&requested_ip);
174            let has_static_lease = self.static_leases.contains_key(&owner);
175
176            if !ip_in_pool {
177                debug!("Requested IP Address is not in network range");
178            } else if not_available {
179                debug!("Requested IP Address is not available");
180            } else if has_static_lease {
181                debug!("Requested IP Address ignored because owner has static lease");
182            } else {
183                self.available.remove(&requested_ip);
184                self.offered.insert(
185                    owner,
186                    Lease {
187                        ip: requested_ip,
188                        when: SystemTime::now(),
189                    },
190                );
191                return Ok(requested_ip);
192            }
193        }
194
195        // else give an available ip address below...
196        let ip = self.find_available_ip(&owner)?;
197        self.offered.insert(
198            owner,
199            Lease {
200                ip,
201                when: SystemTime::now(),
202            },
203        );
204        Ok(ip)
205    }
206
207    /// Takes a chaddr's offered ip address and marks it as reserved
208    /// then commits it to persistent storage and returns the address.
209    ///
210    /// Currently only one IP is allowed per chaddr.
211    pub fn ack(&mut self, owner: MacAddress) -> Result<Ipv4Addr> {
212        if self.acked.contains_key(&owner) && !self.is_expired(&owner)? {
213            return Err("This device already has already been leased a non-expired IP address");
214        }
215
216        let maybe_offered_ip = self.offered.remove(&owner);
217        let ip = match maybe_offered_ip {
218            Some(lease) => lease.ip,
219            None => {
220                // nothing was offered, but this might be a rapid commit
221                self.find_available_ip(&owner)?
222            }
223        };
224
225        self.acked.insert(
226            owner,
227            Lease {
228                ip,
229                when: SystemTime::now(),
230            },
231        );
232
233        self.commit()?;
234
235        Ok(ip)
236    }
237
238    /// Resets the time of an acked lease. Used in the lease renew/rebind process.
239    pub fn extend(&mut self, owner: MacAddress, ip: Ipv4Addr) -> Result<()> {
240        match self.acked.insert(
241            owner,
242            Lease {
243                ip,
244                when: SystemTime::now(),
245            },
246        ) {
247            Some(_) => Ok(()),
248            None => Err("No IP address lease found with that owner"),
249        }
250    }
251
252    /// Makes an ip address that was reserved during an offer available again.
253    pub fn unoffer(&mut self, owner: MacAddress) -> Result<()> {
254        let maybe_offered = self.offered.remove(&owner);
255
256        match maybe_offered {
257            Some(lease) => {
258                self.available.insert(lease.ip);
259                Ok(())
260            }
261            None => Err("No IP address offer found to unoffer"),
262        }
263    }
264
265    /// Makes an ip address that was acked available again.
266    // TODO require IP address to release as well?
267    pub fn release(&mut self, owner: MacAddress) -> Result<()> {
268        let maybe_leased = self.acked.remove(&owner);
269
270        match maybe_leased {
271            Some(lease) => {
272                self.available.insert(lease.ip);
273                Ok(())
274            }
275            None => Err("No IP address lease found with that owner"),
276        }
277    }
278
279    /// Returns a static ip address or gets the next available address.
280    /// If there are no more available addresses, then it will:
281    /// 1. Search for offered addresses older than 1 minute
282    /// 2. Search for acked addresses that have expired.
283    fn find_available_ip(&mut self, owner: &MacAddress) -> Result<Ipv4Addr> {
284        if let Some(ip) = self.static_leases.get(owner) {
285            return match self.available.take(ip) {
286                Some(ip) => Ok(ip),
287                None => Err("A static lease was found but is not available"),
288            };
289        }
290
291        if let Some(any) = self.available.iter().next().cloned() {
292            debug!("Chose available IP address {any}");
293            // UNWRAP is okay because we've found this element above
294            return Ok(self.available.take(&any).unwrap());
295        }
296
297        // "Because the servers have not committed any network address assignments"
298        // "on the basis of a DHCPOFFER, server are free to reuse offered network addresses in response to subsequent requests."
299        // "Servers SHOULD NOT reuse offered addresses and may use an implementation-specific timeout mechanism to decide when to reuse an offered address."
300        //
301        // TODO collect all expired offers into a Vec and add them back to available except for the last which should be returned instead.
302        let maybe_expired_offer = self
303            .offered
304            .clone() // TODO slow
305            .into_iter() // TODO slow
306            .find(|offer| {
307                let elapsed_result = offer.1.when.elapsed();
308                match elapsed_result {
309                    Ok(elapsed) => elapsed > Duration::from_secs(300),
310                    Err(_) => false,
311                }
312            });
313
314        if let Some(expired_offer) = maybe_expired_offer {
315            debug!("Found an expired offer's IP address");
316            // UNWRAP is okay because we've found this element above
317            return Ok(self.offered.remove(&expired_offer.0).unwrap().ip);
318        }
319
320        // TODO collect all expired acks into a Vec and add them back to available except for the last which should be returned instead.
321        let maybe_expired_ack = self
322            .acked
323            .clone() // TODO slow
324            .into_iter() // TODO slow
325            .find(|ack| {
326                let elapsed_result = ack.1.when.elapsed();
327                match elapsed_result {
328                    Ok(elapsed) => elapsed > Duration::from_secs(300),
329                    Err(_) => false,
330                }
331            });
332
333        if let Some(expired_ack) = maybe_expired_ack {
334            debug!("Found an expired ack's IP address");
335            // UNWRAP is okay because we've found this element above
336            return Ok(self.acked.remove(&expired_ack.0).unwrap().ip);
337        }
338
339        Err("No more IP addresses available")
340    }
341
342    /// Checks whether the passed IP address is available in the pool.
343    pub fn is_available(&self, ip: &Ipv4Addr) -> bool {
344        self.available.contains(ip)
345    }
346
347    /// Checks if the passed IP address matches the committed, leased IP address,
348    /// and that the lease is not expired.
349    pub fn verify_lease(&self, owner: MacAddress, ip: &Ipv4Addr) -> Result<()> {
350        let maybe_leased = self.acked.get(&owner);
351
352        match maybe_leased {
353            Some(lease) => {
354                if &lease.ip != ip {
355                    return Err("Client's notion of ip address is wrong");
356                }
357
358                if self.is_expired(&owner)? {
359                    return Err("Lease has expired");
360                }
361
362                Ok(())
363            }
364            None => Err("No IP address lease found with that owner"),
365        }
366    }
367
368    /// Looks up a lease, if one is found, and checks if it has expired.
369    pub fn is_expired(&self, owner: &MacAddress) -> Result<bool> {
370        let maybe_leased = self.acked.get(owner);
371
372        match maybe_leased {
373            Some(lease) => match lease.when.elapsed() {
374                Ok(elapsed) => Ok(elapsed >= Duration::from_secs(self.lease_time as u64)),
375                Err(_) => Err("Problem getting elapsed system time"),
376            },
377            None => Err("No IP address lease found with that owner"),
378        }
379    }
380}