Skip to main content

toe_beans/v4/server/leases/
mod.rs

1mod config;
2mod lease_time;
3
4pub use config::*;
5pub use lease_time::*;
6
7use crate::v4::error::Result;
8use ip_network::Ipv4Network;
9use jiff::Zoned;
10use log::{debug, error, info, warn};
11use mac_address::MacAddress;
12use std::fmt::Display;
13use std::fs::{File, OpenOptions};
14use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
15use std::str::FromStr;
16use std::{
17    collections::{HashMap, HashSet},
18    net::Ipv4Addr,
19};
20
21/// How much space is used by each lease in the leases file.
22const PAGE_SIZE: usize = 200;
23
24#[derive(Clone, Debug)]
25enum LeaseStatus {
26    Offered,
27    Acked,
28}
29
30impl Display for LeaseStatus {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            LeaseStatus::Offered => write!(f, "Offered"),
34            LeaseStatus::Acked => write!(f, "Acked"),
35        }
36    }
37}
38
39impl From<&str> for LeaseStatus {
40    fn from(value: &str) -> Self {
41        match value {
42            "Offered" => Self::Offered,
43            "Acked" => Self::Acked,
44            _ => panic!("Unknown value used for LeaseStatus"),
45        }
46    }
47}
48
49#[derive(Clone, Debug)]
50struct Lease {
51    /// What line in the leases file this lease occupies.
52    /// Where 0 indicates it has not been assigned a line yet.
53    line: u32,
54    /// What ip address was assigned
55    ip: Ipv4Addr,
56    status: LeaseStatus,
57    is_static: bool,
58    /// A UTC time with time zone for when the lease was assigned/extended.
59    when: Zoned,
60}
61
62impl Lease {
63    #[inline]
64    fn new(ip: Ipv4Addr, status: LeaseStatus, is_static: bool) -> Self {
65        Self {
66            line: 0,
67            ip,
68            status,
69            is_static,
70            when: Zoned::now(),
71        }
72    }
73
74    /// Get back the entry that maps the mac address to the lease
75    /// The passed string must exactly match the expected format.
76    fn from_string(string: String, line: u32) -> Result<(MacAddress, Lease)> {
77        let mut parts = string.split(',');
78        // TODO handle unwraps
79        let owner = MacAddress::from_str(parts.next().unwrap()).unwrap();
80        let ip = Ipv4Addr::from_str(parts.next().unwrap()).unwrap();
81        let status = LeaseStatus::from(parts.next().unwrap());
82        let is_static = parts.next().unwrap().parse().unwrap();
83
84        // The last value might have trailing spaces
85        let when = Zoned::from_str(parts.next().unwrap().trim_end()).unwrap();
86
87        let lease = Self {
88            line,
89            ip,
90            status,
91            is_static,
92            when,
93        };
94        Ok((owner, lease))
95    }
96
97    /// Resets the time that this was considered leased.
98    #[inline]
99    fn extend(&mut self) {
100        self.when = Zoned::now();
101    }
102
103    /// If the elapsed time since the assignment of the lease exceeds
104    /// the allowed lease time then the lease is expired.
105    fn is_expired(&self, lease_time: u32) -> bool {
106        let elapsed = self.when.duration_until(&Zoned::now());
107        elapsed.as_secs() >= lease_time as i64
108    }
109
110    fn to_string(&self, owner: MacAddress) -> String {
111        // Some parts of this string are variable, but let's assume 100 for now.
112        let mut string = String::with_capacity(PAGE_SIZE);
113
114        string.push_str(&owner.to_string());
115        string.push(',');
116        string.push_str(&self.ip.to_string()); // variable
117        string.push(',');
118        string.push_str(&self.status.to_string()); // variable
119        string.push(',');
120        string.push_str(&self.is_static.to_string()); // variable
121        string.push(',');
122        string.push_str(&self.when.to_string()); // variable
123
124        string
125    }
126}
127
128/// Stores the leases and other information used across leasing functions in memory.
129///
130/// The leases are also written to a file on disk to preserve state in the event that something, such as a power outage, causes the server to stop.
131/// This file is attempted to be read, parsed, and validated during `Leases` creation during `Server` start. After which it is only written to in order to sync it with its in-memory state.
132/// Therefore, changing the file when the server is running will have no effect and may impact syncing to the file correctly.
133#[derive(Debug)]
134pub struct Leases {
135    available: HashSet<Ipv4Addr>,
136    leased: HashMap<MacAddress, Lease>,
137    /// Passed to Leases from LeaseConfig.lease_time, so Leases can check for lease expiration.
138    lease_time: u32,
139    /// Keeps track of the number of lines written to the leases file.
140    lines: u32,
141    /// All configuration for `Leases`.
142    config: LeaseConfig,
143    /// A handle to the leases file.
144    /// Will always be Some after `::new()` if `LeasesConfig.use_leases_file` is true.
145    /// This is kept open for the duration of a `Leases` which is usually the lifetime of the `Server`.
146    /// An exclusive file lock is attempted to reduce the chances that another process (or even another toe-beans server) will write to the same file. If you want to run multiple toe-beans servers then change the `LeasesConfig.leases_file_path` so that each writes to a different location.
147    file: Option<File>,
148}
149
150impl Leases {
151    /// The file name that the Leases is read from and written to.
152    pub const FILE_NAME: &'static str = "toe-beans.leases";
153
154    /// Offers only valid for about 5 minutes (300 seconds)
155    /// at which point the offer's IP address may be reused.
156    ///
157    /// "Because the servers have not committed any network address assignments"
158    /// "on the basis of a DHCPOFFER, server are free to reuse offered network addresses in response to subsequent requests."
159    /// "Servers SHOULD NOT reuse offered addresses and may use an implementation-specific timeout mechanism to decide when to reuse an offered address."
160    const OFFER_TIMEOUT: u32 = 300;
161
162    /// Once you pass a `LeaseConfig` to `Leases`, `Leases` privately owns it,
163    /// but you can use this fn to borrow an immutable reference.
164    #[inline]
165    pub fn get_config(&self) -> &LeaseConfig {
166        &self.config
167    }
168
169    fn empty(config: &LeaseConfig) -> Self {
170        let size = config.network_cidr.hosts().len();
171        Self {
172            available: HashSet::with_capacity(size),
173            leased: HashMap::with_capacity(size),
174            lease_time: config.lease_time.as_server(),
175            config: LeaseConfig::default(),
176            lines: 0,
177            file: None,
178        }
179    }
180
181    /// Depending on whether the passed config enables `use_leases_file`:
182    /// Restore and validate the leases file or create a new, empty `Leases`.
183    pub fn new(config: LeaseConfig) -> Self {
184        let file_path = config.leases_file_path.join(Self::FILE_NAME);
185
186        let mut leases = if config.use_leases_file {
187            let mut leases = Self::read_leases(&config).unwrap_or_else(|_| Self::empty(&config));
188
189            let file = OpenOptions::new()
190                .write(true)
191                .create(true)
192                .truncate(false)
193                .open(file_path)
194                .expect("Problem opening leases file for writing");
195            file.lock().expect("Failed to acquire lock on leases file");
196            leases.file = Some(file);
197
198            leases
199        } else {
200            Self::empty(&config)
201        };
202
203        leases.config = config;
204        leases.fill_available();
205
206        leases
207    }
208
209    fn read_leases(config: &LeaseConfig) -> Result<Leases> {
210        info!("Trying to read, parse, and validate leases file");
211
212        let full_leases_file_path = config.leases_file_path.join(Self::FILE_NAME);
213        let open_result = File::open(full_leases_file_path);
214        let leased: HashMap<MacAddress, Lease> = match open_result {
215            Ok(file) => {
216                let mut line_num = 0;
217                BufReader::new(file)
218                    .lines()
219                    .map(|line| {
220                        let lease_string = line.expect("Problem reading leases file");
221                        line_num += 1;
222                        Lease::from_string(lease_string, line_num)
223                            .expect("Problem parsing leases file")
224                    })
225                    .collect()
226            }
227            Err(message) => {
228                error!("Problem opening leases file: {}", message);
229                return Err("Problem opening leases file");
230            }
231        };
232
233        let mut leases = Leases::empty(config);
234        leases.leased = leased;
235
236        leases.validate(&config.network_cidr)?;
237
238        info!("Restored and validated leases file");
239
240        Ok(leases)
241    }
242
243    /// Checks that both `static_leases` and `leased`:
244    /// 1. Only contain ip addresses in the configured network range
245    /// 2. Don't contain the broadcast or network address (which are unassignable)
246    #[inline]
247    fn validate(&self, network: &Ipv4Network) -> Result<()> {
248        let broadcast_address = network.broadcast_address();
249        let network_address = network.network_address();
250        let static_lease_ips = self.config.static_leases.values();
251        let leased_ips = self.leased.values().map(|lease| &lease.ip);
252
253        static_lease_ips.chain(leased_ips).try_for_each(|ip| {
254            if !network.contains(*ip) {
255                return Err("The configured network range does not include the restored ip. Did the network range change?");
256            }
257
258            if ip == &broadcast_address {
259                return Err("The network's broadcast address can't be leased");
260            }
261
262            if ip == &network_address {
263                return Err("The network's network address can't be leased");
264            }
265
266            Ok(())
267        })?;
268
269        Ok(())
270    }
271
272    /// Only call this _after_ `leased` and `static_leases` are filled.
273    fn fill_available(&mut self) {
274        let network = self.config.network_cidr;
275        let hosts = network.hosts();
276
277        self.available.extend(hosts);
278
279        // Dont assign the broadcast or network addresses
280        self.available.remove(&network.broadcast_address());
281        self.available.remove(&network.network_address());
282
283        // Dont assign the already assigned addresses
284        self.leased.values().for_each(|lease| {
285            self.available.remove(&lease.ip);
286        });
287
288        // Dont assign the statically reserved addresses
289        self.config.static_leases.values().for_each(|ip| {
290            self.available.remove(ip);
291        });
292    }
293
294    /// Writes leases to persistent storage as toe-beans-leases.toml
295    fn commit(&mut self, mut lease: Lease, owner: MacAddress) -> Result<()> {
296        if !self.config.use_leases_file {
297            return Ok(());
298        }
299
300        // if line has not be assigned, assign next one:
301        if lease.line == 0 {
302            lease.line = self.lines + 1
303        }
304
305        // Convert string to a page of bytes for writing.
306        // If string is less than the page size then remaining characters are spaces.
307        let mut file_content: [u8; PAGE_SIZE] = [32; PAGE_SIZE];
308        lease
309            .to_string(owner)
310            .as_bytes()
311            .iter()
312            .enumerate()
313            .for_each(|(i, byte)| file_content[i] = *byte);
314        // The last character in a page is a line break.
315        file_content[PAGE_SIZE - 1] = 10;
316
317        if self
318            .file
319            .as_mut()
320            .expect("This should always be Some here")
321            .seek(SeekFrom::Start((PAGE_SIZE * lease.line as usize) as u64))
322            .is_err()
323        {
324            return Err("Problem writing to leases file");
325        };
326
327        if self
328            .file
329            .as_mut()
330            .expect("This should always be Some here")
331            .write_all(&file_content)
332            .is_err()
333        {
334            return Err("Problem writing to leases file");
335        };
336
337        self.lines += 1;
338
339        Ok(())
340    }
341
342    /// Takes an available IP address, marks it as offered, and returns it.
343    ///
344    /// Returns one of:
345    /// - The previously offered address, if one has been offered.
346    /// - The requested address:
347    ///   - If one was requested,
348    ///   - _and_ it is available,
349    ///   - _and_ there is not a static lease
350    /// - Otherwise any available address (unless none are available).
351    pub fn offer(&mut self, owner: MacAddress, requested_ip: Option<Ipv4Addr>) -> Result<Ipv4Addr> {
352        if let Some(lease) = self.leased.get_mut(&owner) {
353            match lease.status {
354                LeaseStatus::Offered => {
355                    warn!(
356                        "{} will be offered an IP address that it has already been offered",
357                        owner
358                    );
359                    lease.extend();
360                    return Ok(lease.ip);
361                }
362                LeaseStatus::Acked => {
363                    if !lease.is_expired(self.lease_time) {
364                        warn!(
365                            "{} will be offered its non-expired, acked IP address again",
366                            owner
367                        );
368                        lease.status = LeaseStatus::Offered;
369                        lease.extend();
370                        return Ok(lease.ip);
371                    }
372                    // else if it is expired then continue to offer new address below...
373                }
374            }
375        }
376
377        if let Some(requested_ip) = requested_ip {
378            let not_available = !self.available.contains(&requested_ip);
379            let has_static_lease = self.config.static_leases.contains_key(&owner);
380
381            if not_available {
382                // we validated that available and leased were in range in `restore`
383                debug!("Requested IP Address is not available (or maybe not in network range)");
384            } else if has_static_lease {
385                debug!("Requested IP Address ignored because owner has static lease");
386            } else {
387                self.available.remove(&requested_ip);
388                self.leased
389                    .insert(owner, Lease::new(requested_ip, LeaseStatus::Offered, false));
390                return Ok(requested_ip);
391            }
392        }
393
394        // else give an available ip address below...
395        let lease = self.get_lease(&owner, LeaseStatus::Offered)?;
396        let ip = lease.ip;
397        self.leased.insert(owner, lease);
398        Ok(ip)
399    }
400
401    /// Takes a chaddr's offered ip address and marks it as reserved
402    /// then commits it to persistent storage and returns the address.
403    pub fn ack(&mut self, owner: MacAddress) -> Result<Ipv4Addr> {
404        let maybe_lease = self.leased.get_mut(&owner);
405        let lease = match maybe_lease {
406            Some(lease) => {
407                match lease.status {
408                    LeaseStatus::Offered => {
409                        lease.status = LeaseStatus::Acked;
410                    }
411                    LeaseStatus::Acked => {
412                        warn!(
413                            "{} will be leased an IP address it was already leased",
414                            owner
415                        );
416                    }
417                };
418
419                lease.extend();
420                lease.to_owned()
421            }
422            None => {
423                // nothing was offered, but this might be a rapid commit
424                let lease = self.get_lease(&owner, LeaseStatus::Acked)?;
425                self.leased.insert(owner, lease.clone());
426                lease
427            }
428        };
429
430        let ip = lease.ip;
431        self.commit(lease, owner)?;
432
433        Ok(ip)
434    }
435
436    /// Resets the time of an offered or acked lease.
437    /// Used by the server in the lease renew/rebind process.
438    pub fn extend(&mut self, owner: MacAddress) -> Result<()> {
439        match self.leased.get_mut(&owner) {
440            Some(lease) => {
441                lease.extend();
442                Ok(())
443            }
444            None => Err("No IP address lease found with that owner"),
445        }
446    }
447
448    /// Makes an ip address that was offered or acked available again.
449    pub fn release(&mut self, owner: MacAddress) -> Result<()> {
450        let maybe_leased = self.leased.remove(&owner);
451
452        match maybe_leased {
453            Some(lease) => {
454                if !lease.is_static {
455                    self.available.insert(lease.ip);
456                }
457                Ok(())
458            }
459            None => Err("No IP address lease found with that owner"),
460        }
461    }
462
463    /// Returns either a lease with a static address or one with the next available address
464    fn get_lease(&mut self, owner: &MacAddress, status: LeaseStatus) -> Result<Lease> {
465        let lease = match self.config.static_leases.get(owner) {
466            Some(ip) => Lease::new(*ip, status, true),
467            None => Lease::new(self.get_ip()?, status, false),
468        };
469        Ok(lease)
470    }
471
472    /// Returns the next available address.
473    /// If there are no more available addresses, then it will:
474    /// 1. Search for offered addresses older than 1 minute
475    /// 2. Search for acked addresses that have expired.
476    fn get_ip(&mut self) -> Result<Ipv4Addr> {
477        if let Some(any) = self.available.iter().next().cloned() {
478            debug!("Chose available IP address {any}");
479            // UNWRAP is okay because we've found this element above
480            return Ok(self.available.take(&any).unwrap());
481        }
482
483        let maybe_expired_lease = self
484            .leased
485            .clone() // TODO slow
486            .into_iter() // TODO slow
487            .find(|(_owner, lease)| {
488                let expiration = match lease.status {
489                    LeaseStatus::Offered => Self::OFFER_TIMEOUT,
490                    LeaseStatus::Acked => self.lease_time,
491                };
492                lease.is_expired(expiration)
493            });
494
495        if let Some(expired_lease) = maybe_expired_lease {
496            debug!("Reusing expired lease's IP address");
497            // UNWRAP is okay because we've found this element above
498            return Ok(self.leased.remove(&expired_lease.0).unwrap().ip);
499        }
500
501        Err("No more IP addresses available")
502    }
503
504    /// Checks whether the passed IP address is available in the pool.
505    pub fn is_available(&self, ip: &Ipv4Addr) -> bool {
506        self.available.contains(ip)
507    }
508
509    /// Checks if the passed IP address matches the committed, leased IP address,
510    /// and that the lease is not expired.
511    pub fn verify_lease(&self, owner: MacAddress, ip: &Ipv4Addr) -> Result<()> {
512        let maybe_leased = self.leased.get(&owner);
513
514        match maybe_leased {
515            Some(lease) => {
516                if let LeaseStatus::Offered = lease.status {
517                    return Err("Lease offered but not previously acked");
518                }
519
520                if &lease.ip != ip {
521                    return Err("Client's notion of ip address is wrong");
522                }
523
524                if lease.is_expired(self.lease_time) {
525                    return Err("Lease has expired");
526                }
527
528                Ok(())
529            }
530            None => Err("No IP address lease found with that owner"),
531        }
532    }
533}