mirror of
https://github.com/pj1234678/RustyDHCP.git
synced 2026-10-16 01:03:12 +08:00
324 lines
13 KiB
Rust
324 lines
13 KiB
Rust
//! `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<Ipv4Addr>,
|
|
/// 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<Ipv4Addr>,
|
|
/// 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<Ipv4Addr>,
|
|
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<u8>,
|
|
/// 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<Ipv6Addr>,
|
|
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<Ipv4Addr> {
|
|
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<Config, String> {
|
|
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<Config, String> {
|
|
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<Config, String> {
|
|
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<bool> {
|
|
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<Vec<Ipv4Addr>, String> {
|
|
s.split(',')
|
|
.map(|part| {
|
|
part.trim()
|
|
.parse::<Ipv4Addr>()
|
|
.map_err(|e| format!("{:?}: {}", part.trim(), e))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn parse_ipv6_list(s: &str) -> Result<Vec<Ipv6Addr>, String> {
|
|
s.split(',')
|
|
.map(|part| {
|
|
part.trim()
|
|
.parse::<Ipv6Addr>()
|
|
.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<Vec<u8>, String> {
|
|
#[inline]
|
|
fn hex_val(b: u8) -> Option<u8> {
|
|
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<u8> = 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)
|
|
}
|