//! `dhcp.conf` configuration file support (zero dependencies). //! //! All server settings — previously hard-coded constants in //! `examples/server.rs` — now live in a single `dhcp.conf` file using a //! minimal `key = value` format: //! //! ```text //! # lines starting with '#' or ';' are comments; blank lines are ignored //! server_ip = 192.168.2.1 //! dns_ips = 8.8.8.8, 8.8.4.4 //! ``` //! //! Rules (locked by `tests/test_config.rs`): //! - keys are case-insensitive; surrounding whitespace is trimmed; //! - a trailing ` # comment` (whitespace + `#`) is stripped from values; //! - duplicate keys: last occurrence wins; //! - unknown keys are ignored (forward compatibility); //! - a line without `=` (that is not blank/comment) is an error; //! - empty keys and empty values are errors; //! - every value is validated; failures name the key and line number. //! //! [`Config::defaults`] exactly matches the historical hard-coded constants, //! so running without a `dhcp.conf` behaves exactly as before. use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; /// All tunable server settings. See [`Config::defaults`] for the values the /// server historically used when everything was hard-coded. #[derive(Debug, Clone, PartialEq)] pub struct Config { // -- IPv4 (DHCPv4, RFC 2131) -- pub server_ip: Ipv4Addr, pub ip_start: Ipv4Addr, pub lease_num: u32, pub subnet_mask: Ipv4Addr, pub router_ip: Ipv4Addr, pub dns_ips: Vec, /// Master switch for advertising NTP servers (DHCP option 42). Off by /// default so a missing config file preserves old behavior exactly /// (the old server never sent option 42). pub enable_ntp: bool, /// NTP servers advertised to clients (option 42), comma-separated. /// Only sent when `enable_ntp` is true and the list is non-empty. pub ntp_ips: Vec, /// Master switch for DHCP Rapid Commit (RFC 4039 option 80 for IPv4, /// RFC 8415 option 14 for IPv6): when true, a Solicit/Discover carrying /// the rapid-commit option is answered immediately (Reply/Ack) instead /// of Advertise/Offer. Off by default so a missing config file preserves /// old behavior exactly. pub enable_rapid_commit: bool, pub broadcast_ip: Ipv4Addr, /// Extra broadcast destinations for replies (comma-separated). Every /// broadcast-routed reply is sent to each address in /// [`Config::broadcasts`]: some clients/APs only accept the limited /// broadcast `255.255.255.255`, others only the subnet directed /// broadcast — listing both reaches both populations. Unicast replies /// are unaffected (sent once to the peer). pub extra_broadcast_ips: Vec, pub lease_duration_secs: u32, pub leases_file: String, pub listen_addr: SocketAddr, // -- IPv6 managed range (DHCPv6, RFC 8415) -- pub enable_dhcpv6: bool, /// Server DUID served in SERVERID options (opaque bytes, 1..=128). pub server_duid: Vec, /// First address of the managed IPv6 pool. pub ipv6_start: Ipv6Addr, /// Number of addresses in the managed IPv6 pool. pub ipv6_lease_num: u64, pub ipv6_dns: Vec, pub ipv6_lease_duration_secs: u32, pub leases_file_v6: String, pub listen_addr_v6: SocketAddr, } impl Config { /// Defaults identical to the historical hard-coded constants /// (`examples/server.rs` before `dhcp.conf` existed). DHCPv6 is off by /// default so a missing config file preserves old behavior exactly. pub fn defaults() -> Config { Config { server_ip: Ipv4Addr::new(192, 168, 2, 1), ip_start: Ipv4Addr::new(192, 168, 2, 2), lease_num: 252, subnet_mask: Ipv4Addr::new(255, 255, 255, 0), router_ip: Ipv4Addr::new(192, 168, 2, 1), dns_ips: vec![Ipv4Addr::new(8, 8, 8, 8)], enable_ntp: false, ntp_ips: vec![], enable_rapid_commit: false, broadcast_ip: Ipv4Addr::new(192, 168, 2, 255), extra_broadcast_ips: vec![], lease_duration_secs: 86400, leases_file: "leases".to_string(), listen_addr: SocketAddr::from(([0, 0, 0, 0], 67)), enable_dhcpv6: false, server_duid: vec![ 0x00, 0x03, 0x00, 0x01, 0x02, 0x00, 0x5e, 0xaa, 0xbb, 0xcc, ], ipv6_start: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0x100), ipv6_lease_num: 1000, ipv6_dns: vec![Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888)], ipv6_lease_duration_secs: 86400, leases_file_v6: "leases6".to_string(), listen_addr_v6: SocketAddr::from(( Ipv6Addr::UNSPECIFIED, 547, )), } } /// First usable IPv4 address as a number (pool is /// `[ip_start_num, ip_start_num + lease_num)`). pub fn ip_start_num(&self) -> u32 { u32::from_be_bytes(self.ip_start.octets()) } /// First usable IPv6 address as a number (pool is /// `[ipv6_start_num, ipv6_start_num + ipv6_lease_num)`). pub fn ipv6_start_num(&self) -> u128 { u128::from(self.ipv6_start) } /// All broadcast destinations for replies, primary first, /// de-duplicated. Never empty (falls back to the primary alone). pub fn broadcasts(&self) -> Vec { let mut out = Vec::with_capacity(1 + self.extra_broadcast_ips.len()); out.push(self.broadcast_ip); for ip in &self.extra_broadcast_ips { if !out.contains(ip) { out.push(*ip); } } out } /// Load and parse the file at `path`. pub fn load(path: &str) -> Result { let text = std::fs::read_to_string(path) .map_err(|e| format!("cannot read {}: {}", path, e))?; Config::parse(&text) } /// Parse `dhcp.conf` text; starts from [`Config::defaults`] and applies /// each key in order. pub fn parse(text: &str) -> Result { let mut cfg = Config::defaults(); for (idx, raw_line) in text.lines().enumerate() { let line_no = idx + 1; let line = raw_line.trim(); if line.is_empty() || line.starts_with('#') || line.starts_with(';') { continue; } let eq = line.find('=').ok_or_else(|| { format!("{}: expected `key = value`, got {:?}", line_no, raw_line.trim()) })?; let key = line[..eq].trim().to_lowercase(); let mut value = line[eq + 1..].trim().to_string(); // strip trailing " # comment" (whitespace + '#'); a '#' glued to // the value is kept verbatim. if let Some(pos) = value.find(" #").or_else(|| value.find('\t').and_then(|p| { if value[p + 1..].starts_with('#') { Some(p) } else { None } })) { value.truncate(pos); value = value.trim_end().to_string(); } if key.is_empty() { return Err(format!("{}: empty key", line_no)); } if value.is_empty() { return Err(format!("{}: empty value for key {:?}", line_no, key)); } cfg.apply(&key, &value, line_no)?; } cfg.validate() } fn apply(&mut self, key: &str, value: &str, line: usize) -> Result<(), String> { let bad = |what: &str| format!("{}: invalid {} for key {:?}: {:?}", line, what, key, value); match key { "server_ip" => self.server_ip = value.parse().map_err(|_| bad("IPv4 address"))?, "ip_start" => self.ip_start = value.parse().map_err(|_| bad("IPv4 address"))?, "lease_num" => self.lease_num = value.parse().map_err(|_| bad("u32"))?, "subnet_mask" => self.subnet_mask = value.parse().map_err(|_| bad("IPv4 address"))?, "router_ip" => self.router_ip = value.parse().map_err(|_| bad("IPv4 address"))?, "dns_ips" => self.dns_ips = parse_ipv4_list(value).map_err(|_| bad("IPv4 list"))?, "enable_ntp" => self.enable_ntp = parse_bool(value).ok_or_else(|| bad("bool"))?, "ntp_ips" => self.ntp_ips = parse_ipv4_list(value).map_err(|_| bad("IPv4 list"))?, "enable_rapid_commit" => { self.enable_rapid_commit = parse_bool(value).ok_or_else(|| bad("bool"))? } "broadcast_ip" => { // Accepts a comma-separated list: the first address stays the // primary `broadcast_ip`, the rest extend // `extra_broadcast_ips` (de-duplicated by `broadcasts()`). let mut parts = value.split(','); let first = parts.next().ok_or_else(|| bad("IPv4 address"))?; self.broadcast_ip = first .trim() .parse() .map_err(|_| bad("IPv4 address"))?; for part in parts { self.extra_broadcast_ips.push( part.trim().parse().map_err(|_| bad("IPv4 address"))?, ); } } "extra_broadcast_ips" => { let mut list = parse_ipv4_list(value).map_err(|_| bad("IPv4 list"))?; self.extra_broadcast_ips.append(&mut list); } "lease_duration_secs" => { self.lease_duration_secs = value.parse().map_err(|_| bad("u32"))? } "leases_file" => self.leases_file = value.to_string(), "listen_addr" => { self.listen_addr = value.parse().map_err(|_| bad("socket address"))? } "enable_dhcpv6" => self.enable_dhcpv6 = parse_bool(value).ok_or_else(|| bad("bool"))?, "server_duid" => self.server_duid = parse_duid_hex(value).map_err(|_| bad("DUID hex"))?, "ipv6_start" => self.ipv6_start = value.parse().map_err(|_| bad("IPv6 address"))?, "ipv6_lease_num" => self.ipv6_lease_num = value.parse().map_err(|_| bad("u64"))?, "ipv6_dns" => self.ipv6_dns = parse_ipv6_list(value).map_err(|_| bad("IPv6 list"))?, "ipv6_lease_duration_secs" => { self.ipv6_lease_duration_secs = value.parse().map_err(|_| bad("u32"))? } "leases_file_v6" => self.leases_file_v6 = value.to_string(), "listen_addr_v6" => { self.listen_addr_v6 = value.parse().map_err(|_| bad("socket address"))? } // unknown keys are ignored for forward compatibility _ => {} } Ok(()) } /// Cross-field validation: pool ranges must not overflow the address /// space, and the DUID must fit RFC 8415 limits (1..=128 bytes). fn validate(self) -> Result { if self.server_duid.is_empty() || self.server_duid.len() > 128 { return Err("server_duid must be 1..=128 bytes".to_string()); } let v4_end = u64::from(self.ip_start_num()) + u64::from(self.lease_num); if v4_end > u64::from(u32::MAX) + 1 { return Err("ip_start + lease_num overflows IPv4 space".to_string()); } if (self.ipv6_start_num()) .checked_add(self.ipv6_lease_num as u128) .is_none() { return Err("ipv6_start + ipv6_lease_num overflows IPv6 space".to_string()); } Ok(self) } } fn parse_bool(s: &str) -> Option { match s.to_lowercase().as_str() { "true" | "yes" | "on" | "1" => Some(true), "false" | "no" | "off" | "0" => Some(false), _ => None, } } fn parse_ipv4_list(s: &str) -> Result, String> { s.split(',') .map(|part| { part.trim() .parse::() .map_err(|e| format!("{:?}: {}", part.trim(), e)) }) .collect() } fn parse_ipv6_list(s: &str) -> Result, String> { s.split(',') .map(|part| { part.trim() .parse::() .map_err(|e| format!("{:?}: {}", part.trim(), e)) }) .collect() } /// Parse opaque hex bytes (a DUID), accepting `00:03:00:01...`, `00-03-...` /// or plain `00030001...` (whitespace between bytes is also tolerated). /// /// Used for `server_duid` values and the DUID field of `leases6` lines. pub fn parse_duid_hex(s: &str) -> Result, String> { #[inline] fn hex_val(b: u8) -> Option { match b { b'0'..=b'9' => Some(b - b'0'), b'a'..=b'f' => Some(b - b'a' + 10), b'A'..=b'F' => Some(b - b'A' + 10), _ => None, } } let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len() / 2); let mut hi: Option = None; for &c in bytes { if c == b':' || c == b'-' || c == b' ' || c == b'\t' { continue; } let v = hex_val(c).ok_or_else(|| format!("invalid hex {:?}: {:?}", s, c as char))?; if let Some(h) = hi { out.push((h << 4) | v); hi = None; } else { hi = Some(v); } } if hi.is_some() || out.is_empty() { return Err(format!("odd-length hex: {:?}", s)); } Ok(out) }