diff --git a/example.yaml b/example.yaml index df88b5f..6c710c0 100644 --- a/example.yaml +++ b/example.yaml @@ -82,12 +82,17 @@ networks: start: 192.168.5.2 # end of your range end: 192.168.5.250 - # configured lease time (only `default` is required) + # configured lease time: + # Values can be specified as: + # - Plain numbers (assumed to be seconds): 3600, 86400 + # - Strings with time units: 1h, 60m, 3600s, 24h + # Supported units: h (hours), m (minutes), s (seconds) + # Defaults: default=24h (86400s), min=20m (1200s), max=7d (604800s) config: lease_time: - default: 3600 - min: 1200 - max: 4800 + default: 3600 # or 1h + min: 1200 # or 20m + max: 4800 # or 80m # Both reservations & ranges can include an options map, if an incoming dhcp msg gets # an IP from that reservation or range, it will also use the corresponding `options` # to respond to any parameter request list values. diff --git a/libs/config/src/wire/mod.rs b/libs/config/src/wire/mod.rs index a07f283..e100066 100644 --- a/libs/config/src/wire/mod.rs +++ b/libs/config/src/wire/mod.rs @@ -1,7 +1,8 @@ use std::{collections::HashMap, num::NonZeroU32, time::Duration}; +use anyhow::{Context, Result}; use ipnet::Ipv4Net; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, de}; use crate::{LeaseTime, wire::client_classes::ClientClasses}; @@ -37,8 +38,11 @@ pub struct FloodThreshold { #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] pub struct MinMax { + #[serde(deserialize_with = "deserialize_duration")] pub default: NonZeroU32, + #[serde(default, deserialize_with = "deserialize_optional_duration")] pub min: Option, + #[serde(default, deserialize_with = "deserialize_optional_duration")] pub max: Option, } @@ -70,6 +74,16 @@ pub fn default_cache_threshold() -> u32 { 0 } +impl Default for MinMax { + fn default() -> Self { + Self { + default: NonZeroU32::new(86400).unwrap(), // 24 hours + min: Some(NonZeroU32::new(1200).unwrap()), // 20 minutes + max: Some(NonZeroU32::new(604800).unwrap()), // 7 days + } + } +} + impl From for LeaseTime { fn from(lease_time: MinMax) -> Self { let default = Duration::from_secs(lease_time.default.get() as u64); @@ -85,6 +99,70 @@ impl From for LeaseTime { } } +/// Parse a duration string with optional time units +/// Accepts: "3600", "3600s", "60m", "24h" +/// If no unit is specified, assumes seconds +fn parse_duration(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err(anyhow::Error::msg("empty duration string")); + } + + let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + // split units + let (num, unit) = s.split_at(end); + let num = num.parse::().context("invalid number")?; + + let num_seconds = match unit.trim() { + "" | "s" => 1, + "m" => 60, + "h" => 3600, + other => anyhow::bail!( + "unknown time unit '{}', only 'h', 'm', or 's' are supported", + other + ), + }; + + num.checked_mul(num_seconds) + .context("duration value overflow") +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum LeaseDuration { + Seconds(u64), + String(String), +} + +impl LeaseDuration { + fn into_nonzero(self) -> Result { + match self { + LeaseDuration::Seconds(val) => NonZeroU32::new( + u32::try_from(val).map_err(|_| E::custom("duration value too large"))?, + ) + .ok_or_else(|| E::custom("duration cannot be zero")), + LeaseDuration::String(s) => NonZeroU32::new(parse_duration(&s).map_err(E::custom)?) + .ok_or_else(|| E::custom("duration cannot be zero")), + } + } +} + +fn deserialize_duration<'de, D>(de: D) -> Result +where + D: Deserializer<'de>, +{ + LeaseDuration::deserialize(de)?.into_nonzero() +} + +fn deserialize_optional_duration<'de, D>(de: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(de)? + .map(LeaseDuration::into_nonzero) + .transpose() +} + #[derive(Serialize, Deserialize, Debug)] #[serde(untagged)] pub(crate) enum MaybeList { @@ -94,6 +172,7 @@ pub(crate) enum MaybeList { #[cfg(test)] mod tests { + use super::*; pub static EXAMPLE: &str = include_str!("../../../../example.yaml"); @@ -106,4 +185,45 @@ mod tests { let s = serde_yaml::to_string(&cfg).unwrap(); println!("{s}"); } + + #[test] + fn test_parse_duration() { + assert_eq!(parse_duration("3600s").unwrap(), 3600); + assert_eq!(parse_duration("60s").unwrap(), 60); + assert_eq!(parse_duration("1s").unwrap(), 1); + + assert_eq!(parse_duration("60m").unwrap(), 3600); + assert_eq!(parse_duration("1m").unwrap(), 60); + assert_eq!(parse_duration("90m").unwrap(), 5400); + + assert_eq!(parse_duration("24h").unwrap(), 86400); + assert_eq!(parse_duration("1h").unwrap(), 3600); + assert_eq!(parse_duration("48h").unwrap(), 172800); + } + + #[test] + fn test_parse_duration_invalid_unit() { + assert!(parse_duration("60d").is_err()); + assert!(parse_duration("60w").is_err()); + assert!(parse_duration("60x").is_err()); + assert!(parse_duration("60mins").is_err()); + } + + #[test] + fn test_minmax() { + let json = r#"{"default": 3600, "min": 1200, "max": 7200}"#; + let minmax: MinMax = serde_json::from_str(json).unwrap(); + assert_eq!(minmax.default.get(), 3600); + assert_eq!(minmax.min.unwrap().get(), 1200); + assert_eq!(minmax.max.unwrap().get(), 7200); + } + + #[test] + fn test_minmax_strings() { + let json = r#"{"default": "1h", "min": "20m", "max": "2h"}"#; + let minmax: MinMax = serde_json::from_str(json).unwrap(); + assert_eq!(minmax.default.get(), 3600); + assert_eq!(minmax.min.unwrap().get(), 1200); + assert_eq!(minmax.max.unwrap().get(), 7200); + } } diff --git a/libs/config/src/wire/v4.rs b/libs/config/src/wire/v4.rs index 2fd793e..0e048af 100644 --- a/libs/config/src/wire/v4.rs +++ b/libs/config/src/wire/v4.rs @@ -90,14 +90,16 @@ pub struct IpRange { #[serde(flatten)] pub range: RangeInclusive, pub options: Options, + #[serde(default)] pub config: NetworkConfig, #[serde(default)] pub except: Vec, pub class: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] pub struct NetworkConfig { + #[serde(default)] pub lease_time: MinMax, } @@ -130,6 +132,7 @@ pub struct ReservedIp { pub options: Options, #[serde(rename = "match")] pub condition: Condition, + #[serde(default)] pub config: NetworkConfig, pub class: Option, }