From 9bc1a0941f294f90a441fbb2e1ba19d021393223 Mon Sep 17 00:00:00 2001 From: KKRainbow <5665404+KKRainbow@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:34:26 +0800 Subject: [PATCH] feat(dhcp): accept an IPv4 CIDR for the initial address subnet (#2686) * feat(dhcp): accept an IPv4 CIDR for the initial address subnet Accept booleans or an IPv4 CIDR in --dhcp, ET_DHCP, and TOML. Preserve existing boolean behavior and reject invalid subnets and /31 or /32. Carry the bootstrap subnet through management config conversions and runtime snapshots. Keep peer-advertised subnets authoritative and use 10.126.126.0/24 when no custom bootstrap subnet is supplied. Cover CLI overrides, config round trips, validation, address allocation, and subnet precedence. Update English and Chinese CLI help. * refactor(dhcp): select the bootstrap subnet during construction Read the optional DHCP CIDR once when creating the service and pass it to the existing allocator constructor. Restore reconciliation without rewriting the bootstrap subnet on every round. Initialize test configurations before constructing their DHCP services. The PatchConfig API does not expose the DHCP policy; management changes recreate the network instance. --- easytier-core/src/config/api.rs | 1 + easytier-core/src/config/api_input.rs | 53 ++++++++++++- easytier-core/src/config/dhcp.rs | 78 +++++++++++++++++++ easytier-core/src/config/mod.rs | 2 + easytier-core/src/config/runtime.rs | 3 + easytier-core/src/config/toml.rs | 80 ++++++++++++++++++-- easytier-core/src/gateway/dhcp.rs | 67 +++++++++++++--- easytier-core/src/instance/config.rs | 1 + easytier-core/src/wasi/web_client.rs | 3 + easytier-proto/proto/api_manage.proto | 2 + easytier-web/src/central_network/compiler.rs | 3 + easytier/locales/app.yml | 4 +- easytier/src/core.rs | 66 +++++++++++++++- 13 files changed, 340 insertions(+), 23 deletions(-) create mode 100644 easytier-core/src/config/dhcp.rs diff --git a/easytier-core/src/config/api.rs b/easytier-core/src/config/api.rs index c5b574c1..9b7056b9 100644 --- a/easytier-core/src/config/api.rs +++ b/easytier-core/src/config/api.rs @@ -16,6 +16,7 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig { let mut result = NetworkConfig { instance_id: Some(config.get_id().to_string()), dhcp: Some(config.get_dhcp()), + dhcp_ipv4_cidr: config.get_dhcp_config().cidr().map(|cidr| cidr.to_string()), ..Default::default() }; diff --git a/easytier-core/src/config/api_input.rs b/easytier-core/src/config/api_input.rs index 688e9ee0..4b2b9661 100644 --- a/easytier-core/src/config/api_input.rs +++ b/easytier-core/src/config/api_input.rs @@ -250,7 +250,12 @@ impl NetworkConfigExt for NetworkConfig { .with_context(|| format!("failed to parse instance id: {:?}", self.instance_id))?, ); cfg.set_hostname(self.hostname.clone()); - cfg.set_dhcp(self.dhcp.unwrap_or_default()); + cfg.set_dhcp(self.dhcp.unwrap_or(self.dhcp_ipv4_cidr.is_some())); + if cfg.get_dhcp() + && let Some(cidr) = &self.dhcp_ipv4_cidr + { + cfg.set_dhcp_config(super::DhcpConfig::from_cidr(cidr)?); + } cfg.set_inst_name(self.network_name.clone().unwrap_or_default()); // The web UI does not expose credential inputs directly, but imported/saved @@ -551,6 +556,7 @@ impl NetworkConfigExt for NetworkConfig { } result.dhcp = Some(config.get_dhcp()); + result.dhcp_ipv4_cidr = config.get_dhcp_config().cidr().map(|cidr| cidr.to_string()); let network_identity = config.get_network_identity(); result.network_name = Some(network_identity.network_name.clone()); @@ -700,6 +706,51 @@ mod tests { } } + #[test] + fn dhcp_cidr_survives_api_and_toml_round_trips() { + let mut input = standalone_config(); + input.dhcp_ipv4_cidr = Some("192.168.100.0/24".to_owned()); + for enabled in [None, Some(true), Some(false)] { + input.dhcp = enabled; + let config = input.gen_config().unwrap(); + assert_eq!(config.get_dhcp(), enabled.unwrap_or(true)); + let readback = NetworkConfig::new_from_config(config.clone()).unwrap(); + let expected_cidr = if enabled == Some(false) { + None + } else { + input.dhcp_ipv4_cidr.clone() + }; + assert_eq!(readback.dhcp_ipv4_cidr, expected_cidr); + assert_eq!( + TomlConfigLoader::new_from_str(&config.dump()) + .unwrap() + .get_dhcp_config(), + readback.gen_config().unwrap().get_dhcp_config() + ); + #[cfg(feature = "web-client")] + assert_eq!( + super::super::api::network_config_from_toml(&config).dhcp_ipv4_cidr, + expected_cidr + ); + } + } + + #[test] + fn dhcp_api_rejects_invalid_or_unusable_subnets() { + let mut input = standalone_config(); + for value in [ + "true", + "false", + "yes", + "::/64", + "192.168.100.0/31", + "192.168.100.1/32", + ] { + input.dhcp_ipv4_cidr = Some(value.to_owned()); + assert!(input.gen_config().is_err(), "accepted {value}"); + } + } + /// The management API's name for a flag is the schema's to state, and the /// projection is what actually names the fields. Turning the flag the /// annotation names must turn the field it points at, in the direction the diff --git a/easytier-core/src/config/dhcp.rs b/easytier-core/src/config/dhcp.rs new file mode 100644 index 00000000..97e3fc33 --- /dev/null +++ b/easytier-core/src/config/dhcp.rs @@ -0,0 +1,78 @@ +use std::{fmt, str::FromStr}; + +use anyhow::Context; +use cidr::Ipv4Cidr; +use serde::{Deserialize, Deserializer, Serialize, de}; + +/// DHCP enablement and an optional subnet used before peers advertise one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(untagged)] +pub enum DhcpConfig { + Enabled(bool), + Cidr(Ipv4Cidr), +} + +impl Default for DhcpConfig { + fn default() -> Self { + Self::Enabled(false) + } +} + +impl DhcpConfig { + pub fn enabled(self) -> bool { + !matches!(self, Self::Enabled(false)) + } + + pub fn cidr(self) -> Option { + match self { + Self::Enabled(_) => None, + Self::Cidr(cidr) => Some(cidr), + } + } + + pub fn from_cidr(value: &str) -> anyhow::Result { + let cidr: Ipv4Cidr = value + .parse() + .with_context(|| format!("invalid DHCP IPv4 CIDR: {value}"))?; + anyhow::ensure!( + cidr.network_length() <= 30, + "DHCP IPv4 CIDR must have usable host addresses (prefix length 0..=30)" + ); + Ok(Self::Cidr(cidr)) + } +} + +impl FromStr for DhcpConfig { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value.parse::() { + Ok(enabled) => Ok(Self::Enabled(enabled)), + Err(_) => Self::from_cidr(value), + } + } +} + +impl<'de> Deserialize<'de> for DhcpConfig { + fn deserialize>(deserializer: D) -> Result { + struct DhcpVisitor; + + impl de::Visitor<'_> for DhcpVisitor { + type Value = DhcpConfig; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a boolean or an IPv4 CIDR with prefix length 0..=30") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(DhcpConfig::Enabled(value)) + } + + fn visit_str(self, value: &str) -> Result { + DhcpConfig::from_cidr(value).map_err(E::custom) + } + } + + deserializer.deserialize_any(DhcpVisitor) + } +} diff --git a/easytier-core/src/config/mod.rs b/easytier-core/src/config/mod.rs index ce008b73..4b960a7f 100644 --- a/easytier-core/src/config/mod.rs +++ b/easytier-core/src/config/mod.rs @@ -10,12 +10,14 @@ pub mod api_input; target_os = "unknown" ))] mod browser; +mod dhcp; mod encryption; pub mod gateway; pub mod peers; pub mod runtime; pub mod toml; +pub use dhcp::DhcpConfig; pub use encryption::EncryptionAlgorithm; pub(crate) const DEFAULT_UDP_STUN_SERVERS: &[&str] = &[ diff --git a/easytier-core/src/config/runtime.rs b/easytier-core/src/config/runtime.rs index d7b5cf6e..854cf275 100644 --- a/easytier-core/src/config/runtime.rs +++ b/easytier-core/src/config/runtime.rs @@ -16,6 +16,8 @@ use super::{ pub struct CoreRuntimeConfig { pub acl: AclRuleConfig, pub dhcp_ipv4: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dhcp_ipv4_cidr: Option, pub gateway: GatewayRuntimeConfig, #[serde(default, skip_serializing_if = "Option::is_none")] pub manual_routes: Option>, @@ -30,6 +32,7 @@ impl Default for CoreRuntimeConfig { Self { acl: AclRuleConfig::default(), dhcp_ipv4: false, + dhcp_ipv4_cidr: None, gateway: GatewayRuntimeConfig::default(), manual_routes: None, proxy: ProxyRuntimeConfig::default(), diff --git a/easytier-core/src/config/toml.rs b/easytier-core/src/config/toml.rs index c0835fea..8a1ed6c6 100644 --- a/easytier-core/src/config/toml.rs +++ b/easytier-core/src/config/toml.rs @@ -6,7 +6,7 @@ use std::{ sync::{Arc, Mutex}, }; -use super::normalize_secure_mode_config; +use super::{DhcpConfig, normalize_secure_mode_config}; pub use super::{EncryptionAlgorithm, gateway::PortForwardConfig}; use anyhow::Context; #[cfg(feature = "rich-config-errors")] @@ -53,8 +53,16 @@ pub trait ConfigLoader: Send + Sync { fn get_ipv6_public_addr_prefix(&self) -> Option; fn set_ipv6_public_addr_prefix(&self, prefix: Option); - fn get_dhcp(&self) -> bool; - fn set_dhcp(&self, dhcp: bool); + fn get_dhcp_config(&self) -> DhcpConfig; + fn set_dhcp_config(&self, dhcp: DhcpConfig); + + fn get_dhcp(&self) -> bool { + self.get_dhcp_config().enabled() + } + + fn set_dhcp(&self, dhcp: bool) { + self.set_dhcp_config(DhcpConfig::Enabled(dhcp)); + } fn add_proxy_cidr( &self, @@ -397,7 +405,7 @@ struct Config { ipv6_public_addr_provider: Option, ipv6_public_addr_auto: Option, ipv6_public_addr_prefix: Option, - dhcp: Option, + dhcp: Option, network_identity: Option, listeners: Option>, mapped_listeners: Option>, @@ -725,11 +733,11 @@ impl ConfigLoader for TomlConfig { prefix.map(|prefix| prefix.to_string()); } - fn get_dhcp(&self) -> bool { + fn get_dhcp_config(&self) -> DhcpConfig { self.config.lock().unwrap().dhcp.unwrap_or_default() } - fn set_dhcp(&self, dhcp: bool) { + fn set_dhcp_config(&self, dhcp: DhcpConfig) { self.config.lock().unwrap().dhcp = Some(dhcp); } @@ -1065,6 +1073,62 @@ pub type TomlConfigLoader = TomlConfig; mod tests { use super::*; + #[test] + fn dhcp_accepts_booleans_and_ipv4_subnets() { + assert!(!TomlConfig::default().get_dhcp()); + for (value, expected) in [ + ("false", DhcpConfig::Enabled(false)), + ("true", DhcpConfig::Enabled(true)), + ( + "'192.168.100.0/24'", + DhcpConfig::Cidr("192.168.100.0/24".parse().unwrap()), + ), + ( + "'192.168.100.0/30'", + DhcpConfig::Cidr("192.168.100.0/30".parse().unwrap()), + ), + ( + "'0.0.0.0/0'", + DhcpConfig::Cidr("0.0.0.0/0".parse().unwrap()), + ), + ] { + let config = TomlConfig::new_from_str(&format!("dhcp = {value}")).unwrap(); + assert_eq!(config.get_dhcp_config(), expected); + assert_eq!(config.get_dhcp(), expected.enabled()); + #[cfg(feature = "config-write")] + assert_eq!( + TomlConfig::new_from_str(&config.dump()) + .unwrap() + .get_dhcp_config(), + expected + ); + config.set_dhcp(false); + assert_eq!(config.get_dhcp_config(), DhcpConfig::Enabled(false)); + } + } + + #[test] + fn dhcp_rejects_invalid_or_unusable_subnets() { + for value in [ + "'yes'", + "'true'", + "'::/64'", + "'192.168.100.0/31'", + "'192.168.100.1/32'", + "'192.168.100.1/24'", + "'192.168.100.0/33'", + "'192.168.100.1'", + "123", + "{}", + "[]", + ] { + assert!( + TomlConfig::new_from_str(&format!("dhcp = {value}")).is_err(), + "accepted {value}" + ); + } + } + #[test] fn parse_error_preserves_source_and_location() { let error = @@ -1073,7 +1137,7 @@ mod tests { assert!(display.contains("fixture.toml")); assert!(display.contains("dhcp = \"yes\"")); - assert!(display.contains("invalid type: string")); + assert!(display.contains("invalid DHCP IPv4 CIDR")); assert!( error .chain() @@ -1890,7 +1954,7 @@ mod diagnostic_compatibility_tests { assert!(error.contains("dhcp = \"yes\"")); assert!(error.contains('^')); - assert!(error.contains("invalid type: string")); + assert!(error.contains("invalid DHCP IPv4 CIDR")); } #[cfg(feature = "rich-config-errors")] diff --git a/easytier-core/src/gateway/dhcp.rs b/easytier-core/src/gateway/dhcp.rs index 8ad8bb6f..588634e3 100644 --- a/easytier-core/src/gateway/dhcp.rs +++ b/easytier-core/src/gateway/dhcp.rs @@ -262,12 +262,18 @@ impl DhcpIpv4Service { runtime_config: CoreRuntimeConfigStore, host: Arc, ) -> Arc { + let default_subnet = runtime_config + .snapshot() + .services + .dhcp_ipv4_cidr + .map(|cidr| cidr.first()) + .unwrap_or_else(|| { + Ipv4Inet::new(std::net::Ipv4Addr::new(10, 126, 126, 0), 24) + .expect("valid bootstrap subnet") + }); Arc::new(Self { operation: tokio::sync::Mutex::new(()), - allocator: std::sync::Mutex::new(DhcpIpv4Allocator::new( - Ipv4Inet::new(std::net::Ipv4Addr::new(10, 126, 126, 0), 24) - .expect("valid bootstrap subnet"), - )), + allocator: std::sync::Mutex::new(DhcpIpv4Allocator::new(default_subnet)), route_source, runtime_config, host, @@ -434,9 +440,13 @@ mod tests { fn service( snapshot: DhcpIpv4RouteSnapshot, host: Arc, + cidr: Option<&str>, ) -> (Arc, CoreRuntimeConfigStore) { let runtime_config = CoreRuntimeConfigStore::new( - crate::config::runtime::CoreRuntimeConfig::default(), + crate::config::runtime::CoreRuntimeConfig { + dhcp_ipv4_cidr: cidr.map(|cidr| cidr.parse().unwrap()), + ..Default::default() + }, Arc::new(crate::config::peers::PeerRuntimeSnapshot::default()), ); *host.runtime_config.lock().unwrap() = Some(runtime_config.clone()); @@ -632,15 +642,44 @@ mod tests { #[tokio::test] async fn service_bootstraps_ipv4_when_no_peer_has_one() { - check_service_bootstrap(true).await; + check_service_bootstrap(true, None).await; } #[tokio::test] async fn service_bootstraps_ipv4_without_other_peers() { - check_service_bootstrap(false).await; + check_service_bootstrap(false, None).await; } - async fn check_service_bootstrap(has_routes: bool) { + #[tokio::test] + async fn service_bootstraps_ipv4_in_configured_subnet() { + for cidr in [ + "192.168.100.0/24", + "10.20.0.0/16", + "192.168.100.0/30", + "0.0.0.0/0", + ] { + for has_routes in [true, false] { + check_service_bootstrap(has_routes, Some(cidr)).await; + } + } + } + + #[tokio::test] + async fn service_prefers_peer_subnet_over_configured_bootstrap_subnet() { + let host = Arc::new(RecordingHost::default()); + let (service, _runtime_config) = service( + DhcpIpv4RouteSnapshot { + has_routes: true, + used_ipv4: HashSet::from(["10.20.0.1/16".parse().unwrap()]), + }, + host, + Some("192.168.100.0/24"), + ); + service.reconcile_once().await; + assert_eq!(service.current(), Some("10.20.0.2/16".parse().unwrap())); + } + + async fn check_service_bootstrap(has_routes: bool, cidr: Option<&str>) { let host = Arc::new(RecordingHost::default()); let (service, runtime_config) = service( DhcpIpv4RouteSnapshot { @@ -648,6 +687,7 @@ mod tests { used_ipv4: HashSet::new(), }, host.clone(), + cidr, ); for _ in 0..BOOTSTRAP_WAIT_ROUNDS { @@ -672,8 +712,13 @@ mod tests { let address = service.current().expect("bootstrap address"); assert_eq!( address.network(), - "10.126.126.0/24".parse::().unwrap().network() + cidr.unwrap_or("10.126.126.0/24") + .parse::() + .unwrap() + .network() ); + assert_ne!(address.address(), address.first_address()); + assert_ne!(address.address(), address.last_address()); assert_eq!(*host.changes.lock().unwrap(), [(None, Some(address))]); assert_eq!( runtime_config.snapshot().peer.runtime.core.routes.ipv4, @@ -696,6 +741,7 @@ mod tests { used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), }, host.clone(), + None, ); assert!(service.reconcile_once().await); @@ -729,6 +775,7 @@ mod tests { used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), }, host.clone(), + None, ); service.reconcile_once().await; @@ -752,6 +799,7 @@ mod tests { used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), }, host.clone(), + None, ); service.reconcile_once().await; @@ -775,6 +823,7 @@ mod tests { used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), }, host.clone(), + None, ); service.reconcile_once().await; host.interface_closed.store(true, Ordering::Release); diff --git a/easytier-core/src/instance/config.rs b/easytier-core/src/instance/config.rs index 7bfdd23a..c140fdc6 100644 --- a/easytier-core/src/instance/config.rs +++ b/easytier-core/src/instance/config.rs @@ -296,6 +296,7 @@ impl CoreInstanceConfig { whitelist_priority: None, }, dhcp_ipv4: config.get_dhcp(), + dhcp_ipv4_cidr: config.get_dhcp_config().cidr(), gateway: GatewayRuntimeConfig { socks5_bind, port_forwards: if host.ignore_unsupported_config && !host.gateway_enabled { diff --git a/easytier-core/src/wasi/web_client.rs b/easytier-core/src/wasi/web_client.rs index 1b814938..415783f7 100644 --- a/easytier-core/src/wasi/web_client.rs +++ b/easytier-core/src/wasi/web_client.rs @@ -84,6 +84,7 @@ fn hosted_network_config(config: &NetworkConfig) -> NetworkConfig { NetworkConfig { instance_id: config.instance_id.clone(), dhcp: config.dhcp, + dhcp_ipv4_cidr: config.dhcp_ipv4_cidr.clone(), virtual_ipv4: config.virtual_ipv4.clone(), network_length: config.network_length, hostname: config.hostname.clone(), @@ -403,6 +404,7 @@ mod tests { let original = NetworkConfig { instance_id: Some(uuid::Uuid::new_v4().to_string()), dhcp: Some(true), + dhcp_ipv4_cidr: Some("192.168.100.0/24".to_owned()), network_name: Some("network".to_owned()), network_secret: Some("secret".to_owned()), networking_method: Some(NetworkingMethod::Manual as i32), @@ -452,6 +454,7 @@ mod tests { let hosted = hosted_network_config(&original); + assert_eq!(hosted.dhcp_ipv4_cidr, original.dhcp_ipv4_cidr); assert_eq!( hosted.peer_urls, vec!["tcp://peer.example:11010".to_owned()] diff --git a/easytier-proto/proto/api_manage.proto b/easytier-proto/proto/api_manage.proto index af4f86d5..ab559969 100644 --- a/easytier-proto/proto/api_manage.proto +++ b/easytier-proto/proto/api_manage.proto @@ -106,6 +106,8 @@ message NetworkConfig { optional VpnPortalConfig vpn_portal_config = 69; repeated ManagedCredentialConfig managed_credentials = 71; optional bool prefer_peer_relay = 72; + // Bootstrap subnet used when DHCP has not learned a subnet from peers. + optional string dhcp_ipv4_cidr = 74; } message ManagedCredentialConfig { diff --git a/easytier-web/src/central_network/compiler.rs b/easytier-web/src/central_network/compiler.rs index 872a617b..61509f76 100644 --- a/easytier-web/src/central_network/compiler.rs +++ b/easytier-web/src/central_network/compiler.rs @@ -193,6 +193,7 @@ fn apply_member_override( let mut merged = config_override.clone(); merged.instance_id = base.instance_id; merged.dhcp = base.dhcp; + merged.dhcp_ipv4_cidr = base.dhcp_ipv4_cidr; merged.virtual_ipv4 = base.virtual_ipv4; merged.network_length = base.network_length; merged.hostname = base.hostname; @@ -1254,6 +1255,7 @@ mod tests { network_secret: Some("tampered".to_owned()), hostname: Some("tampered".to_owned()), virtual_ipv4: Some("192.168.1.1".to_owned()), + dhcp_ipv4_cidr: Some("192.168.100.0/24".to_owned()), managed_credentials: Vec::new(), acl: None, ..Default::default() @@ -1284,6 +1286,7 @@ mod tests { Some("10.42.0.1") ); assert_eq!(permanent.network_config.managed_credentials.len(), 2); + assert_eq!(permanent.network_config.dhcp_ipv4_cidr, None); assert!(permanent.network_config.acl.is_some()); } } diff --git a/easytier/locales/app.yml b/easytier/locales/app.yml index 50f4a996..cf442b04 100644 --- a/easytier/locales/app.yml +++ b/easytier/locales/app.yml @@ -50,8 +50,8 @@ core_clap: en: "manually specify the public IPv6 subnet to share, instead of auto-detecting from system routes" zh-CN: "手动指定要共享的公网 IPv6 子网,不自动从系统路由检测" dhcp: - en: "automatically determine and set IP address by Easytier, and the IP address starts from 10.0.0.1 by default. Warning, if there is an IP conflict in the network when using DHCP, the IP will be automatically changed." - zh-CN: "由Easytier自动确定并设置IP地址,默认从10.0.0.1开始。警告:在使用DHCP时,如果网络中出现IP冲突,IP将自动更改。" + en: "automatically assign an IPv4 address. Accepts true, false, or an IPv4 CIDR (e.g. --dhcp 192.168.100.0/24) for the initial subnet, defaulting to 10.126.126.0/24. Subnets advertised by peers take priority. Prefix lengths /31 and /32 are not supported. IP conflicts automatically trigger reassignment." + zh-CN: "自动分配 IPv4 地址。支持 true、false 或 IPv4 CIDR(如 --dhcp 192.168.100.0/24)指定初始网段,默认使用 10.126.126.0/24。优先使用对等节点公布的网段,不支持 /31 和 /32。IP 冲突时会自动重新分配。" peers: en: "peers to connect initially" zh-CN: "最初要连接的对等节点" diff --git a/easytier/src/core.rs b/easytier/src/core.rs index 70bba2a1..c8b7b340 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -20,7 +20,7 @@ use crate::{ use anyhow::Context; use cidr::IpCidr; use clap::{Arg, ArgMatches, CommandFactory, FromArgMatches, Parser}; -use easytier_core::config::{EncryptionAlgorithm, normalize_secure_mode_config}; +use easytier_core::config::{DhcpConfig, EncryptionAlgorithm, normalize_secure_mode_config}; use guarden::defer; use prost_types::field_descriptor_proto::Type; use rust_i18n::t; @@ -219,7 +219,7 @@ struct NetworkOptions { num_args = 0..=1, default_missing_value = "true" )] - dhcp: Option, + dhcp: Option, #[arg( short, @@ -689,7 +689,7 @@ impl NetworkOptions { } if let Some(dhcp) = self.dhcp { - cfg.set_dhcp(dhcp); + cfg.set_dhcp_config(dhcp); } if let Some(ipv4) = &self.ipv4 { @@ -1601,6 +1601,66 @@ mod tests { } } + #[test] + fn dhcp_cli_accepts_optional_bool_or_cidr_and_overrides_config() { + for (args, expected) in [ + (vec![], DhcpConfig::Cidr("10.20.0.0/16".parse().unwrap())), + (vec!["--dhcp"], DhcpConfig::Enabled(true)), + (vec!["-d"], DhcpConfig::Enabled(true)), + (vec!["--dhcp", "true"], DhcpConfig::Enabled(true)), + (vec!["--dhcp=false"], DhcpConfig::Enabled(false)), + (vec!["--dhcp", "false"], DhcpConfig::Enabled(false)), + ( + vec!["--dhcp", "192.168.100.0/24"], + DhcpConfig::Cidr("192.168.100.0/24".parse().unwrap()), + ), + ( + vec!["-d", "192.168.100.0/30"], + DhcpConfig::Cidr("192.168.100.0/30".parse().unwrap()), + ), + ( + vec!["--dhcp=192.168.100.0/24"], + DhcpConfig::Cidr("192.168.100.0/24".parse().unwrap()), + ), + ( + vec!["--dhcp", "--hostname", "dhcp-node"], + DhcpConfig::Enabled(true), + ), + ] { + let config = TomlConfigLoader::new_from_str("dhcp = '10.20.0.0/16'").unwrap(); + let cli = Cli::try_parse_from(std::iter::once("easytier-core").chain(args)).unwrap(); + cli.network_options.merge_into(&config).unwrap(); + assert_eq!(config.get_dhcp_config(), expected); + let normalized = + easytier_core::instance::CoreInstanceConfig::from_toml(&config).unwrap(); + assert_eq!( + normalized.connectivity.runtime.dhcp_ipv4, + expected.enabled() + ); + assert_eq!( + normalized.connectivity.runtime.dhcp_ipv4_cidr, + expected.cidr() + ); + } + } + + #[test] + fn dhcp_cli_rejects_invalid_or_unusable_subnets() { + for value in [ + "yes", + "::/64", + "192.168.100.0/31", + "192.168.100.1/32", + "192.168.100.0/33", + "192.168.100.1/24", + ] { + assert!( + Cli::try_parse_from(["easytier-core", "--dhcp", value]).is_err(), + "accepted {value}" + ); + } + } + #[test] fn test_network_options_merge_preserves_credential_identity() { let cfg = TomlConfigLoader::new_from_str(