From 5b4d4a1075effa0774c8ba8c2382b990f7d729f3 Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Thu, 22 Jan 2026 23:02:15 -0500 Subject: [PATCH 1/3] feat: add string inputs for lease time --- example.yaml | 13 +++- libs/config/src/wire/mod.rs | 150 +++++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 5 deletions(-) diff --git a/example.yaml b/example.yaml index df88b5f..2427cb5 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..cfcb3c9 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 { @@ -106,4 +184,74 @@ mod tests { let s = serde_yaml::to_string(&cfg).unwrap(); println!("{s}"); } +<<<<<<< Updated upstream +======= + + #[test] + fn test_interface() { + let iface = Interface { + name: "eth0".to_string(), + addr: Some([192, 168, 1, 1].into()), + }; + + let s = serde_json::to_string(&iface).unwrap(); + assert_eq!(s, "\"eth0@192.168.1.1\""); + + let err = serde_json::from_str::("\"@192.168.1.1\""); + assert!(err.is_err()); + + let json_test: Interface = serde_json::from_str(&s).unwrap(); + assert_eq!(iface, json_test); + + let no_addr = Interface { + name: "lo".to_string(), + addr: None, + }; + let json_no_addr = serde_json::to_string(&no_addr).unwrap(); + assert_eq!(json_no_addr, "\"lo\""); + let test_no_addr: Interface = serde_json::from_str(&json_no_addr).unwrap(); + assert_eq!(no_addr, test_no_addr); + } + + #[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); + } +>>>>>>> Stashed changes } From a778e2b0f9d7044778d117d939f53f4c2cb2ba6d Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Thu, 22 Jan 2026 23:09:54 -0500 Subject: [PATCH 2/3] default lease_time and fix borked lines --- libs/config/src/wire/mod.rs | 30 +----------------------------- libs/config/src/wire/v4.rs | 5 ++++- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/libs/config/src/wire/mod.rs b/libs/config/src/wire/mod.rs index cfcb3c9..c6f0ad8 100644 --- a/libs/config/src/wire/mod.rs +++ b/libs/config/src/wire/mod.rs @@ -172,6 +172,7 @@ pub(crate) enum MaybeList { #[cfg(test)] mod tests { + use super::*; pub static EXAMPLE: &str = include_str!("../../../../example.yaml"); @@ -184,34 +185,6 @@ mod tests { let s = serde_yaml::to_string(&cfg).unwrap(); println!("{s}"); } -<<<<<<< Updated upstream -======= - - #[test] - fn test_interface() { - let iface = Interface { - name: "eth0".to_string(), - addr: Some([192, 168, 1, 1].into()), - }; - - let s = serde_json::to_string(&iface).unwrap(); - assert_eq!(s, "\"eth0@192.168.1.1\""); - - let err = serde_json::from_str::("\"@192.168.1.1\""); - assert!(err.is_err()); - - let json_test: Interface = serde_json::from_str(&s).unwrap(); - assert_eq!(iface, json_test); - - let no_addr = Interface { - name: "lo".to_string(), - addr: None, - }; - let json_no_addr = serde_json::to_string(&no_addr).unwrap(); - assert_eq!(json_no_addr, "\"lo\""); - let test_no_addr: Interface = serde_json::from_str(&json_no_addr).unwrap(); - assert_eq!(no_addr, test_no_addr); - } #[test] fn test_parse_duration() { @@ -253,5 +226,4 @@ mod tests { assert_eq!(minmax.min.unwrap().get(), 1200); assert_eq!(minmax.max.unwrap().get(), 7200); } ->>>>>>> Stashed changes } 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, } From 7cc32a10cb9325cbb4521f418d3295c580cc3f83 Mon Sep 17 00:00:00 2001 From: Evan Cameron Date: Thu, 22 Jan 2026 23:15:41 -0500 Subject: [PATCH 3/3] accept defaults correctly --- example.yaml | 8 ++++---- libs/config/src/wire/mod.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/example.yaml b/example.yaml index 2427cb5..6c710c0 100644 --- a/example.yaml +++ b/example.yaml @@ -85,14 +85,14 @@ networks: # configured lease time: # Values can be specified as: # - Plain numbers (assumed to be seconds): 3600, 86400 - # - Strings with time units: "1h", "60m", "3600s", "24h" + # - 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 # or "1h" - min: 1200 # or "20m" - max: 4800 # or "80m" + 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 c6f0ad8..e100066 100644 --- a/libs/config/src/wire/mod.rs +++ b/libs/config/src/wire/mod.rs @@ -104,7 +104,7 @@ impl From for LeaseTime { /// If no unit is specified, assumes seconds fn parse_duration(s: &str) -> Result { let s = s.trim(); - if !s.is_empty() { + if s.is_empty() { return Err(anyhow::Error::msg("empty duration string")); }