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